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>
This commit is contained in:
sh
2026-10-14 10:43:53 +00:00
committed by GitHub
co-authored by spaced4ndy
parent 5db181c10e
commit daa7c4f3d8
10 changed files with 76 additions and 38 deletions
+13 -8
View File
@@ -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 <https://www.apple.com/certificateauthority/AppleRootCA-G3.cer>. 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
<https://www.apple.com/certificateauthority/AppleRootCA-G3.cer>. 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
@@ -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.
@@ -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
@@ -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
@@ -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"
+1 -1
View File
@@ -44,7 +44,7 @@ The client never states what is signed. The tier is that of whatever funded the
- **App Store.** Apple is verified offline, so it has no "not yet".
- `receipt_invalid`: a JWS that is not three base64url parts or names no `transactionId`; an `x5c` certificate that is not base64; a JWS signature that is not 64 bytes; a leaf whose signature does not verify against the intermediate; a JWS signature that does not verify against the leaf; a transaction with a `revocationDate`, refunded or revoked.
- `test_purchase`: refused by the service after the verifier vouched for it, a `Sandbox` transaction.
- `internal`: whatever this service's own configuration decides — an intermediate whose signature does not verify against the configured root, and another app's `bundleId`, since the configured root or bundle id may be the wrong one as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so the configured root does not verify it; evidence in a shape this service does not know — a header that does not name `ES256` or carries no `x5c` of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown `environment`; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1.
- `internal`: whatever this service's own configuration decides — an intermediate whose signature verifies against none of the configured roots, and another app's `bundleId`, since a root may be missing or the bundle id wrong as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so no configured root verifies it; evidence in a shape this service does not know — a header that does not name `ES256` or carries no `x5c` of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown `environment`; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1.
- `product_unavailable`: a product this service does not price — the verifier vouched for the transaction either way.
- `provider_not_configured`: the deployment has no `[apple]` section.
- **Google Play.** Only a purchase record is a verdict.
@@ -37,11 +37,12 @@ waiting_seconds = 3
idle_seconds = 60
; optional; without it every App Store receipt is answered provider_not_configured, which the app retries later.
; root_certificate is https://www.apple.com/certificateauthority/AppleRootCA-G3.cer, in DER;
; uncomment its mount in compose.yml.
; root_certificates lists, comma-separated, the roots trusted to sign receipts, each in DER and mounted
; in compose.yml. Today that is 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
; optional; without it every Play receipt is answered provider_not_configured, which the app retries later.
; service_account_file is the JSON key of a service account with the Play Console permissions "View
+4 -3
View File
@@ -8,6 +8,7 @@ import BadgeService.Config
import qualified Data.ByteString.Char8 as B
import Data.Either (isLeft)
import Data.Ini (readIniFile)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Simplex.Messaging.Encoding.String (strDecode)
@@ -386,7 +387,7 @@ withDev :: T.Text -> (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
+21 -4
View File
@@ -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