From 6b57302885bd3532011ea09e28ba7ee4e999e19c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:18:37 +0400 Subject: [PATCH] badge service: credit a store receipt only when the verified transaction is the one claimed; answer internal when a request throws --- .../src/BadgeService/Service.hs | 21 +++++++++---------- .../src/BadgeService/StoreReceipts.hs | 3 ++- docs/protocol/badges-rpc.md | 4 ++-- tests/BadgeTests.hs | 4 ++-- tests/Bots/BadgeService/BotTests.hs | 10 ++++++++- tests/Bots/BadgeService/FakeStore.hs | 19 ++++++++++------- 6 files changed, 37 insertions(+), 24 deletions(-) diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index 3ccf22cf90..a101453395 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -24,6 +24,7 @@ where import BadgeService.Catalog (StoreProduct (..), defaultCatalog, storeProduct) import BadgeService.Config (BadgeIssuerKey (..), ServiceConfig (..), readServiceConfig) +import BadgeService.Log (logError, logInfo, logWarn) import BadgeService.Options import BadgeService.Poller (newPollerEnv, newReadHints, runPoller) import BadgeService.Providers.BTCPay (btcpayProvider) @@ -36,8 +37,6 @@ import BadgeService.Waiters (Waiters, newWaiters) import BadgeService.Web.Server (exportWebapp, newWebEnv, runWebListener) import Control.Applicative (optional) import Control.Concurrent.STM -import BadgeService.Log (logError, logInfo, logWarn) -import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try) import Control.Monad import Control.Monad.IO.Class (liftIO) import qualified Data.Aeson as J @@ -74,7 +73,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (bbsPublicKey) import Simplex.Messaging.Encoding.String (TextEncoding, strEncode, textDecode, textEncode) -import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow) +import Simplex.Messaging.Util (catchOwn', raceAny_, safeDecodeUtf8, tshow) import Simplex.Messaging.Version (isCompatible) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) @@ -295,15 +294,10 @@ processChatRedeems key env = do (ct, msg) <- atomically $ readTQueue $ chatRedeemQ env survive "a chat redemption" $ chatRedeem key cc ct msg --- | Asynchronous exceptions are rethrown, since that is how the race stops this thread. The +-- | Cancellation is rethrown, since that is how the race stops this thread. The -- exception itself is not logged: it can quote the request, which carries codes and store receipts. survive :: T.Text -> IO () -> IO () -survive what action = - (try action :: IO (Either SomeException ())) >>= \case - Right () -> pure () - Left e -> case fromException e :: Maybe SomeAsyncException of - Just _ -> throwIO e - Nothing -> logError $ "badge service: " <> what <> " failed; the next one will be handled" +survive what action = action `catchOwn'` \_ -> logError $ "badge service: " <> what <> " failed; the next one will be handled" -- | Here the service generates the master key and can link the badge, so [dev] chat_redeem gates this. chatRedeem :: BadgeIssuerKey -> ChatController -> Contact -> T.Text -> IO () @@ -336,7 +330,9 @@ handleServiceRequest :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Use handleServiceRequest key verifier cc User {userId} reqId sigKey reqData = do let reqIdT = safeDecodeUtf8 (strEncode reqId) logInfo $ "badge service request " <> reqIdT - resp <- badgeServiceResponse key verifier cc sigKey reqData + resp <- + badgeServiceResponse key verifier cc sigKey reqData `catchOwn'` \_ -> + logError ("badge service request " <> reqIdT <> " failed") $> errorResponse BSEInternal sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case Right _ -> pure () Left e -> logError $ "badge service response failed for " <> reqIdT <> ": " <> tshow e @@ -455,6 +451,9 @@ purchaseWithReceipt key cc purchaseKey masterKey StoreReceipt {provider, provide | otherwise -> verifyReceipt >>= \case Left refusal -> storeRefusalResponse refusal + -- the claim was read from the evidence before it was verified, so it must name the transaction the store vouched for + Right StoreTransaction {transactionRef} + | transactionRef /= providerRef -> storeRefusalResponse $ SRVerifierFailed "verified a transaction other than the one claimed" Right StoreTransaction {environment = SETest} -> storeRefusalResponse $ SRInvalid "test purchase" -- another key's claim is told only once the store vouched for the receipt, or it would reveal which transactions were credited Right tx -> case claim of diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs index b6400cd66e..044ad29849 100644 --- a/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs @@ -27,7 +27,8 @@ import System.Timeout (timeout) -- | What a store vouches for about one completed transaction. data StoreTransaction = StoreTransaction - { productId :: Text, + { transactionRef :: Text, -- from what was verified: Apple's transactionId, googlePurchaseRef of the token asked about + productId :: Text, quantity :: Int, environment :: StoreEnvironment, paid :: Maybe (CurrencyAmount, Text) -- in minor units; Google's purchase record carries no price diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 611bf5a76c..a2343de443 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -23,7 +23,7 @@ A timeout hides the outcome, so the client repeats the identical signed request - `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing; a store payment credited to another key, `receipt_used`. - `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing. - `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. -- `purchaseBadge` with a `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. +- `purchaseBadge` with the recovery `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. ## Commands @@ -32,7 +32,7 @@ No command lets the client state what is signed. The tier is that of whatever fu - `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config. - `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr). The response holds the generic `invoice` — `invoiceId`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `currency`, `expiresAt`, and `paymentTo` (`url` for card; `address` and `cryptoAmount` for crypto) — beside the badge part, `badgeType` and `months`. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`. - `redeemBadgeCode` → `badgeCredential` — redeems a code, records the credit, and issues the first credential, in one round trip. It carries `masterKey` and `code` and no `badgeRequest`: a code states no tier and no expiry, so the credential is what reports them. Errors: `code_invalid` for an unknown or malformed code, `code_used` when another key redeemed it, `code_expired` past a redemption deadline. -- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` product id and token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. It carries `masterKey` and no `badgeRequest`: the tier and months are those of the product the funding proves, and the expiry is the one every credential for that week shares, so the client has nothing to state. Errors: `receipt_invalid` for a receipt the store does not vouch for, whether forged, malformed, unknown or refunded, and for a test purchase, which cost nothing; `receipt_used` when another key was credited with it; `product_unavailable` for a product that grants no badge; `payment_pending` while the store has not settled it and `provider_unavailable` while the store cannot be asked, both recording nothing; `provider_not_configured` when this deployment has no verifier for the store, recording nothing and terminal for the request, since retrying cannot deploy one. A receipt already credited to the signing key is answered from the service's record without asking the store. The client does not finish the store transaction, and keeps the keys it signed with, on `product_unavailable` or `provider_not_configured`: the purchase is paid, and a product the service does not price or a store it cannot verify is its operator's error, so the transaction is presented again at the next trigger rather than retried now. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. +- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` product id and token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. It carries `masterKey` and no `badgeRequest`: the tier and months are those of the product the funding proves, and the expiry is the one every credential for that week shares, so the client has nothing to state. Errors: `receipt_invalid` for a receipt the store does not vouch for, whether forged, malformed, unknown or refunded, and for a test purchase, which cost nothing; `receipt_used` when another key was credited with it; `product_unavailable` for a product that grants no badge; `payment_pending` while the store has not settled it and `provider_unavailable` while the store cannot be asked, both recording nothing; `provider_not_configured` when this deployment has no verifier for the store, recording nothing and terminal for the request, since retrying cannot deploy one. A receipt already credited to the signing key is answered from the service's record without asking the store. A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries the same id. The client does not finish the store transaction, and keeps the keys it signed with, on `product_unavailable` or `provider_not_configured`: the purchase is paid, and a product the service does not price or a store it cannot verify is its operator's error, so the transaction is presented again at the next trigger rather than retried now. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. - Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them. - `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. Its `badgeRequest` is to be dropped (see above). - `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. It carries `balance` alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the `sundayAfter` of the period issued. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13). diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index 0e7726e428..9618b87a1c 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -10,11 +10,11 @@ module BadgeTests (badgeTests) where import BadgeService.Service (badgeErrorRetryAfter, shownServiceRequest, survive) +import BadgeService.StoreReceipts (StoreReceipt (..), StoreRefusal (..), StoreVerifier (..), storeReceipt) import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) -import Control.Exception (SomeAsyncException, SomeException, catch, fromException, throwIO) -import BadgeService.StoreReceipts (StoreReceipt (..), StoreRefusal (..), StoreVerifier (..), storeReceipt) import Control.Concurrent.STM (atomically) +import Control.Exception (SomeAsyncException, SomeException, catch, fromException, throwIO) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Base64.URL as B64U import Data.Map.Strict (Map) diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index 3eefac49c8..d3ae71dff0 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -129,6 +129,7 @@ badgeServiceTests = do it "should answer a throwing Apple verifier as internal, and a failing or hanging Google one as retryable" testStoreVerifierFailures it "should credit a transaction claimed twice at once only once" testStoreClaimRace it "should refuse a store with no verifier with no retry, and the client should keep its keys" testPurchaseWithNoVerifier + it "should credit nothing when the verified transaction is not the one the evidence names" testStoreVerifiedOtherTransaction it "should refuse a store purchase whose purchaseKey is not the verified signer" testStorePurchaseKeyMismatch it "should redeem a Play purchase into a badge, and replay it as the same badge" testPurchaseBadge it "should redeem an App Store purchase by its JWS" testPurchaseBadgeAppStore @@ -1559,6 +1560,13 @@ testStoreVerifierFailures ps = answer (googlePayment "badge_supporter_01" googleHangingToken) >>= (`shouldSatisfy` \(code, retryAfter) -> code == BSEProviderUnavailable && isJust retryAfter) nothingPurchased cc +testStoreVerifiedOtherTransaction :: HasCallStack => TestParams -> IO () +testStoreVerifiedOtherTransaction ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc, bsStore = FakeStore {appleMisnamedJWS}} -> do + (purchaseKey, masterKey) <- newPurchaseKeys + refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey SPApple {jws = appleMisnamedJWS}) `shouldReturn` (BSEInternal, Nothing) + nothingPurchased cc + testPurchaseWithNoVerifier :: HasCallStack => TestParams -> IO () testPurchaseWithNoVerifier ps = withBadgeServiceVerifier ps (const noStoreVerifier) $ \env@BadgeServiceEnv {bsClientCfg, bsController = cc} -> do @@ -1580,7 +1588,7 @@ testStoreClaimRace ps = let claim paymentId purchaseKey masterKey = withDB' "claim" cc $ \db -> createStorePurchase db NewStorePurchase {paymentId, provider = PPGoogle, providerRef = "ref", paid = Nothing, purchaseKey, masterKey, badgeType = BTSupporter} now - -- both past the read before either wrote, as two requests signing at once are + -- the second insert meets the first's claim, as a request that read before the first wrote would claim "p1" firstKey firstMasterKey >>= (`shouldSatisfy` either (const False) isJust) claim "p2" otherKey otherMasterKey `shouldReturn` Right Nothing rowCount cc "sx_badge_service_payments" `shouldReturn` 1 diff --git a/tests/Bots/BadgeService/FakeStore.hs b/tests/Bots/BadgeService/FakeStore.hs index ed7e41a468..c47e4d8bc3 100644 --- a/tests/Bots/BadgeService/FakeStore.hs +++ b/tests/Bots/BadgeService/FakeStore.hs @@ -28,7 +28,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.Text (Text) -import Simplex.Chat.PaymentService (ServicePayment (..)) +import Simplex.Chat.PaymentService (ServicePayment (..), googlePurchaseRef) import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) import Simplex.Messaging.Util (safeDecodeUtf8) import System.FilePath (()) @@ -43,6 +43,7 @@ data FakeStore = FakeStore appleLegendJWS :: Text, appleSandboxJWS :: Text, appleThrowingJWS :: Text, + appleMisnamedJWS :: Text, pendingSettled :: IORef Bool, googleDown :: IORef Bool, fakeVerifier :: StoreVerifier @@ -54,12 +55,15 @@ newFakeStore = do appleLegendJWS <- fixtureJWS "transaction-legend.json" appleSandboxJWS <- fixtureJWS "transaction-sandbox.json" let appleThrowingJWS = unsignedJWS "{\"transactionId\":\"2000000812345679\",\"productId\":\"BADGE_SUPPORTER_01\"}" + appleMisnamedJWS = unsignedJWS "{\"transactionId\":\"2000000812345698\",\"productId\":\"BADGE_SUPPORTER_01\"}" pendingSettled <- newIORef False googleDown <- newIORef False let appleReceipts = - [ (appleSupporterJWS, appleTransaction "BADGE_SUPPORTER_01" SEProduction 700), - (appleLegendJWS, appleTransaction "BADGE_LEGEND_01" SEProduction 7000), - (appleSandboxJWS, appleTransaction "BADGE_LEGEND_01" SETest 7000) + [ (appleSupporterJWS, appleTransaction "2000000812345671" "BADGE_SUPPORTER_01" SEProduction 700), + (appleLegendJWS, appleTransaction "2000000812345672" "BADGE_LEGEND_01" SEProduction 7000), + (appleSandboxJWS, appleTransaction "2000000812345673" "BADGE_LEGEND_01" SETest 7000), + -- a verifier vouching for another transaction than the one the evidence names + (appleMisnamedJWS, appleTransaction "2000000812345671" "BADGE_SUPPORTER_01" SEProduction 700) ] verifyApple jws | jws == appleThrowingJWS = error "fake verifier bug" @@ -71,14 +75,15 @@ newFakeStore = do appleLegendJWS, appleSandboxJWS, appleThrowingJWS, + appleMisnamedJWS, pendingSettled, googleDown, fakeVerifier = StoreVerifier {verifyApple = Just verifyApple, verifyGoogle = Just verifyGoogle, verifyTimeout = 500000} } where fixtureJWS name = unsignedJWS <$> B.readFile (fixtureDir name) - appleTransaction productId environment cents = - StoreTransaction {productId, quantity = 1, environment, paid = Just (CurrencyAmount cents, "USD")} + appleTransaction transactionRef productId environment cents = + StoreTransaction {transactionRef, productId, quantity = 1, environment, paid = Just (CurrencyAmount cents, "USD")} googleVerdict :: IORef Bool -> IORef Bool -> Text -> Text -> IO (Either StoreRefusal StoreTransaction) googleVerdict pendingSettled googleDown productId token = @@ -95,7 +100,7 @@ googleVerdict pendingSettled googleDown productId token = | token == googleHangingToken -> forever $ threadDelay 1000000 | otherwise -> pure $ Left $ SRInvalid "not a fake purchase" where - purchased = StoreTransaction {productId, quantity = 1, environment = SEProduction, paid = Nothing} + purchased = StoreTransaction {transactionRef = googlePurchaseRef token, productId, quantity = 1, environment = SEProduction, paid = Nothing} settlePending :: FakeStore -> IO () settlePending FakeStore {pendingSettled} = writeIORef pendingSettled True