@pithy-sh/payments 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/paddle-prices.iife.js +1 -0
  4. package/dist/pithy.manifest.json +68 -0
  5. package/docs/lemon-squeezy.md +167 -0
  6. package/docs/paddle.md +486 -0
  7. package/package.json +73 -0
  8. package/pithy.manifest.json +68 -0
  9. package/src/admin/coverage.ts +107 -0
  10. package/src/admin/read.ts +360 -0
  11. package/src/audit/actions.ts +214 -0
  12. package/src/capability.ts +377 -0
  13. package/src/client/api.ts +734 -0
  14. package/src/client/checkout.ts +252 -0
  15. package/src/client/fixtures/price-preview-de.json +54 -0
  16. package/src/client/fixtures/price-preview-gb.json +54 -0
  17. package/src/client/fixtures/price-preview-jp-yen.json +54 -0
  18. package/src/client/fixtures/price-preview-us-country-only.json +54 -0
  19. package/src/client/fixtures/price-preview-us-new-york.json +54 -0
  20. package/src/client/fixtures/pricePreview.ts +67 -0
  21. package/src/client/hooks.ts +590 -0
  22. package/src/client/paddle.ts +733 -0
  23. package/src/client/paddleCache.ts +265 -0
  24. package/src/client/paddlePrices.iife.ts +43 -0
  25. package/src/client/paddlePrices.ts +143 -0
  26. package/src/client/paddlePricesTag.ts +332 -0
  27. package/src/client/projection.ts +133 -0
  28. package/src/client/test-utils/cacheStore.ts +41 -0
  29. package/src/client/wholeUnits.ts +147 -0
  30. package/src/cloudflare-test.d.ts +16 -0
  31. package/src/config/config.ts +563 -0
  32. package/src/data/discount.ts +238 -0
  33. package/src/data/entitlement.ts +67 -0
  34. package/src/data/money.ts +75 -0
  35. package/src/data/pause.ts +148 -0
  36. package/src/data/providerAccount.ts +47 -0
  37. package/src/data/purchase.ts +131 -0
  38. package/src/data/rail.ts +74 -0
  39. package/src/data/reconcileRun.ts +208 -0
  40. package/src/data/renderMoney.ts +180 -0
  41. package/src/data/status.ts +67 -0
  42. package/src/data/subject.ts +148 -0
  43. package/src/data/subscription.ts +675 -0
  44. package/src/data/syncCursor.ts +44 -0
  45. package/src/data/tables.ts +61 -0
  46. package/src/data/webhookEvent.ts +196 -0
  47. package/src/entitlement/manual.ts +209 -0
  48. package/src/entitlement/resolver.ts +125 -0
  49. package/src/entitlement/subjectSeam.ts +171 -0
  50. package/src/error/errors.ts +421 -0
  51. package/src/grants/apply.ts +207 -0
  52. package/src/grants/clawback.ts +128 -0
  53. package/src/grants/ledgerSeam.ts +141 -0
  54. package/src/http/dispatch.ts +48 -0
  55. package/src/http/guards.ts +58 -0
  56. package/src/http/manifestConfig.ts +56 -0
  57. package/src/http/responses.ts +1147 -0
  58. package/src/http/routes.ts +2911 -0
  59. package/src/http/schemas.ts +577 -0
  60. package/src/http/scopes.ts +260 -0
  61. package/src/http/view.ts +188 -0
  62. package/src/http/webhookGuard.ts +341 -0
  63. package/src/index.ts +231 -0
  64. package/src/migrations/0001_purchases.ts +312 -0
  65. package/src/pricing/location.ts +175 -0
  66. package/src/pricing/visitor.ts +82 -0
  67. package/src/projection/event.ts +101 -0
  68. package/src/projection/orphans.ts +226 -0
  69. package/src/projection/owner.ts +247 -0
  70. package/src/projection/resolve.ts +57 -0
  71. package/src/projection/writer.ts +554 -0
  72. package/src/provision/resolvePaymentsConfig.ts +91 -0
  73. package/src/rails/apple/certs.ts +66 -0
  74. package/src/rails/apple/der.ts +249 -0
  75. package/src/rails/apple/fixtures/chain.ts +412 -0
  76. package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
  77. package/src/rails/apple/fixtures/did-renew.json +48 -0
  78. package/src/rails/apple/fixtures/one-time-charge.json +32 -0
  79. package/src/rails/apple/fixtures/refund.json +33 -0
  80. package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
  81. package/src/rails/apple/fixtures/test.json +14 -0
  82. package/src/rails/apple/http.ts +128 -0
  83. package/src/rails/apple/jws.ts +123 -0
  84. package/src/rails/apple/notification.ts +357 -0
  85. package/src/rails/apple/rail.ts +114 -0
  86. package/src/rails/apple/serverApi.ts +412 -0
  87. package/src/rails/apple/verify.ts +93 -0
  88. package/src/rails/apple/x509.ts +424 -0
  89. package/src/rails/contract.ts +939 -0
  90. package/src/rails/google/fixtures/play-product-purchased.json +14 -0
  91. package/src/rails/google/fixtures/play-subscription-active.json +23 -0
  92. package/src/rails/google/fixtures/push.ts +152 -0
  93. package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
  94. package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
  95. package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
  96. package/src/rails/google/fixtures/rtdn-test.json +8 -0
  97. package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
  98. package/src/rails/google/http.ts +124 -0
  99. package/src/rails/google/jwt.ts +127 -0
  100. package/src/rails/google/oidc.ts +308 -0
  101. package/src/rails/google/playApi.ts +598 -0
  102. package/src/rails/google/rail.ts +174 -0
  103. package/src/rails/google/rtdn.ts +331 -0
  104. package/src/rails/google/verify.ts +127 -0
  105. package/src/rails/lemonSqueezy/api.ts +192 -0
  106. package/src/rails/lemonSqueezy/checkout.ts +186 -0
  107. package/src/rails/lemonSqueezy/discounts.ts +166 -0
  108. package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
  109. package/src/rails/lemonSqueezy/objects.ts +511 -0
  110. package/src/rails/lemonSqueezy/portal.ts +82 -0
  111. package/src/rails/lemonSqueezy/pricing.ts +124 -0
  112. package/src/rails/lemonSqueezy/rail.ts +137 -0
  113. package/src/rails/lemonSqueezy/read.ts +72 -0
  114. package/src/rails/lemonSqueezy/refresh.ts +86 -0
  115. package/src/rails/lemonSqueezy/signature.ts +119 -0
  116. package/src/rails/lemonSqueezy/verify.ts +45 -0
  117. package/src/rails/lemonSqueezy/webhook.ts +326 -0
  118. package/src/rails/paddle/adjustments.ts +94 -0
  119. package/src/rails/paddle/api.ts +266 -0
  120. package/src/rails/paddle/checkout.ts +240 -0
  121. package/src/rails/paddle/discounts.ts +176 -0
  122. package/src/rails/paddle/events.ts +285 -0
  123. package/src/rails/paddle/fixtures/browserForged.ts +54 -0
  124. package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
  125. package/src/rails/paddle/objects.ts +883 -0
  126. package/src/rails/paddle/portal.ts +130 -0
  127. package/src/rails/paddle/rail.ts +219 -0
  128. package/src/rails/paddle/read.ts +108 -0
  129. package/src/rails/paddle/recorded.ts +83 -0
  130. package/src/rails/paddle/refresh.ts +153 -0
  131. package/src/rails/paddle/refund.ts +297 -0
  132. package/src/rails/paddle/signature.ts +225 -0
  133. package/src/rails/paddle/subscription.ts +793 -0
  134. package/src/rails/paddle/verify.ts +120 -0
  135. package/src/rails/paddle/webhook.ts +405 -0
  136. package/src/rails/providers.ts +168 -0
  137. package/src/rails/stripe/api.ts +253 -0
  138. package/src/rails/stripe/checkout.ts +175 -0
  139. package/src/rails/stripe/discounts.ts +169 -0
  140. package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
  141. package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
  142. package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
  143. package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
  144. package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
  145. package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
  146. package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
  147. package/src/rails/stripe/fixtures/events.ts +111 -0
  148. package/src/rails/stripe/objects.ts +642 -0
  149. package/src/rails/stripe/portal.ts +45 -0
  150. package/src/rails/stripe/pricing.ts +123 -0
  151. package/src/rails/stripe/rail.ts +139 -0
  152. package/src/rails/stripe/refresh.ts +79 -0
  153. package/src/rails/stripe/signature.ts +127 -0
  154. package/src/rails/stripe/verify.ts +105 -0
  155. package/src/rails/stripe/webhook.ts +79 -0
  156. package/src/secret/registry.ts +275 -0
  157. package/src/seeds/example.ts +181 -0
  158. package/src/version.generated.ts +16 -0
  159. package/src/workflows/paddleSweep.ts +717 -0
  160. package/src/workflows/railAccess.ts +87 -0
  161. package/src/workflows/reconcile.ts +648 -0
  162. package/src/workflows/report.ts +64 -0
  163. package/src/workflows/retryPolicy.ts +40 -0
  164. package/src/workflows/specs.ts +143 -0
  165. package/src/workflows/worker.ts +166 -0
  166. package/src/workflows/workerConfig.ts +73 -0
  167. package/src/workflows/wrangler.jsonc +55 -0
