From daa7c4f3d8315005e03d78c86cec2ca988f594d2 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:43:53 +0400 Subject: [PATCH] badge-service: accept a list of Apple roots (#7707) * badge-service: accept a list of Apple roots * badge-service: document root_certificates --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- apps/simplex-badge-service/README.md | 21 ++++++++------ .../badge_service.ini.example | 7 +++-- .../src/BadgeService/Config.hs | 14 ++++++--- .../src/BadgeService/Service.hs | 4 +-- .../src/BadgeService/StoreReceipts/Apple.hs | 27 +++++++++++------- .../test-fixtures/apple/AppleRootCA-G3.cer | Bin 0 -> 583 bytes docs/protocol/badges-rpc.md | 2 +- .../badge-service/badge_service.ini.example | 7 +++-- tests/Bots/BadgeService/ConfigTests.hs | 7 +++-- tests/Bots/BadgeService/StoreVerifierTests.hs | 25 +++++++++++++--- 10 files changed, 76 insertions(+), 38 deletions(-) create mode 100644 apps/simplex-badge-service/test-fixtures/apple/AppleRootCA-G3.cer diff --git a/apps/simplex-badge-service/README.md b/apps/simplex-badge-service/README.md index ef48a072ba..bb1cec2230 100644 --- a/apps/simplex-badge-service/README.md +++ b/apps/simplex-badge-service/README.md @@ -213,25 +213,30 @@ two included, answers `method_not_allowed`. Each store is verified only when its section is present; without one, every receipt from that store is answered `provider_not_configured` and the app keeps the purchase to present again. A section -missing a key, an unreadable root certificate or service account key, or either section beside +missing a key, a root certificate that cannot be read or is not a root, an unreadable service +account key, or either section beside `[dev] accept_unverified_store_receipts = on`, stops the service at startup. ```ini [apple] bundle_id = chat.simplex.app -root_certificate = /etc/simplex-badge-service/AppleRootCA-G3.cer +root_certificates = /etc/simplex-badge-service/AppleRootCA-G3.cer [google] package_name = chat.simplex.app service_account_file = /etc/simplex-badge-service/play-service-account.json ``` -- `[apple]` verifies the signed transaction offline, against Apple Root CA - G3 in DER as Apple - publishes it at . A transaction - for another bundle id, or a chain this root does not verify, is answered `internal` and logged as - an error rather than refused, since `bundle_id` or `root_certificate` may be the wrong one; check - both when those errors appear, unless the purchase was made in Xcode's StoreKit testing, which - the App Store does not sign, so no root verifies it. +- `[apple]` verifies the signed transaction offline, against the roots `root_certificates` lists, + comma-separated, each in DER. Today that is Apple Root CA - G3 as Apple publishes it at + . Each must be self-signed. + Verification reads no validity period, so an expired root keeps verifying. A transaction for + another bundle id, or an intermediate no listed root signed, is answered `internal` and logged as + an error rather than refused, since `bundle_id` may be wrong or a root missing; check both when those errors appear, unless the purchase was made in Xcode's + StoreKit testing, which the App Store does not sign, so no root verifies it. When Apple signs + under a new root, list it beside the old one rather than in its place: a purchase signed under the + old root still verifies, and one answered `internal` meanwhile is credited when the app presents + it again. - `[google]` asks the Google Play Developer API about each purchase, and acknowledges it before crediting it, as the service account whose JSON key `service_account_file` holds. The account needs both Play Console permissions Google's diff --git a/apps/simplex-badge-service/badge_service.ini.example b/apps/simplex-badge-service/badge_service.ini.example index 20d4e8614b..26abb5aa7e 100644 --- a/apps/simplex-badge-service/badge_service.ini.example +++ b/apps/simplex-badge-service/badge_service.ini.example @@ -42,11 +42,12 @@ idle_seconds = 60 ; verifies App Store purchases offline. Omit the whole section and the service answers every App Store ; receipt provider_not_configured, which the app retries later. -; root_certificate is Apple Root CA - G3 as Apple publishes it, in DER: -; https://www.apple.com/certificateauthority/AppleRootCA-G3.cer +; root_certificates lists, comma-separated, the roots trusted to sign receipts, each in DER. Today that +; is Apple Root CA - G3 as Apple publishes it: https://www.apple.com/certificateauthority/AppleRootCA-G3.cer +; When Apple adds a root, list it beside the old one rather than in its place. ;[apple] ;bundle_id = chat.simplex.app -;root_certificate = /etc/simplex-badge-service/AppleRootCA-G3.cer +;root_certificates = /etc/simplex-badge-service/AppleRootCA-G3.cer ; verifies Play purchases with the Google Play Developer API. Omit the whole section and the service answers ; every Play receipt provider_not_configured, which the app retries later. diff --git a/apps/simplex-badge-service/src/BadgeService/Config.hs b/apps/simplex-badge-service/src/BadgeService/Config.hs index 493c95c27e..72e774fdac 100644 --- a/apps/simplex-badge-service/src/BadgeService/Config.hs +++ b/apps/simplex-badge-service/src/BadgeService/Config.hs @@ -28,6 +28,8 @@ import Control.Monad (mfilter, when) import Data.Attoparsec.Text (Parser, endOfInput, isEndOfLine, parseOnly, satisfy, skipMany, skipSpace, skipWhile) import qualified Data.ByteString.Char8 as B import Data.Ini (Ini, iniGlobals, iniParser, keys, lookupValue, sections) +import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T @@ -95,7 +97,7 @@ data PollConfig = PollConfig {pWaitingSeconds :: Int, pIdleSeconds :: Int} data AppleStoreConfig = AppleStoreConfig { aBundleId :: Text, - aRootCertificate :: FilePath + aRootCertificates :: NonEmpty FilePath } deriving (Eq, Show) @@ -190,7 +192,7 @@ knownSettings = ("btcpay", ["host", "api_key", "store_id", "webhook_secret", "expiry_minutes", "speed_policy", "payment_tolerance"]), ("stripe", ["secret_key", "publishable_key", "webhook_secret", "session_minutes"]), ("poll", ["waiting_seconds", "idle_seconds"]), - ("apple", ["bundle_id", "root_certificate"]), + ("apple", ["bundle_id", "root_certificates"]), ("google", ["package_name", "service_account_file"]), ("group", ["display_name", "description"]), ("dev", ["accept_unverified_store_receipts"]), @@ -336,8 +338,12 @@ parseConfig ini = do | not (hasSection "apple") = Right Nothing | otherwise = do aBundleId <- required "apple" "bundle_id" - aRootCertificate <- T.unpack <$> required "apple" "root_certificate" - pure (Just AppleStoreConfig {aBundleId, aRootCertificate}) + aRootCertificates <- rootCertificates + pure (Just AppleStoreConfig {aBundleId, aRootCertificates}) + rootCertificates = do + v <- required "apple" "root_certificates" + maybe (Left "apple.root_certificates must list at least one file") Right $ + L.nonEmpty $ map T.unpack $ filter (not . T.null) $ map T.strip $ T.splitOn "," v googleSection | not (hasSection "google") = Right Nothing | otherwise = do diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index 98a0a4c7ae..72389c8cc7 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -141,8 +141,8 @@ configureStoreVerifier serviceCfg env@ServiceState {storeVerifier = v} = case se logWarn "[dev] accept_unverified_store_receipts is on: any store receipt is accepted without verification" pure env {storeVerifier = mockStoreVerifier} Just ServiceConfig {appleStore, playStore} -> do - apple <- forM appleStore $ \AppleStoreConfig {aBundleId, aRootCertificate} -> - (\root -> verifyAppleTransaction root aBundleId) <$> orExit (readAppleRoot aRootCertificate) + apple <- forM appleStore $ \AppleStoreConfig {aBundleId, aRootCertificates} -> + (\roots -> verifyAppleTransaction roots aBundleId) <$> mapM (orExit . readAppleRoot) aRootCertificates google <- forM playStore $ orExit . playStoreVerifier forM_ appleStore $ \AppleStoreConfig {aBundleId} -> logInfo $ "verifying App Store receipts for " <> aBundleId forM_ playStore $ \PlayStoreConfig {gPackageName} -> logInfo $ "verifying Play purchases for " <> gPackageName diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs index 42200fcc0c..c94c6dec61 100644 --- a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs @@ -25,6 +25,7 @@ import qualified Data.ByteString as B import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Base64.URL as B64U import Data.Foldable (toList) +import Data.List.NonEmpty (NonEmpty) import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T @@ -36,10 +37,10 @@ import Data.X509.Validation (SignatureFailure (..), SignatureVerification (..), import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) import Simplex.Messaging.Util (eitherToMaybe) -verifyAppleTransaction :: SignedCertificate -> Text -> Text -> Either StoreRefusal VerifiedStoreTransaction -verifyAppleTransaction root ourBundleId jws = case T.splitOn "." jws of +verifyAppleTransaction :: NonEmpty SignedCertificate -> Text -> Text -> Either StoreRefusal VerifiedStoreTransaction +verifyAppleTransaction roots ourBundleId jws = case T.splitOn "." jws of [header, payload, signature] -> do - leaf <- signingChain root =<< certificateChain header + leaf <- signingChain roots =<< certificateChain header sig <- jwsSignature signature key <- leafKey leaf unless (ECDSA.verify SHA256 key sig (encodeUtf8 $ header <> "." <> payload)) $ @@ -65,14 +66,14 @@ certificateChain header = do J.String c | Right bs <- B64.decode (encodeUtf8 c) -> Right bs _ -> refused "an x5c certificate is not base64" --- | The root in the chain is ignored: trust comes from the configured one alone. -signingChain :: SignedCertificate -> [ByteString] -> Either StoreRefusal SignedCertificate -signingChain root = \case +-- | The root in the chain is ignored: trust comes from the configured ones alone. +signingChain :: NonEmpty SignedCertificate -> [ByteString] -> Either StoreRefusal SignedCertificate +signingChain roots = \case [leafDer, intermediateDer, _] -> do leaf <- certificate leafDer intermediate <- certificate intermediateDer - -- the configured root may be stale or wrong as likely as the receipt forged, so this retries rather than refuses - unless (signedBy root intermediate) $ failed "the intermediate is not signed by the configured root" + -- a root may be missing or wrong as likely as the receipt forged, so this retries rather than refuses + unless (any (`signedBy` intermediate) roots) $ failed "the intermediate is not signed by a configured root" case verifySignedSignature leaf (certPubKey $ getCertificate intermediate) of SignaturePass -> Right () SignatureFailed SignatureInvalid -> refused "the leaf is not signed by the intermediate" @@ -84,7 +85,6 @@ signingChain root = \case _ -> failed "x5c is not leaf, intermediate and root" where certificate = either (const $ failed "an x5c certificate does not decode") Right . decodeSignedCertificate - signedBy issuer cert = verifySignedSignature cert (certPubKey $ getCertificate issuer) == SignaturePass marked oid cert = case certExtensions (getCertificate cert) of Extensions (Just exts) -> any ((== oid) . extRawOID) exts Extensions Nothing -> False @@ -175,8 +175,15 @@ refused = Left . SRInvalid failed :: Text -> Either StoreRefusal a failed = Left . SRVerifierFailed . ("apple verifier: " <>) +signedBy :: SignedCertificate -> SignedCertificate -> Bool +signedBy issuer cert = verifySignedSignature cert (certPubKey $ getCertificate issuer) == SignaturePass + readAppleRoot :: FilePath -> IO (Either String SignedCertificate) readAppleRoot path = try (B.readFile path) >>= \case Left (_ :: IOException) -> pure $ Left $ path <> ": could not be read" - Right der -> pure $ either (const $ Left $ path <> ": is not a DER certificate") Right $ decodeSignedCertificate der + Right der -> pure $ either (const $ Left $ path <> ": is not a DER certificate") checkRoot $ decodeSignedCertificate der + where + checkRoot root + | signedBy root root = Right root + | otherwise = Left $ path <> ": is not a self-signed root certificate" diff --git a/apps/simplex-badge-service/test-fixtures/apple/AppleRootCA-G3.cer b/apps/simplex-badge-service/test-fixtures/apple/AppleRootCA-G3.cer new file mode 100644 index 0000000000000000000000000000000000000000..228bfa39cbd5acfe53fb9d196e3c1bbbd28649f8 GIT binary patch literal 583 zcmXqLVsbWUVm!HknTe5!i9`43pN>mMy{8&*v2kd%d7QIlVP-Z+HbQ8gAnH8xlJyL^4LCu9Y{E>T!3J{TyoM$QCdLMa7KWAvW>Mn2#+FE2`Z=V` zK!A-M?0+UkHdgIM76v8eBnFllFZFF5ik7^ctW?w}EOS?2>c^vt{R;1hh~4CSx{Ot; zJf%9`&*JiK8JDf~U*)$MCB>e6*%Iw<;4c`(@HZlYXX#gd9ba~L;nG{vr%%r}jCrd) zw_3sa#?FwNaWj`#1#%fKb~11^i`@ju(^Y`3K= md8u*U (Either String ServiceConfig -> IO a) -> IO a withDev keys act = withIni (fullIni <> "[dev]\n" <> keys) $ \p -> readServiceConfig p >>= act appleIni, googleIni :: T.Text -appleIni = "[apple]\nbundle_id = chat.simplex.app\nroot_certificate = /etc/badges/AppleRootCA-G3.cer\n" +appleIni = "[apple]\nbundle_id = chat.simplex.app\nroot_certificates = /etc/badges/AppleRootCA-G3.cer ,/etc/badges/AppleRootCA-G4.cer,\n" googleIni = "[google]\npackage_name = chat.simplex.app\nservice_account_file = /etc/badges/play.json\n" testStoresAbsent :: IO () @@ -398,7 +399,7 @@ testStoresAbsent = withIni fullIni $ \p -> do testStoresRead :: IO () testStoresRead = withIni (fullIni <> appleIni <> googleIni) $ \p -> do Right cfg <- readServiceConfig p - appleStore cfg `shouldBe` Just AppleStoreConfig {aBundleId = "chat.simplex.app", aRootCertificate = "/etc/badges/AppleRootCA-G3.cer"} + appleStore cfg `shouldBe` Just AppleStoreConfig {aBundleId = "chat.simplex.app", aRootCertificates = "/etc/badges/AppleRootCA-G3.cer" :| ["/etc/badges/AppleRootCA-G4.cer"]} playStore cfg `shouldBe` Just PlayStoreConfig @@ -411,7 +412,7 @@ testStoresRead = withIni (fullIni <> appleIni <> googleIni) $ \p -> do testStoresIncomplete :: IO () testStoresIncomplete = do refused (T.replace "bundle_id = chat.simplex.app\n" "" appleIni) "apple.bundle_id" - refused (T.replace "root_certificate = /etc/badges/AppleRootCA-G3.cer\n" "" appleIni) "apple.root_certificate" + refused (T.replace "root_certificates = /etc/badges/AppleRootCA-G3.cer ,/etc/badges/AppleRootCA-G4.cer,\n" "" appleIni) "apple.root_certificates" refused (T.replace "package_name = chat.simplex.app\n" "" googleIni) "google.package_name" refused (T.replace "service_account_file = /etc/badges/play.json\n" "" googleIni) "google.service_account_file" where diff --git a/tests/Bots/BadgeService/StoreVerifierTests.hs b/tests/Bots/BadgeService/StoreVerifierTests.hs index e428e0ed1e..27e73c7154 100644 --- a/tests/Bots/BadgeService/StoreVerifierTests.hs +++ b/tests/Bots/BadgeService/StoreVerifierTests.hs @@ -25,8 +25,10 @@ import qualified Data.ByteString as B import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Base64.URL as B64U import qualified Data.ByteString.Lazy as LB +import Data.Either (isRight) import Data.Hourglass (Date (..), DateTime (..), Month (..), TimeOfDay (..)) import Data.List (isInfixOf) +import Data.List.NonEmpty (NonEmpty (..)) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeUtf8) @@ -42,6 +44,7 @@ badgeStoreVerifierTests :: Spec badgeStoreVerifierTests = describe "badge store verifiers" $ do describe "App Store signed transactions" $ do it "vouches for a transaction a chain to the trusted root signed, naming the transaction it was claimed by" testAppleVerdict + it "verifies a transaction under any of the trusted roots" testAppleAnyTrustedRoot it "fails, rather than refuses, an intermediate the trusted root did not sign, since the root may be misconfigured" testAppleRootNotTrusted it "refuses a leaf the intermediate did not sign, though the intermediate is genuine" testAppleForgedLeaf it "fails, rather than refuses, a leaf signature the library cannot check" testAppleLeafUncheckable @@ -55,7 +58,7 @@ badgeStoreVerifierTests = describe "badge store verifiers" $ do it "fails, rather than refuses, a payload Apple signed in a shape it does not know" testAppleUnknownShape it "fails, rather than refuses, a leaf holding a key it cannot read" testAppleLeafKey it "records the price in the currency's minor units, or not at all" testApplePrice - it "reads the trusted root from a DER file, and reports one it cannot read" testReadAppleRoot + it "reads the trusted root from a DER file, and reports one it cannot read or that is not a root" testReadAppleRoot it "has nothing to acknowledge" testAppleNoAcknowledgement describe "Google Play purchases" $ do it "vouches for a purchased token, asking Play about that token in this app" testPlayPurchased @@ -108,7 +111,10 @@ ourBundleId :: Text ourBundleId = "chat.simplex.app" appleVerdict :: SignedCertificate -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) -appleVerdict root jws = seamVerdict noStoreVerifier {verifyApple = Just (verifyAppleTransaction root ourBundleId)} SPApple {jws} +appleVerdict root = appleVerdictUnder (root :| []) + +appleVerdictUnder :: NonEmpty SignedCertificate -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) +appleVerdictUnder roots jws = seamVerdict noStoreVerifier {verifyApple = Just (verifyAppleTransaction roots ourBundleId)} SPApple {jws} data TestChain = TestChain { rootCert :: SignedCertificate, @@ -223,6 +229,14 @@ testAppleVerdict = do `shouldReturn` Right VerifiedStoreTransaction {transactionRef = "2000000900000001", productId = "BADGE_SUPPORTER_01", quantity = 1, testPurchase = False, paid = Just (CurrencyAmount 700, "USD"), alreadyAcknowledged = False} appleTransactionId jws `shouldBe` Just "2000000900000001" +testAppleAnyTrustedRoot :: IO () +testAppleAnyTrustedRoot = do + old <- trustedChain + new <- newChain otherRootName [intermediateMarker] [receiptLeafMarker] + forM_ [old, new] $ \chain -> do + jws <- signedBy chain $ transactionPayload [] [] + fmap transactionRef <$> appleVerdictUnder (rootCert old :| [rootCert new]) jws `shouldReturn` Right "2000000900000001" + testAppleRootNotTrusted :: IO () testAppleRootNotTrusted = do trusted <- trustedChain @@ -345,7 +359,8 @@ testApplePrice = do testReadAppleRoot :: IO () testReadAppleRoot = do - TestChain {rootCert} <- trustedChain + TestChain {rootCert, intermediateCert} <- trustedChain + readAppleRoot ("apps" "simplex-badge-service" "test-fixtures" "apple" "AppleRootCA-G3.cer") >>= (`shouldSatisfy` isRight) createDirectoryIfMissing True "tests/tmp" withTempDirectory "tests/tmp" "apple-root" $ \d -> do B.writeFile (d "root.cer") (encodeSignedObject rootCert) @@ -353,6 +368,8 @@ testReadAppleRoot = do B.writeFile (d "root.pem") "-----BEGIN CERTIFICATE-----\n" readAppleRoot (d "root.pem") >>= (`shouldSatisfy` either ("not a DER certificate" `isInfixOf`) (const False)) readAppleRoot (d "absent.cer") >>= (`shouldSatisfy` either ("could not be read" `isInfixOf`) (const False)) + B.writeFile (d "intermediate.cer") (encodeSignedObject intermediateCert) + readAppleRoot (d "intermediate.cer") >>= (`shouldSatisfy` either ("not a self-signed root certificate" `isInfixOf`) (const False)) testAppleNoAcknowledgement :: IO () testAppleNoAcknowledgement = do @@ -360,7 +377,7 @@ testAppleNoAcknowledgement = do jws <- signedBy chain $ transactionPayload [] [] withFakePlay $ \fake -> do verifier <- playVerifier fake - acknowledgement verifier {verifyApple = Just (verifyAppleTransaction (rootCert chain) ourBundleId)} SPApple {jws} `shouldReturn` Nothing + acknowledgement verifier {verifyApple = Just (verifyAppleTransaction (rootCert chain :| []) ourBundleId)} SPApple {jws} `shouldReturn` Nothing -- * Google Play