Accept bounded StoreKit signing clock skew

Allow legitimate App Store transaction timestamps to differ within the existing verification tolerance while preserving rejection beyond that boundary.
This commit is contained in:
Rocky
2026-08-19 18:26:54 +08:00
parent 75c046d91d
commit 2194e69bb8
2 changed files with 32 additions and 3 deletions
@@ -123,7 +123,7 @@ class StoreKitService(
} }
val now = clock.instant() val now = clock.instant()
if ( if (
transaction.purchasedAt > transaction.signedAt || transaction.purchasedAt > transaction.signedAt.plus(MAX_CLOCK_SKEW) ||
transaction.signedAt > now.plus(MAX_CLOCK_SKEW) transaction.signedAt > now.plus(MAX_CLOCK_SKEW)
) { ) {
throw StoreKitPurchaseConflict() throw StoreKitPurchaseConflict()
@@ -33,14 +33,16 @@ class StoreKitServiceTest : FunSpec({
accountToken: UUID = userId, accountToken: UUID = userId,
productId: String = product.productId, productId: String = product.productId,
revokedAt: Instant? = null, revokedAt: Instant? = null,
purchasedAt: Instant = now.minusSeconds(10),
signedAt: Instant = now.minusSeconds(5),
) = VerifiedStoreKitTransaction( ) = VerifiedStoreKitTransaction(
transactionId = transactionId, transactionId = transactionId,
originalTransactionId = transactionId, originalTransactionId = transactionId,
appAccountToken = accountToken, appAccountToken = accountToken,
productId = productId, productId = productId,
environment = StoreKitEnvironment.SANDBOX, environment = StoreKitEnvironment.SANDBOX,
purchasedAt = now.minusSeconds(10), purchasedAt = purchasedAt,
signedAt = now.minusSeconds(5), signedAt = signedAt,
revokedAt = revokedAt, revokedAt = revokedAt,
) )
@@ -115,6 +117,33 @@ class StoreKitServiceTest : FunSpec({
store.ledger shouldHaveSize 0 store.ledger shouldHaveSize 0
} }
test("small App Store signing clock skew is accepted") {
val store = TestBillingStore()
val verified = transaction(
purchasedAt = now.minusSeconds(10),
signedAt = now.minusSeconds(70),
)
val result = service(store, verified).submit(userId, signedTransaction)
result.balanceAfter shouldBeExactly 3_000
store.ledger shouldHaveSize 1
}
test("App Store signing clock skew beyond the tolerance is rejected") {
val store = TestBillingStore()
val verified = transaction(
purchasedAt = now.minusSeconds(10),
signedAt = now.minusSeconds(311),
)
shouldThrow<StoreKitPurchaseConflict> {
service(store, verified).submit(userId, signedTransaction)
}
store.ledger shouldHaveSize 0
}
test("unknown or revoked products never grant credits") { test("unknown or revoked products never grant credits") {
val unknownStore = TestBillingStore() val unknownStore = TestBillingStore()
shouldThrow<StoreKitPurchaseConflict> { shouldThrow<StoreKitPurchaseConflict> {