@@ -0,0 +1,424 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PaymentsInvalidReceiptError, PaymentsVerificationFailedError } from "../../error/errors";
5
+ import {
6
+ ASN1_BOOLEAN,
7
+ ASN1_GENERALIZED_TIME,
8
+ ASN1_INTEGER,
9
+ ASN1_OCTET_STRING,
10
+ ASN1_SEQUENCE,
11
+ ASN1_UTC_TIME,
12
+ type Asn1Node,
13
+ asn1Children,
14
+ bytesEqual,
15
+ decodeBase64,
16
+ derSignatureToRaw,
17
+ readAsn1,
18
+ readBits,
19
+ readBoolean,
20
+ readInteger,
21
+ readOid,
22
+ } from "./der";
23
+
24
+ /**
25
+ * X.509 certificates, and the chain check that makes an Apple signature mean something.
26
+ *
27
+ * A JWS signature only proves that whoever held a key signed the bytes. What makes it *Apple's* signature
28
+ * is that the key belongs to a certificate Apple issued, through an intermediate Apple issued, from a root
29
+ * we shipped. Skipping any link of that turns the webhook route into one that accepts a notification signed
30
+ * by anybody who can mint a certificate — which is anybody.
31
+ *
32
+ * So the chain is verified in full, and every rule below exists because omitting it is a known bypass:
33
+ *
34
+ * - **Each link's signature is checked against the next certificate's key.** Reading `x5c` and trusting the
35
+ * leaf is the classic mistake: the header is attacker-controlled, so an unchecked chain is decoration.
36
+ * - **The last certificate must be byte-identical to a pinned root.** Byte equality rather than
37
+ * re-verifying the root's self-signature, because a self-signature only proves a root vouches for itself,
38
+ * which every forged root also does.
39
+ * - **Issuer and subject names must match, link by link.** Cheap, and it refuses a chain assembled out of
40
+ * two unrelated ones before any crypto runs.
41
+ * - **Every certificate's validity window must contain the clock**, the root included. An expired
42
+ * intermediate is as much a refusal as an expired leaf, and the clock is injected so the check is a real
43
+ * comparison in tests rather than an accident of when the suite runs.
44
+ * - **Every certificate above the leaf must be a CA**, by `basicConstraints.cA`, with `keyCertSign` in
45
+ * `keyUsage` where that extension is present, and its `pathLenConstraint` respected. Without this the
46
+ * other rules are not enough, and the hole is not theoretical: a chain of
47
+ * `[forged, someAppleLeaf, intermediate, root]` links correctly, sits inside every validity window, and
48
+ * roots in the pinned certificate. Anyone holding **any** ECDSA leaf certificate issued under Apple Root
49
+ * CA - G3 — a paid developer's own certificate is one — could sign `forged` themselves and every check
50
+ * above would pass. `cA` is what says a certificate may issue others, so it is what closes it.
51
+ * - **The leaf and its issuer must carry Apple's marker extensions.** Apple publishes an OID it puts on the
52
+ * certificate that signs App Store data, and another on the WWDR intermediate that issues it. Requiring
53
+ * them means a chain must not merely root in Apple's CA but be *the* Apple chain for *this* purpose — so
54
+ * even a genuine Apple sub-CA issued for something else cannot sign a notification.
55
+ *
56
+ * Only ECDSA is accepted, on P-256 and P-384, with SHA-256 or SHA-384. Apple's chain is elliptic-curve end
57
+ * to end (see `certs.ts`), and an algorithm the boundary never meets is surface with no user.
58
+ *
59
+ * A structural problem throws `payments/invalid_receipt`; a chain that is well-formed but does not verify
60
+ * throws `payments/verification_failed`. The distinction is worth keeping: the first is a malformed
61
+ * request, the second is a rejected one.
62
+ */
63
+
64
+ /** ECDSA signature-algorithm OIDs → the digest each one names. */
65
+ const SIGNATURE_HASHES: Record<string, "SHA-256" | "SHA-384"> = {
66
+ "1.2.840.10045.4.3.2": "SHA-256",
67
+ "1.2.840.10045.4.3.3": "SHA-384",
68
+ };
69
+
70
+ /** Named-curve OIDs → the curve names WebCrypto knows them by. */
71
+ const CURVES: Record<string, "P-256" | "P-384"> = {
72
+ "1.2.840.10045.3.1.7": "P-256",
73
+ "1.3.132.0.34": "P-384",
74
+ };
75
+
76
+ /** The coordinate width of each curve, which is what an ECDSA signature's two halves are padded to. */
77
+ const COORDINATE_BYTES: Record<"P-256" | "P-384", number> = { "P-256": 32, "P-384": 48 };
78
+
79
+ /** `basicConstraints` — whether a certificate may issue others, and how deep beneath it a path may run. */
80
+ const OID_BASIC_CONSTRAINTS = "2.5.29.19";
81
+ /** `keyUsage` — the bit field whose `keyCertSign` says the key may sign certificates. */
82
+ const OID_KEY_USAGE = "2.5.29.15";
83
+ /** `keyCertSign` is bit 5 of `keyUsage`, counting from the most significant bit of the first byte. */
84
+ const KEY_CERT_SIGN_BIT = 5;
85
+
86
+ /**
87
+ * Apple's marker on the certificate that signs App Store Server Notifications and transactions, and on the
88
+ * WWDR intermediate that issues it. Published by Apple and checked by Apple's own `app-store-server-library`,
89
+ * which is where these values come from; they are identifiers, not secrets.
90
+ *
91
+ * They are what make the chain check an identity check. Rooting in Apple Root CA - G3 proves Apple issued
92
+ * something in the path; these prove the path is Apple's App-Store-data-signing path specifically.
93
+ */
94
+ export const APPLE_RECEIPT_SIGNER_OID = "1.2.840.113635.100.6.11.1";
95
+ export const APPLE_WWDR_INTERMEDIATE_OID = "1.2.840.113635.100.6.2.1";
96
+
97
+ /**
98
+ * The most certificates a chain may carry. Apple sends three. The cap exists because every link costs a
99
+ * signature verification, and the chain arrives in an unauthenticated request — an attacker who can make us
100
+ * verify an unbounded chain has found a way to spend our CPU for the price of one POST.
101
+ */
102
+ const MAX_CHAIN_LENGTH = 10;
103
+
104
+ /** One certificate extension: whether it was marked critical, and its own DER. */
105
+ export interface CertificateExtension {
106
+ critical: boolean;
107
+ value: Uint8Array;
108
+ }
109
+
110
+ /** `basicConstraints`, decoded. Absent from a certificate means "not a CA" (the ASN.1 default for `cA`). */
111
+ export interface BasicConstraints {
112
+ ca: boolean;
113
+ pathLen?: number;
114
+ }
115
+
116
+ /** One parsed certificate — only the fields a chain check needs, and nothing else. */
117
+ export interface ParsedCertificate {
118
+ /** The whole certificate's DER, for byte-comparing against a pinned root. */
119
+ der: Uint8Array;
120
+ /** The `tbsCertificate` as encoded, tag and length included — exactly the bytes the issuer signed. */
121
+ tbs: Uint8Array;
122
+ /** The issuer Name's DER, compared against the next certificate's subject to link the chain. */
123
+ issuer: Uint8Array;
124
+ /** The subject Name's DER. */
125
+ subject: Uint8Array;
126
+ /** When the certificate becomes valid. */
127
+ notBefore: Date;
128
+ /** When it stops being valid. */
129
+ notAfter: Date;
130
+ /** The SubjectPublicKeyInfo, ready for `crypto.subtle.importKey("spki", …)`. */
131
+ spki: Uint8Array;
132
+ /** The curve the subject's key is on. */
133
+ curve: "P-256" | "P-384";
134
+ /** The OID of the algorithm the issuer signed this certificate with. */
135
+ signatureAlgorithm: string;
136
+ /** The signature value — a DER `SEQUENCE { INTEGER r, INTEGER s }`. */
137
+ signature: Uint8Array;
138
+ /** Every extension, by OID. Unknown ones are kept rather than refused; only the checked ones are decoded. */
139
+ extensions: Map<string, CertificateExtension>;
140
+ /** `basicConstraints`, or undefined when the certificate carries none — which means it is not a CA. */
141
+ basicConstraints?: BasicConstraints;
142
+ /** Whether `keyUsage` is present and grants `keyCertSign`; undefined when the extension is absent. */
143
+ keyCertSign?: boolean;
144
+ }
145
+
146
+ /** A leaf's verifying key and the curve it is on. The curve is returned because ES256 requires P-256. */
147
+ export interface VerifiedLeaf {
148
+ key: CryptoKey;
149
+ curve: "P-256" | "P-384";
150
+ }
151
+
152
+ /** What the chain check needs: which roots to trust, and the clock to judge validity against. */
153
+ export interface VerifyChainOptions {
154
+ /** base64 DER of every acceptable root. The chain's last certificate must equal one of them. */
155
+ roots: readonly string[];
156
+ /** The clock. Injected, so a validity refusal is deterministic. */
157
+ now: Date;
158
+ /**
159
+ * An extension OID the leaf must carry. Defaults to {@link APPLE_RECEIPT_SIGNER_OID}. Pass `null` only
160
+ * where a chain genuinely is not Apple's App-Store-signing chain — there is no such caller in the package,
161
+ * and the option exists so the requirement is visible at the boundary rather than buried.
162
+ */
163
+ requiredLeafExtension?: string | null;
164
+ /** An extension OID the leaf's issuer must carry. Defaults to {@link APPLE_WWDR_INTERMEDIATE_OID}. */
165
+ requiredIssuerExtension?: string | null;
166
+ }
167
+
168
+ function malformed(detail: string): PaymentsInvalidReceiptError {
169
+ return new PaymentsInvalidReceiptError({ detail });
170
+ }
171
+
172
+ /** A well-formed chain that does not verify. The public message never says which link failed. */
173
+ function rejected(detail: string): PaymentsVerificationFailedError {
174
+ return new PaymentsVerificationFailedError({
175
+ message: "That purchase could not be verified.",
176
+ action: "Retry. If it persists, the store's signing certificates may have changed — update the package.",
177
+ detail,
178
+ });
179
+ }
180
+
181
+ /** An X.509 `Time`, which is a UTCTime before 2050 and a GeneralizedTime after it. */
182
+ function readTime(node: Asn1Node): Date {
183
+ const text = new TextDecoder().decode(node.value);
184
+ if (node.tag === ASN1_UTC_TIME) {
185
+ if (!/^\d{12}Z$/.test(text)) throw malformed(`X.509: malformed UTCTime "${text}".`);
186
+ const twoDigitYear = Number(text.slice(0, 2));
187
+ // RFC 5280: 50–99 is 19xx, 00–49 is 20xx.
188
+ const year = twoDigitYear >= 50 ? 1900 + twoDigitYear : 2000 + twoDigitYear;
189
+ return new Date(
190
+ `${year}-${text.slice(2, 4)}-${text.slice(4, 6)}T${text.slice(6, 8)}:${text.slice(8, 10)}:${text.slice(10, 12)}Z`,
191
+ );
192
+ }
193
+ if (node.tag === ASN1_GENERALIZED_TIME) {
194
+ if (!/^\d{14}Z$/.test(text)) throw malformed(`X.509: malformed GeneralizedTime "${text}".`);
195
+ return new Date(
196
+ `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}T${text.slice(8, 10)}:${text.slice(10, 12)}:${text.slice(12, 14)}Z`,
197
+ );
198
+ }
199
+ throw malformed(`X.509: tag 0x${node.tag.toString(16)} is not a Time.`);
200
+ }
201
+
202
+ /** The curve named in a SubjectPublicKeyInfo's algorithm parameters. */
203
+ function readCurve(spki: Asn1Node): "P-256" | "P-384" {
204
+ const algorithm = asn1Children(spki)[0];
205
+ if (!algorithm) throw malformed("X.509: SubjectPublicKeyInfo has no algorithm.");
206
+ const parameters = asn1Children(algorithm)[1];
207
+ if (!parameters) throw malformed("X.509: the subject key names no curve — only ECDSA keys are accepted.");
208
+ const curve = CURVES[readOid(parameters)];
209
+ if (!curve) throw malformed(`X.509: curve ${readOid(parameters)} is not one this verifier accepts.`);
210
+ return curve;
211
+ }
212
+
213
+ /**
214
+ * The extensions of a tbsCertificate, by OID.
215
+ *
216
+ * `extensions` is `[3] EXPLICIT Extensions OPTIONAL`, the last field, and each `Extension` is
217
+ * `SEQUENCE { extnID OID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING }`. A repeated OID is
218
+ * refused: RFC 5280 forbids it, and a parser that kept the first while another kept the last would let one
219
+ * certificate mean two things.
220
+ */
221
+ function readExtensions(fields: readonly Asn1Node[]): Map<string, CertificateExtension> {
222
+ const found = new Map<string, CertificateExtension>();
223
+ const container = fields.find((field) => field.tag === 0xa3);
224
+ if (!container) return found;
225
+ const [list] = asn1Children(container);
226
+ if (!list || list.tag !== ASN1_SEQUENCE) throw malformed("X.509: extensions is not a SEQUENCE.");
227
+ for (const extension of asn1Children(list)) {
228
+ const parts = asn1Children(extension);
229
+ const [id] = parts;
230
+ if (!id) throw malformed("X.509: an extension has no OID.");
231
+ const oid = readOid(id);
232
+ // `critical` is optional, so the value is whichever OCTET STRING is present rather than a fixed index.
233
+ const critical = parts[1]?.tag === ASN1_BOOLEAN ? readBoolean(parts[1]) : false;
234
+ const value = parts.find((part, index) => index > 0 && part.tag === ASN1_OCTET_STRING);
235
+ if (!value) throw malformed(`X.509: extension ${oid} has no value.`);
236
+ if (found.has(oid)) throw malformed(`X.509: extension ${oid} appears twice.`);
237
+ found.set(oid, { critical, value: value.value });
238
+ }
239
+ return found;
240
+ }
241
+
242
+ /** `basicConstraints ::= SEQUENCE { cA BOOLEAN DEFAULT FALSE, pathLenConstraint INTEGER OPTIONAL }`. */
243
+ function readBasicConstraints(extension: CertificateExtension): BasicConstraints {
244
+ const { node } = readAsn1(extension.value);
245
+ if (node.tag !== ASN1_SEQUENCE) throw malformed("X.509: basicConstraints is not a SEQUENCE.");
246
+ const parts = asn1Children(node);
247
+ // Both members are optional, and an empty SEQUENCE is the encoding of `cA` at its default of false.
248
+ const ca = parts[0]?.tag === ASN1_BOOLEAN ? readBoolean(parts[0]) : false;
249
+ const lengthNode = parts.find((part) => part.tag === ASN1_INTEGER);
250
+ return lengthNode ? { ca, pathLen: readInteger(lengthNode) } : { ca };
251
+ }
252
+
253
+ /** Whether `keyUsage` grants `keyCertSign`. A short BIT STRING simply does not reach the bit. */
254
+ function readKeyCertSign(extension: CertificateExtension): boolean {
255
+ const { node } = readAsn1(extension.value);
256
+ return readBits(node)[KEY_CERT_SIGN_BIT] === true;
257
+ }
258
+
259
+ /**
260
+ * Parse one DER certificate.
261
+ *
262
+ * `Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue }`, and inside the tbs,
263
+ * `SEQUENCE { [0] version, serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo, … }`.
264
+ * The version field is optional, so the fields after it are located by counting from the end of whichever
265
+ * prefix is present rather than by fixed index.
266
+ */
267
+ export function parseCertificate(der: Uint8Array): ParsedCertificate {
268
+ const { node: certificate } = readAsn1(der);
269
+ if (certificate.tag !== ASN1_SEQUENCE) throw malformed("X.509: a certificate must be a SEQUENCE.");
270
+ const [tbs, algorithm, signature] = asn1Children(certificate);
271
+ if (!tbs || !algorithm || !signature) throw malformed("X.509: a certificate has three members.");
272
+
273
+ const fields = asn1Children(tbs);
274
+ // `[0] EXPLICIT Version` is optional and tagged 0xa0; without it the certificate is a v1 and
275
+ // serialNumber comes first. Everything after is positional from there.
276
+ const offset = fields[0]?.tag === 0xa0 ? 1 : 0;
277
+ const issuer = fields[offset + 2];
278
+ const validity = fields[offset + 3];
279
+ const subject = fields[offset + 4];
280
+ const spki = fields[offset + 5];
281
+ if (!issuer || !validity || !subject || !spki) throw malformed("X.509: tbsCertificate is missing fields.");
282
+
283
+ const [notBefore, notAfter] = asn1Children(validity);
284
+ if (!notBefore || !notAfter) throw malformed("X.509: validity needs both bounds.");
285
+
286
+ const extensions = readExtensions(fields);
287
+ const constraints = extensions.get(OID_BASIC_CONSTRAINTS);
288
+ const usage = extensions.get(OID_KEY_USAGE);
289
+
290
+ return {
291
+ der,
292
+ tbs: tbs.der,
293
+ issuer: issuer.der,
294
+ subject: subject.der,
295
+ notBefore: readTime(notBefore),
296
+ notAfter: readTime(notAfter),
297
+ spki: spki.der,
298
+ curve: readCurve(spki),
299
+ signatureAlgorithm: readOid(asn1Children(algorithm)[0] ?? algorithm),
300
+ // A BIT STRING's first contents byte counts unused trailing bits; for a signature it is always zero.
301
+ signature: signature.value.subarray(1),
302
+ extensions,
303
+ basicConstraints: constraints ? readBasicConstraints(constraints) : undefined,
304
+ keyCertSign: usage ? readKeyCertSign(usage) : undefined,
305
+ };
306
+ }
307
+
308
+ /** Import a certificate's public key for verification. */
309
+ async function importKey(certificate: ParsedCertificate): Promise<CryptoKey> {
310
+ try {
311
+ return await crypto.subtle.importKey(
312
+ "spki",
313
+ certificate.spki as unknown as ArrayBuffer,
314
+ { name: "ECDSA", namedCurve: certificate.curve },
315
+ false,
316
+ ["verify"],
317
+ );
318
+ } catch (cause) {
319
+ throw new PaymentsInvalidReceiptError(
320
+ { detail: `X.509: the ${certificate.curve} public key could not be imported.` },
321
+ { cause },
322
+ );
323
+ }
324
+ }
325
+
326
+ /** Whether `issuer` signed `certificate`. */
327
+ async function signedBy(certificate: ParsedCertificate, issuer: ParsedCertificate): Promise<boolean> {
328
+ const hash = SIGNATURE_HASHES[certificate.signatureAlgorithm];
329
+ if (!hash) throw rejected(`X.509: signature algorithm ${certificate.signatureAlgorithm} is not accepted.`);
330
+ const raw = derSignatureToRaw(certificate.signature, COORDINATE_BYTES[issuer.curve]);
331
+ return crypto.subtle.verify(
332
+ { name: "ECDSA", hash },
333
+ await importKey(issuer),
334
+ raw as unknown as ArrayBuffer,
335
+ certificate.tbs as unknown as ArrayBuffer,
336
+ );
337
+ }
338
+
339
+ /**
340
+ * Verify a certificate chain — leaf first, root last, the order `x5c` uses — and return the leaf's
341
+ * verifying key. Throws rather than returning a boolean: there is no caller that wants to carry on with an
342
+ * unverified chain, and a boolean invites one.
343
+ */
344
+ export async function verifyCertificateChain(
345
+ chain: readonly Uint8Array[],
346
+ options: VerifyChainOptions,
347
+ ): Promise<VerifiedLeaf> {
348
+ // Two is the minimum that can be checked at all: a lone certificate would be trusted on its own word.
349
+ if (chain.length < 2) throw rejected(`X.509: a chain of ${chain.length} cannot be verified; leaf and root at least.`);
350
+ // Bounded before anything is parsed, so an oversized chain costs one length check rather than N signature
351
+ // verifications. The chain arrives unauthenticated; this is the only thing standing between that and our CPU.
352
+ if (chain.length > MAX_CHAIN_LENGTH) {
353
+ throw rejected(`X.509: a chain of ${chain.length} is longer than the ${MAX_CHAIN_LENGTH} accepted.`);
354
+ }
355
+
356
+ const certificates = chain.map((der) => parseCertificate(der));
357
+ const at = options.now.getTime();
358
+ for (const [index, certificate] of certificates.entries()) {
359
+ if (at < certificate.notBefore.getTime() || at > certificate.notAfter.getTime()) {
360
+ throw rejected(
361
+ `X.509: certificate ${index} is valid ${certificate.notBefore.toISOString()}–${certificate.notAfter.toISOString()}.`,
362
+ );
363
+ }
364
+ }
365
+
366
+ const root = certificates[certificates.length - 1] as ParsedCertificate;
367
+ const pinned = options.roots.some((encoded) => bytesEqual(decodeBase64(encoded), root.der));
368
+ if (!pinned) throw rejected("X.509: the chain's root is not one of the pinned roots.");
369
+
370
+ /**
371
+ * Every certificate above the leaf must be entitled to issue the one below it.
372
+ *
373
+ * This is the check whose absence makes the rest decorative. A chain of
374
+ * `[forged, someAppleLeaf, intermediate, root]` links, sits inside every window, and roots in the pinned
375
+ * certificate — so without `cA` anyone holding any ECDSA certificate issued under Apple's root could sign
376
+ * `forged` and be believed. `pathLenConstraint` is the same rule stated as a depth: a CA that says "no
377
+ * intermediates beneath me" must not have any.
378
+ */
379
+ for (let index = 1; index < certificates.length; index += 1) {
380
+ const authority = certificates[index] as ParsedCertificate;
381
+ if (authority.basicConstraints?.ca !== true) {
382
+ throw rejected(`X.509: certificate ${index} issued the one below it but is not a CA (basicConstraints).`);
383
+ }
384
+ if (authority.keyCertSign === false) {
385
+ throw rejected(`X.509: certificate ${index} issued the one below it but its keyUsage omits keyCertSign.`);
386
+ }
387
+ // Certificates below this one that are themselves CAs — indices 1..index-1.
388
+ const intermediatesBelow = index - 1;
389
+ const { pathLen } = authority.basicConstraints;
390
+ if (pathLen !== undefined && intermediatesBelow > pathLen) {
391
+ throw rejected(
392
+ `X.509: certificate ${index} permits ${pathLen} intermediates beneath it, the chain has ${intermediatesBelow}.`,
393
+ );
394
+ }
395
+ }
396
+
397
+ for (let index = 0; index < certificates.length - 1; index += 1) {
398
+ const certificate = certificates[index] as ParsedCertificate;
399
+ const issuer = certificates[index + 1] as ParsedCertificate;
400
+ if (!bytesEqual(certificate.issuer, issuer.subject)) {
401
+ throw rejected(`X.509: certificate ${index}'s issuer name does not match certificate ${index + 1}'s subject.`);
402
+ }
403
+ if (!(await signedBy(certificate, issuer))) {
404
+ throw rejected(`X.509: certificate ${index} was not signed by certificate ${index + 1}.`);
405
+ }
406
+ }
407
+
408
+ const leaf = certificates[0] as ParsedCertificate;
409
+ // The identity half. Rooting in Apple's CA proves the path is Apple's; these prove it is the path Apple
410
+ // signs App Store data with, so a genuine Apple certificate issued for anything else cannot stand in.
411
+ const leafExtension =
412
+ options.requiredLeafExtension === undefined ? APPLE_RECEIPT_SIGNER_OID : options.requiredLeafExtension;
413
+ if (leafExtension !== null && !leaf.extensions.has(leafExtension)) {
414
+ throw rejected(`X.509: the leaf does not carry ${leafExtension}, so it is not an App Store signing certificate.`);
415
+ }
416
+ const issuerExtension =
417
+ options.requiredIssuerExtension === undefined ? APPLE_WWDR_INTERMEDIATE_OID : options.requiredIssuerExtension;
418
+ const issuer = certificates[1] as ParsedCertificate;
419
+ if (issuerExtension !== null && !issuer.extensions.has(issuerExtension)) {
420
+ throw rejected(`X.509: the leaf's issuer does not carry ${issuerExtension}, so it is not the WWDR intermediate.`);
421
+ }
422
+
423
+ return { key: await importKey(leaf), curve: leaf.curve };
424
+ }