payment-kit 1.29.5 → 1.29.7

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 (25) hide show
  1. package/api/src/integrations/app-store/client.ts +7 -13
  2. package/api/src/integrations/app-store/native-api.ts +102 -0
  3. package/api/src/integrations/app-store/native-asn1.ts +49 -0
  4. package/api/src/integrations/app-store/native-jws.ts +222 -0
  5. package/api/src/integrations/app-store/native-receipt.ts +105 -0
  6. package/api/src/integrations/app-store/signed-data-verifier.ts +40 -79
  7. package/api/tests/integrations/app-store/client.spec.ts +83 -57
  8. package/api/tests/integrations/app-store/fixtures/README.md +54 -0
  9. package/api/tests/integrations/app-store/fixtures/keys/int.key.pem +5 -0
  10. package/api/tests/integrations/app-store/fixtures/keys/leaf.key.pem +5 -0
  11. package/api/tests/integrations/app-store/fixtures/keys/root.key.pem +5 -0
  12. package/api/tests/integrations/app-store/fixtures/keys/wrongint.key.pem +5 -0
  13. package/api/tests/integrations/app-store/fixtures/make-chain.spec.ts +152 -0
  14. package/api/tests/integrations/app-store/fixtures/make-chain.ts +326 -0
  15. package/api/tests/integrations/app-store/fixtures/real-sandbox.json +43 -0
  16. package/api/tests/integrations/app-store/fixtures/real-sandbox.jws +1 -0
  17. package/api/tests/integrations/app-store/native-api.spec.ts +172 -0
  18. package/api/tests/integrations/app-store/native-integration.spec.ts +78 -0
  19. package/api/tests/integrations/app-store/native-jws.spec.ts +219 -0
  20. package/api/tests/integrations/app-store/native-receipt.spec.ts +161 -0
  21. package/blocklet.yml +1 -1
  22. package/cloudflare/cf-adapter.ts +14 -1
  23. package/cloudflare/tests/x509-probe.mjs +260 -0
  24. package/package.json +7 -10
  25. package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +0 -17
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Synthetic Apple-style 3-cert chain + ES256 JWS generator for the App Store
3
+ * native-verify tests (and mirrored by the workerd probe's chain round).
4
+ *
5
+ * CONTRACT — read before using in validity / clock-skew tests:
6
+ * - `verifyAppleJws` (Phase 1) uses the JWS payload's `signedDate` as the
7
+ * `effectiveDate` for ALL certificate validity checks — NEVER Date.now().
8
+ * So cert windows here are pinned with explicit notBefore/notAfter
9
+ * (`certValidFrom`/`certValidTo`) and the spec positions `signedDate`
10
+ * relative to them to exercise the ±60s skew boundary precisely.
11
+ * - Production OID presence (native-asn1.ts) matches the FULL DER tag
12
+ * `[0x06, len, ...content]`, STRICTER than the probe's content-only scan
13
+ * (see 0.3). The certs below carry the real Apple OIDs as `DER:0500` exts.
14
+ *
15
+ * This generator NEVER validates — by design it can emit deliberately
16
+ * malformed inputs (missing OID, cross-signed leaf, and via `buildJws` any
17
+ * wrong-length x5c / tampered sig / forged alg) so the verifier's negative
18
+ * vectors have inputs to reject.
19
+ *
20
+ * "Callable from the probe": the probe is a raw-node `.mjs` that cannot import
21
+ * this `.ts` module under the ts-jest (CJS) setup, so
22
+ * `cloudflare/tests/x509-probe.mjs` mirrors this chain-building inline. Keep the
23
+ * two in sync — the algorithm (root → CA intermediate w/ Apple OID → leaf w/
24
+ * Apple OID, ES256 P1363 sig over `header.payload`) is identical.
25
+ *
26
+ * Determinism: the EC P-256 signing keys are COMMITTED under `fixtures/keys/`
27
+ * (synthetic, test-only) and the certs are derived from them at runtime with
28
+ * pinned validity windows — so golden-vector VALUE assertions are stable and
29
+ * the certs never expire. Pass `freshKeys: true` to generate unique keys for a
30
+ * single call (e.g. to make two chains demonstrably distinct). The committed
31
+ * keys are test-only and MUST never be imported by `src/` — a data-leak spec
32
+ * (`make-chain.spec.ts`) guards that invariant. The committed REAL Apple
33
+ * fixture (`real-sandbox.jws`) is the golden vector for real-chain field values.
34
+ */
35
+ import { execFileSync } from 'node:child_process';
36
+ import { X509Certificate, sign as nodeSign } from 'node:crypto';
37
+ import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
38
+ import os from 'node:os';
39
+ import path from 'node:path';
40
+
41
+ export const APPLE_LEAF_OID = '1.2.840.113635.100.6.11.1';
42
+ export const APPLE_INT_OID = '1.2.840.113635.100.6.2.1';
43
+
44
+ const ONE_DAY_MS = 86_400_000;
45
+ const TEN_YEARS_MS = 10 * 365 * ONE_DAY_MS;
46
+
47
+ export interface MakeChainOptions {
48
+ /** ms; embedded as payload.signedDate (= effectiveDate verifyAppleJws uses). Default: Date.now(). */
49
+ signedDate?: number;
50
+ /** notBefore for all 3 certs. Default: signedDate − 1 day. */
51
+ certValidFrom?: Date;
52
+ /** notAfter for all 3 certs. Default: signedDate + 10 years. */
53
+ certValidTo?: Date;
54
+ /**
55
+ * Per-cert validity overrides, so a test can expire / not-yet-validate ONE cert
56
+ * (leaf, intermediate, or root) while the others stay valid — proving the
57
+ * verifier checks all three. Falls back to certValidFrom/certValidTo.
58
+ */
59
+ certWindows?: {
60
+ leaf?: { from?: Date; to?: Date };
61
+ intermediate?: { from?: Date; to?: Date };
62
+ root?: { from?: Date; to?: Date };
63
+ };
64
+ /** extra/override payload fields merged into the default transaction payload. */
65
+ payload?: Record<string, unknown>;
66
+ /** header `alg`; default 'ES256'. Override to forge alg-confusion vectors. */
67
+ alg?: string;
68
+ /** include the Apple leaf OID on the leaf (default true). */
69
+ leafOid?: boolean;
70
+ /** include the Apple intermediate OID on the intermediate (default true). */
71
+ intOid?: boolean;
72
+ /** generate unique keys instead of reusing the committed fixtures/keys/ PEMs (default false). */
73
+ freshKeys?: boolean;
74
+ }
75
+
76
+ export interface Chain {
77
+ /** [leafDER_b64, intermediateDER_b64, rootDER_b64] — standard base64 (as in a JWS header). */
78
+ x5c: string[];
79
+ /** valid `header.payload.p1363sig` JWS signed by the leaf key. */
80
+ jws: string;
81
+ /** `header.payload` — the ASCII bytes the ES256 signature covers. */
82
+ signingInput: string;
83
+ /** raw 64-byte IEEE-P1363 (r‖s) signature over `signingInput`. */
84
+ p1363Sig: Buffer;
85
+ /** b64 DER of a leaf signed by a DIFFERENT intermediate key (same subject DN): passes
86
+ * issuer/subject equality but fails `leaf.verify(realIntermediate.publicKey)`. */
87
+ crossSignedLeaf: string;
88
+ keys: { rootKeyPem: string; intKeyPem: string; leafKeyPem: string; wrongIntKeyPem: string };
89
+ certs: { leaf: X509Certificate; intermediate: X509Certificate; root: X509Certificate };
90
+ payload: Record<string, unknown>;
91
+ header: Record<string, unknown>;
92
+ }
93
+
94
+ const SUBJ = {
95
+ root: '/CN=Test Apple Root CA/O=did-pay-test',
96
+ int: '/CN=Test Apple WWDR Intermediate/O=did-pay-test',
97
+ leaf: '/CN=Test Apple Leaf/O=did-pay-test',
98
+ };
99
+
100
+ /** Committed synthetic signing keys (test-only, never imported by src/). */
101
+ const KEYS_DIR = path.join(__dirname, 'keys');
102
+
103
+ /** openssl `-not_before`/`-not_after` want `[CC]YYMMDDHHMMSSZ`. */
104
+ function fmtTime(d: Date): string {
105
+ return `${d
106
+ .toISOString()
107
+ .replace(/[-:]/g, '')
108
+ .replace('T', '')
109
+ .replace(/\.\d+Z$/, '')}Z`;
110
+ }
111
+
112
+ /** Sign `signingInput` (ASCII) with an EC P-256 key, returning a raw 64-byte P1363 sig. */
113
+ export function signES256(signingInput: string, privateKeyPem: string): Buffer {
114
+ return nodeSign('sha256', Buffer.from(signingInput, 'ascii'), { key: privateKeyPem, dsaEncoding: 'ieee-p1363' });
115
+ }
116
+
117
+ /** Build a `header.payload.p1363sig` JWS from arbitrary header/payload objects (for crafting variants). */
118
+ export function buildJws(
119
+ header: Record<string, unknown>,
120
+ payload: Record<string, unknown>,
121
+ leafKeyPem: string
122
+ ): { jws: string; signingInput: string; p1363Sig: Buffer } {
123
+ const h = Buffer.from(JSON.stringify(header)).toString('base64url');
124
+ const p = Buffer.from(JSON.stringify(payload)).toString('base64url');
125
+ const signingInput = `${h}.${p}`;
126
+ const p1363Sig = signES256(signingInput, leafKeyPem);
127
+ return { jws: `${signingInput}.${p1363Sig.toString('base64url')}`, signingInput, p1363Sig };
128
+ }
129
+
130
+ export function makeChain(opts: MakeChainOptions = {}): Chain {
131
+ const signedDate = opts.signedDate ?? Date.now();
132
+ const validFrom = opts.certValidFrom ?? new Date(signedDate - ONE_DAY_MS);
133
+ const validTo = opts.certValidTo ?? new Date(signedDate + TEN_YEARS_MS);
134
+ // Resolve the [notBefore, notAfter] pair for a given cert, honoring per-cert overrides.
135
+ const windowFor = (cert: 'leaf' | 'intermediate' | 'root'): { nb: string; na: string } => {
136
+ const o = opts.certWindows?.[cert];
137
+ return { nb: fmtTime(o?.from ?? validFrom), na: fmtTime(o?.to ?? validTo) };
138
+ };
139
+
140
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'make-chain-'));
141
+ const file = (name: string): string => path.join(dir, name);
142
+ const ossl = (args: string[]): void => {
143
+ execFileSync('openssl', args, { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] });
144
+ };
145
+ // Reuse the committed synthetic keys (deterministic) unless freshKeys is set.
146
+ // `tmpName` is the working filename (e.g. 'root.key'); `committed` is the
147
+ // fixtures/keys/<committed>.key.pem stem.
148
+ const provisionKey = (tmpName: string, committed: string): void => {
149
+ if (opts.freshKeys) {
150
+ ossl(['ecparam', '-name', 'prime256v1', '-genkey', '-noout', '-out', file(tmpName)]);
151
+ } else {
152
+ copyFileSync(path.join(KEYS_DIR, `${committed}.key.pem`), file(tmpName));
153
+ }
154
+ };
155
+
156
+ try {
157
+ const rootW = windowFor('root');
158
+ const intW = windowFor('intermediate');
159
+ const leafW = windowFor('leaf');
160
+
161
+ // root — self-signed CA
162
+ provisionKey('root.key', 'root');
163
+ ossl([
164
+ 'req',
165
+ '-new',
166
+ '-x509',
167
+ '-key',
168
+ file('root.key'),
169
+ '-out',
170
+ file('root.pem'),
171
+ '-subj',
172
+ SUBJ.root,
173
+ '-not_before',
174
+ rootW.nb,
175
+ '-not_after',
176
+ rootW.na,
177
+ '-addext',
178
+ 'basicConstraints=critical,CA:TRUE',
179
+ '-addext',
180
+ 'keyUsage=critical,keyCertSign,cRLSign',
181
+ ]);
182
+
183
+ // intermediate — CA + Apple intermediate OID, signed by root
184
+ provisionKey('int.key', 'int');
185
+ ossl(['req', '-new', '-key', file('int.key'), '-out', file('int.csr'), '-subj', SUBJ.int]);
186
+ const intExt = ['[v3]', 'basicConstraints=critical,CA:TRUE', 'keyUsage=critical,keyCertSign,cRLSign'];
187
+ if (opts.intOid !== false) intExt.push(`${APPLE_INT_OID}=DER:0500`);
188
+ writeFileSync(file('int.ext'), intExt.join('\n'));
189
+ ossl([
190
+ 'x509',
191
+ '-req',
192
+ '-in',
193
+ file('int.csr'),
194
+ '-CA',
195
+ file('root.pem'),
196
+ '-CAkey',
197
+ file('root.key'),
198
+ '-CAcreateserial',
199
+ '-out',
200
+ file('int.pem'),
201
+ '-not_before',
202
+ intW.nb,
203
+ '-not_after',
204
+ intW.na,
205
+ '-extfile',
206
+ file('int.ext'),
207
+ '-extensions',
208
+ 'v3',
209
+ ]);
210
+
211
+ // leaf — Apple leaf OID, signed by intermediate
212
+ provisionKey('leaf.key', 'leaf');
213
+ ossl(['req', '-new', '-key', file('leaf.key'), '-out', file('leaf.csr'), '-subj', SUBJ.leaf]);
214
+ const leafExt = ['[v3]', 'basicConstraints=critical,CA:FALSE'];
215
+ if (opts.leafOid !== false) leafExt.push(`${APPLE_LEAF_OID}=DER:0500`);
216
+ writeFileSync(file('leaf.ext'), leafExt.join('\n'));
217
+ ossl([
218
+ 'x509',
219
+ '-req',
220
+ '-in',
221
+ file('leaf.csr'),
222
+ '-CA',
223
+ file('int.pem'),
224
+ '-CAkey',
225
+ file('int.key'),
226
+ '-CAcreateserial',
227
+ '-out',
228
+ file('leaf.pem'),
229
+ '-not_before',
230
+ leafW.nb,
231
+ '-not_after',
232
+ leafW.na,
233
+ '-extfile',
234
+ file('leaf.ext'),
235
+ '-extensions',
236
+ 'v3',
237
+ ]);
238
+
239
+ // wrong intermediate — SAME subject DN, DIFFERENT key. A leaf it signs passes
240
+ // issuer/subject equality but fails leaf.verify(realIntermediate.publicKey),
241
+ // isolating the chain-signature failure for the negative control.
242
+ provisionKey('wrongint.key', 'wrongint');
243
+ ossl([
244
+ 'req',
245
+ '-new',
246
+ '-x509',
247
+ '-key',
248
+ file('wrongint.key'),
249
+ '-out',
250
+ file('wrongint.pem'),
251
+ '-subj',
252
+ SUBJ.int,
253
+ '-not_before',
254
+ intW.nb,
255
+ '-not_after',
256
+ intW.na,
257
+ '-addext',
258
+ 'basicConstraints=critical,CA:TRUE',
259
+ ]);
260
+ provisionKey('crossleaf.key', 'leaf');
261
+ ossl(['req', '-new', '-key', file('crossleaf.key'), '-out', file('crossleaf.csr'), '-subj', SUBJ.leaf]);
262
+ ossl([
263
+ 'x509',
264
+ '-req',
265
+ '-in',
266
+ file('crossleaf.csr'),
267
+ '-CA',
268
+ file('wrongint.pem'),
269
+ '-CAkey',
270
+ file('wrongint.key'),
271
+ '-CAcreateserial',
272
+ '-out',
273
+ file('crossleaf.pem'),
274
+ '-not_before',
275
+ leafW.nb,
276
+ '-not_after',
277
+ leafW.na,
278
+ '-extfile',
279
+ file('leaf.ext'),
280
+ '-extensions',
281
+ 'v3',
282
+ ]);
283
+
284
+ const leafCert = new X509Certificate(readFileSync(file('leaf.pem')));
285
+ const intCert = new X509Certificate(readFileSync(file('int.pem')));
286
+ const rootCert = new X509Certificate(readFileSync(file('root.pem')));
287
+ const crossLeafCert = new X509Certificate(readFileSync(file('crossleaf.pem')));
288
+
289
+ const x5c = [
290
+ Buffer.from(leafCert.raw).toString('base64'),
291
+ Buffer.from(intCert.raw).toString('base64'),
292
+ Buffer.from(rootCert.raw).toString('base64'),
293
+ ];
294
+ const header = { alg: opts.alg ?? 'ES256', x5c };
295
+ const payload: Record<string, unknown> = {
296
+ transactionId: 'txn_synth_1',
297
+ originalTransactionId: 'orig_synth_1',
298
+ productId: 'sub_synthetic',
299
+ bundleId: 'com.example.app',
300
+ environment: 'Sandbox',
301
+ signedDate,
302
+ ...opts.payload,
303
+ };
304
+ const leafKeyPem = readFileSync(file('leaf.key'), 'utf8');
305
+ const { jws, signingInput, p1363Sig } = buildJws(header, payload, leafKeyPem);
306
+
307
+ return {
308
+ x5c,
309
+ jws,
310
+ signingInput,
311
+ p1363Sig,
312
+ crossSignedLeaf: Buffer.from(crossLeafCert.raw).toString('base64'),
313
+ keys: {
314
+ rootKeyPem: readFileSync(file('root.key'), 'utf8'),
315
+ intKeyPem: readFileSync(file('int.key'), 'utf8'),
316
+ leafKeyPem,
317
+ wrongIntKeyPem: readFileSync(file('wrongint.key'), 'utf8'),
318
+ },
319
+ certs: { leaf: leafCert, intermediate: intCert, root: rootCert },
320
+ payload,
321
+ header,
322
+ };
323
+ } finally {
324
+ rmSync(dir, { recursive: true, force: true });
325
+ }
326
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "_meta": {
3
+ "source": "Apple App Store Server API getTransactionInfo()",
4
+ "fetched_at": "2026-06-15T11:02:00Z",
5
+ "bundle_id": "io.arcblock.ai.stro",
6
+ "environment": "Sandbox",
7
+ "transaction_id": "2000001186283296",
8
+ "verify_url": "https://payment-demo.ofind.cn/api/integrations/app-store/verify",
9
+ "notes": "Apple-signed (ES256). Verifiable against Apple Root CA G3. Use as a fixture for unit tests that need a real signed transaction."
10
+ },
11
+ "decoded_header": {
12
+ "alg": "ES256",
13
+ "x5c_chain": [
14
+ "Prod ECC Mac App Store and iTunes Store Receipt Signing (leaf, expires 2027-10-13)",
15
+ "Apple Worldwide Developer Relations Certification Authority G6 (intermediate)",
16
+ "Apple Root CA - G3 (root, expires 2039-04-30)"
17
+ ]
18
+ },
19
+ "decoded_payload": {
20
+ "transactionId": "2000001186283296",
21
+ "originalTransactionId": "2000001185803701",
22
+ "webOrderLineItemId": "2000000145344971",
23
+ "bundleId": "io.arcblock.ai.stro",
24
+ "productId": "io.aistro.monthly",
25
+ "subscriptionGroupIdentifier": "21331532",
26
+ "purchaseDate": 1781142078000,
27
+ "originalPurchaseDate": 1781080008000,
28
+ "expiresDate": 1781142258000,
29
+ "quantity": 1,
30
+ "type": "Auto-Renewable Subscription",
31
+ "appAccountToken": "040288cc-5062-5af3-ae64-1b1576bd3af7",
32
+ "inAppOwnershipType": "PURCHASED",
33
+ "signedDate": 1781521334379,
34
+ "environment": "Sandbox",
35
+ "transactionReason": "PURCHASE",
36
+ "storefront": "USA",
37
+ "storefrontId": "143441",
38
+ "price": 9990,
39
+ "currency": "USD",
40
+ "appTransactionId": "705611184395056381",
41
+ "billingPlanType": "BILLED_UPFRONT"
42
+ }
43
+ }
@@ -0,0 +1 @@
1
+ eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTVRDQ0E3YWdBd0lCQWdJUVI4S0h6ZG41NTRaL1VvcmFkTng5dHpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJMU1Ea3hPVEU1TkRRMU1Wb1hEVEkzTVRBeE16RTNORGN5TTFvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRmR2Y214a2QybGtaU0JFWlhabGJHOXdaWElnVW1Wc1lYUnBiMjV6TVJNd0VRWURWUVFLREFwQmNIQnNaU0JKYm1NdU1Rc3dDUVlEVlFRR0V3SlZVekJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCTm5WdmhjdjdpVCs3RXg1dEJNQmdyUXNwSHpJc1hSaTBZeGZlazdsdjh3RW1qL2JIaVd0TndKcWMyQm9IenNRaUVqUDdLRklJS2c0WTh5MC9ueW51QW1qZ2dJSU1JSUNCREFNQmdOVkhSTUJBZjhFQWpBQU1COEdBMVVkSXdRWU1CYUFGRDh2bENOUjAxREptaWc5N2JCODVjK2xrR0taTUhBR0NDc0dBUVVGQndFQkJHUXdZakF0QmdnckJnRUZCUWN3QW9ZaGFIUjBjRG92TDJObGNuUnpMbUZ3Y0d4bExtTnZiUzkzZDJSeVp6WXVaR1Z5TURFR0NDc0dBUVVGQnpBQmhpVm9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMWGQzWkhKbk5qQXlNSUlCSGdZRFZSMGdCSUlCRlRDQ0FSRXdnZ0VOQmdvcWhraUc5Mk5rQlFZQk1JSCtNSUhEQmdnckJnRUZCUWNDQWpDQnRneUJzMUpsYkdsaGJtTmxJRzl1SUhSb2FYTWdZMlZ5ZEdsbWFXTmhkR1VnWW5rZ1lXNTVJSEJoY25SNUlHRnpjM1Z0WlhNZ1lXTmpaWEIwWVc1alpTQnZaaUIwYUdVZ2RHaGxiaUJoY0hCc2FXTmhZbXhsSUhOMFlXNWtZWEprSUhSbGNtMXpJR0Z1WkNCamIyNWthWFJwYjI1eklHOW1JSFZ6WlN3Z1kyVnlkR2xtYVdOaGRHVWdjRzlzYVdONUlHRnVaQ0JqWlhKMGFXWnBZMkYwYVc5dUlIQnlZV04wYVdObElITjBZWFJsYldWdWRITXVNRFlHQ0NzR0FRVUZCd0lCRmlwb2RIUndPaTh2ZDNkM0xtRndjR3hsTG1OdmJTOWpaWEowYVdacFkyRjBaV0YxZEdodmNtbDBlUzh3SFFZRFZSME9CQllFRklGaW9HNHdNTVZBMWt1OXpKbUdOUEFWbjNlcU1BNEdBMVVkRHdFQi93UUVBd0lIZ0RBUUJnb3Foa2lHOTJOa0Jnc0JCQUlGQURBS0JnZ3Foa2pPUFFRREF3TnBBREJtQWpFQStxWG5SRUM3aFhJV1ZMc0x4em5qUnBJelBmN1ZIejlWL0NUbTgrTEpsclFlcG5tY1B2R0xOY1g2WFBubGNnTEFBakVBNUlqTlpLZ2c1cFE3OWtuRjRJYlRYZEt2OHZ1dElETVhEbWpQVlQzZEd2RnRzR1J3WE95d1Iya1pDZFNyZmVvdCIsIk1JSURGakNDQXB5Z0F3SUJBZ0lVSXNHaFJ3cDBjMm52VTRZU3ljYWZQVGp6Yk5jd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NakV3TXpFM01qQXpOekV3V2hjTk16WXdNekU1TURBd01EQXdXakIxTVVRd1FnWURWUVFERER0QmNIQnNaU0JYYjNKc1pIZHBaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBhVzl1Y3lCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVMTUFrR0ExVUVDd3dDUnpZeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUhZd0VBWUhLb1pJemowQ0FRWUZLNEVFQUNJRFlnQUVic1FLQzk0UHJsV21aWG5YZ3R4emRWSkw4VDBTR1luZ0RSR3BuZ24zTjZQVDhKTUViN0ZEaTRiQm1QaENuWjMvc3E2UEYvY0djS1hXc0w1dk90ZVJoeUo0NXgzQVNQN2NPQithYW85MGZjcHhTdi9FWkZibmlBYk5nWkdoSWhwSW80SDZNSUgzTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0h3WURWUjBqQkJnd0ZvQVV1N0Rlb1ZnemlKcWtpcG5ldnIzcnI5ckxKS3N3UmdZSUt3WUJCUVVIQVFFRU9qQTRNRFlHQ0NzR0FRVUZCekFCaGlwb2RIUndPaTh2YjJOemNDNWhjSEJzWlM1amIyMHZiMk56Y0RBekxXRndjR3hsY205dmRHTmhaek13TndZRFZSMGZCREF3TGpBc29DcWdLSVltYUhSMGNEb3ZMMk55YkM1aGNIQnNaUzVqYjIwdllYQndiR1Z5YjI5MFkyRm5NeTVqY213d0hRWURWUjBPQkJZRUZEOHZsQ05SMDFESm1pZzk3YkI4NWMrbGtHS1pNQTRHQTFVZER3RUIvd1FFQXdJQkJqQVFCZ29xaGtpRzkyTmtCZ0lCQkFJRkFEQUtCZ2dxaGtqT1BRUURBd05vQURCbEFqQkFYaFNxNUl5S29nTUNQdHc0OTBCYUI2NzdDYUVHSlh1ZlFCL0VxWkdkNkNTamlDdE9udU1UYlhWWG14eGN4ZmtDTVFEVFNQeGFyWlh2TnJreFUzVGtVTUkzM3l6dkZWVlJUNHd4V0pDOTk0T3NkY1o0K1JHTnNZRHlSNWdtZHIwbkRHZz0iLCJNSUlDUXpDQ0FjbWdBd0lCQWdJSUxjWDhpTkxGUzVVd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NVFF3TkRNd01UZ3hPVEEyV2hjTk16a3dORE13TVRneE9UQTJXakJuTVJzd0dRWURWUVFEREJKQmNIQnNaU0JTYjI5MElFTkJJQzBnUnpNeEpqQWtCZ05WQkFzTUhVRndjR3hsSUVObGNuUnBabWxqWVhScGIyNGdRWFYwYUc5eWFYUjVNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVFzd0NRWURWUVFHRXdKVlV6QjJNQkFHQnlxR1NNNDlBZ0VHQlN1QkJBQWlBMklBQkpqcEx6MUFjcVR0a3lKeWdSTWMzUkNWOGNXalRuSGNGQmJaRHVXbUJTcDNaSHRmVGpqVHV4eEV0WC8xSDdZeVlsM0o2WVJiVHpCUEVWb0EvVmhZREtYMUR5eE5CMGNUZGRxWGw1ZHZNVnp0SzUxN0lEdll1VlRaWHBta09sRUtNYU5DTUVBd0hRWURWUjBPQkJZRUZMdXczcUZZTTRpYXBJcVozcjY5NjYvYXl5U3JNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdEZ1lEVlIwUEFRSC9CQVFEQWdFR01Bb0dDQ3FHU000OUJBTURBMmdBTUdVQ01RQ0Q2Y0hFRmw0YVhUUVkyZTN2OUd3T0FFWkx1Tit5UmhIRkQvM21lb3locG12T3dnUFVuUFdUeG5TNGF0K3FJeFVDTUcxbWloREsxQTNVVDgyTlF6NjBpbU9sTTI3amJkb1h0MlFmeUZNbStZaGlkRGtMRjF2TFVhZ002QmdENTZLeUtBPT0iXX0.eyJ0cmFuc2FjdGlvbklkIjoiMjAwMDAwMTE4NjI4MzI5NiIsIm9yaWdpbmFsVHJhbnNhY3Rpb25JZCI6IjIwMDAwMDExODU4MDM3MDEiLCJ3ZWJPcmRlckxpbmVJdGVtSWQiOiIyMDAwMDAwMTQ1MzQ0OTcxIiwiYnVuZGxlSWQiOiJpby5hcmNibG9jay5haS5zdHJvIiwicHJvZHVjdElkIjoiaW8uYWlzdHJvLm1vbnRobHkiLCJzdWJzY3JpcHRpb25Hcm91cElkZW50aWZpZXIiOiIyMTMzMTUzMiIsInB1cmNoYXNlRGF0ZSI6MTc4MTE0MjA3ODAwMCwib3JpZ2luYWxQdXJjaGFzZURhdGUiOjE3ODEwODAwMDgwMDAsImV4cGlyZXNEYXRlIjoxNzgxMTQyMjU4MDAwLCJxdWFudGl0eSI6MSwidHlwZSI6IkF1dG8tUmVuZXdhYmxlIFN1YnNjcmlwdGlvbiIsImFwcEFjY291bnRUb2tlbiI6IjA0MDI4OGNjLTUwNjItNWFmMy1hZTY0LTFiMTU3NmJkM2FmNyIsImluQXBwT3duZXJzaGlwVHlwZSI6IlBVUkNIQVNFRCIsInNpZ25lZERhdGUiOjE3ODE1MjEzMzQzNzksImVudmlyb25tZW50IjoiU2FuZGJveCIsInRyYW5zYWN0aW9uUmVhc29uIjoiUFVSQ0hBU0UiLCJzdG9yZWZyb250IjoiVVNBIiwic3RvcmVmcm9udElkIjoiMTQzNDQxIiwicHJpY2UiOjk5OTAsImN1cnJlbmN5IjoiVVNEIiwiYXBwVHJhbnNhY3Rpb25JZCI6IjcwNTYxMTE4NDM5NTA1NjM4MSIsImJpbGxpbmdQbGFuVHlwZSI6IkJJTExFRF9VUEZST05UIn0.uy7PLqCBuoT9vcUjr8S5qkxpzRGZVwoB9wMum67HXonX7zMrP-VjIzOcWlkt0_WFnjDaMzE0plvEiMkFSWVBoA
@@ -0,0 +1,172 @@
1
+ // Unit tests for the native App Store Server API client (Phase 2, Path B).
2
+ // fetch is mocked — we never hit Apple. A real EC P-256 PKCS#8 key is generated
3
+ // per-suite (Apple `.p8` keys are PKCS#8) to exercise the subtle pkcs8 sign path.
4
+
5
+ import { generateKeyPairSync } from 'node:crypto';
6
+
7
+ import { nativeGetAllSubscriptionStatuses } from '../../../src/integrations/app-store/native-api';
8
+ import type { AppStoreApiCredentials } from '../../../src/integrations/app-store/signed-data-verifier';
9
+
10
+ const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
11
+ const PKCS8_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
12
+
13
+ const creds = (over: Partial<AppStoreApiCredentials> = {}): AppStoreApiCredentials => ({
14
+ issuerId: 'iss_1',
15
+ keyId: 'KEY_ID_1',
16
+ privateKeyPem: PKCS8_PEM,
17
+ bundleId: 'com.example.app',
18
+ environment: 'sandbox',
19
+ ...over,
20
+ });
21
+
22
+ type FetchCapture = { url: string; init: RequestInit | undefined };
23
+ let lastFetch: FetchCapture;
24
+
25
+ function mockFetch(impl: () => { ok: boolean; status: number; json?: () => Promise<unknown> }): void {
26
+ global.fetch = jest.fn(async (url: unknown, init?: unknown) => {
27
+ lastFetch = { url: String(url), init: init as RequestInit };
28
+ const r = impl();
29
+ return { ok: r.ok, status: r.status, json: r.json ?? (async () => ({})) } as unknown as Response;
30
+ }) as unknown as typeof fetch;
31
+ }
32
+
33
+ const okStatusResponse = () => ({
34
+ ok: true,
35
+ status: 200,
36
+ json: async () => ({
37
+ data: [
38
+ {
39
+ subscriptionGroupIdentifier: 'g1',
40
+ lastTransactions: [{ originalTransactionId: 'orig_X', status: 1, signedTransactionInfo: 'inner.jws.sig' }],
41
+ },
42
+ ],
43
+ }),
44
+ });
45
+
46
+ function authHeader(): string {
47
+ return (lastFetch.init!.headers as Record<string, string>).Authorization ?? '';
48
+ }
49
+
50
+ function decodeJwt(header: string): { header: any; payload: any } {
51
+ const jwt = header.replace(/^Bearer /, '');
52
+ const [h, p] = jwt.split('.');
53
+ return {
54
+ header: JSON.parse(Buffer.from(h!, 'base64url').toString('utf8')),
55
+ payload: JSON.parse(Buffer.from(p!, 'base64url').toString('utf8')),
56
+ };
57
+ }
58
+
59
+ afterEach(() => {
60
+ jest.restoreAllMocks();
61
+ });
62
+
63
+ describe('nativeGetAllSubscriptionStatuses — happy path', () => {
64
+ it('hits the sandbox host with a Bearer token and parses StatusResponse', async () => {
65
+ mockFetch(okStatusResponse);
66
+ const res = await nativeGetAllSubscriptionStatuses('orig_X', creds());
67
+ expect(new URL(lastFetch.url).host).toBe('api.storekit-sandbox.apple.com');
68
+ expect(new URL(lastFetch.url).pathname).toBe('/inApps/v1/subscriptions/orig_X');
69
+ const auth = authHeader();
70
+ expect(auth).toMatch(/^Bearer /);
71
+ expect(res.data?.[0]?.lastTransactions?.[0]?.originalTransactionId).toBe('orig_X');
72
+ });
73
+
74
+ it('hits the PRODUCTION host (no itunes) when environment=production', async () => {
75
+ mockFetch(okStatusResponse);
76
+ await nativeGetAllSubscriptionStatuses('orig_X', creds({ environment: 'production' }));
77
+ expect(new URL(lastFetch.url).host).toBe('api.storekit.apple.com');
78
+ });
79
+ });
80
+
81
+ describe('nativeGetAllSubscriptionStatuses — bad input', () => {
82
+ it('throws when issuer/key/pem are missing', async () => {
83
+ mockFetch(okStatusResponse);
84
+ await expect(nativeGetAllSubscriptionStatuses('orig_X', creds({ issuerId: '' }))).rejects.toThrow(
85
+ /issuer_id\/key_id\/private_key_pem/
86
+ );
87
+ });
88
+
89
+ it('throws on a malformed .p8 PEM (not a silent unsigned request)', async () => {
90
+ mockFetch(okStatusResponse);
91
+ await expect(
92
+ nativeGetAllSubscriptionStatuses(
93
+ 'orig_X',
94
+ creds({ privateKeyPem: '-----BEGIN PRIVATE KEY-----\nGARBAGE\n-----END PRIVATE KEY-----' })
95
+ )
96
+ ).rejects.toThrow(/not a valid EC P-256 PKCS#8 key/);
97
+ expect(global.fetch).not.toHaveBeenCalled();
98
+ });
99
+ });
100
+
101
+ describe('nativeGetAllSubscriptionStatuses — security', () => {
102
+ it('signs an ES256 JWT with aud=appstoreconnect-v1 and exp−iat=300s', async () => {
103
+ mockFetch(okStatusResponse);
104
+ await nativeGetAllSubscriptionStatuses('orig_X', creds());
105
+ const { header, payload } = decodeJwt(authHeader());
106
+ expect(header.alg).toBe('ES256');
107
+ expect(header.kid).toBe('KEY_ID_1');
108
+ expect(payload.aud).toBe('appstoreconnect-v1');
109
+ expect(payload.iss).toBe('iss_1');
110
+ expect(payload.bid).toBe('com.example.app');
111
+ expect(payload.exp - payload.iat).toBe(300);
112
+ });
113
+
114
+ it('never leaks the private key into the URL or request body', async () => {
115
+ mockFetch(okStatusResponse);
116
+ await nativeGetAllSubscriptionStatuses('orig_X', creds());
117
+ expect(lastFetch.url).not.toContain('PRIVATE KEY');
118
+ expect(JSON.stringify(lastFetch.init)).not.toContain('PRIVATE KEY');
119
+ expect(lastFetch.init!.method).toBe('GET'); // no body at all
120
+ });
121
+
122
+ it.each(['../../v1/other', '..%2f..%2f', 'x@evil.host', 'x/../../', 'A'.repeat(4096)])(
123
+ 'confines a crafted originalTransactionId %p to one path segment (SSRF guard)',
124
+ async (badId) => {
125
+ mockFetch(okStatusResponse);
126
+ await nativeGetAllSubscriptionStatuses(badId, creds());
127
+ const u = new URL(lastFetch.url);
128
+ expect(u.host).toBe('api.storekit-sandbox.apple.com');
129
+ // exactly the fixed prefix + ONE encoded segment, no extra slashes from the id
130
+ expect(u.pathname).toBe(`/inApps/v1/subscriptions/${encodeURIComponent(badId)}`);
131
+ }
132
+ );
133
+ });
134
+
135
+ describe('nativeGetAllSubscriptionStatuses — data loss', () => {
136
+ it('returns an empty response on 404 instead of throwing (does not drop a pending reconcile)', async () => {
137
+ mockFetch(() => ({ ok: false, status: 404 }));
138
+ const res = await nativeGetAllSubscriptionStatuses('orig_unknown', creds());
139
+ expect(res).toEqual({ data: [] });
140
+ });
141
+ });
142
+
143
+ describe('nativeGetAllSubscriptionStatuses — data damage', () => {
144
+ it('passes signedTransactionInfo through unmodified and keeps numeric field types', async () => {
145
+ mockFetch(() => ({
146
+ ok: true,
147
+ status: 200,
148
+ json: async () => ({
149
+ data: [{ lastTransactions: [{ originalTransactionId: 'o', status: 2, signedTransactionInfo: 'A.B.C' }] }],
150
+ }),
151
+ }));
152
+ const res = await nativeGetAllSubscriptionStatuses('o', creds());
153
+ const tx = res.data?.[0]?.lastTransactions?.[0];
154
+ expect(tx?.signedTransactionInfo).toBe('A.B.C');
155
+ expect(typeof (tx as { status?: number }).status).toBe('number');
156
+ });
157
+ });
158
+
159
+ describe('nativeGetAllSubscriptionStatuses — data leak', () => {
160
+ it('on 401/403 surfaces only the status, never the bearer JWT or .p8', async () => {
161
+ mockFetch(() => ({ ok: false, status: 401 }));
162
+ let caught: Error | undefined;
163
+ try {
164
+ await nativeGetAllSubscriptionStatuses('orig_X', creds());
165
+ } catch (e) {
166
+ caught = e as Error;
167
+ }
168
+ expect(caught?.message).toMatch(/HTTP 401/);
169
+ expect(caught?.message).not.toContain('Bearer');
170
+ expect(caught?.message).not.toContain('PRIVATE KEY');
171
+ });
172
+ });
@@ -0,0 +1,78 @@
1
+ // Integration (Phase 2): with APP_STORE_NATIVE_VERIFY=true and a mocked fetch,
2
+ // AppStoreClient.getSubscriptionStatus drives the WHOLE native path —
3
+ // native API client (Path B, ES256 JWT bearer + fetch) → the returned
4
+ // signedTransactionInfo re-verified by the native JWS verifier (Path A) — using
5
+ // the REAL Apple sandbox JWS as the inner transaction. No apple lib involved.
6
+
7
+ /* eslint-disable import/first */
8
+ process.env.APP_STORE_NATIVE_VERIFY = 'true';
9
+ delete process.env.APP_STORE_SKIP_SIGNATURE_VERIFY;
10
+
11
+ import { generateKeyPairSync } from 'node:crypto';
12
+ import { readFileSync } from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ import { AppStoreClient } from '../../../src/integrations/app-store/client';
16
+
17
+ const REAL_JWS = readFileSync(path.join(__dirname, 'fixtures', 'real-sandbox.jws'), 'utf8').trim();
18
+ const REAL = JSON.parse(readFileSync(path.join(__dirname, 'fixtures', 'real-sandbox.json'), 'utf8')).decoded_payload;
19
+ const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
20
+ const PKCS8_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
21
+
22
+ afterEach(() => jest.restoreAllMocks());
23
+ afterAll(() => {
24
+ delete process.env.APP_STORE_NATIVE_VERIFY;
25
+ });
26
+
27
+ describe('AppStoreClient.getSubscriptionStatus — native end-to-end', () => {
28
+ it('signs a bearer, fetches statuses, and re-verifies the real signedTransactionInfo natively', async () => {
29
+ let fetchedUrl = '';
30
+ global.fetch = jest.fn(async (url: unknown) => {
31
+ fetchedUrl = String(url);
32
+ return {
33
+ ok: true,
34
+ status: 200,
35
+ json: async () => ({
36
+ data: [
37
+ {
38
+ lastTransactions: [
39
+ { originalTransactionId: REAL.originalTransactionId, signedTransactionInfo: REAL_JWS },
40
+ ],
41
+ },
42
+ ],
43
+ }),
44
+ } as unknown as Response;
45
+ }) as unknown as typeof fetch;
46
+
47
+ const client = AppStoreClient.fromSettings({
48
+ bundle_id: REAL.bundleId, // io.arcblock.ai.stro — must match the JWS or verify rejects
49
+ environment: 'sandbox',
50
+ issuer_id: 'iss_1',
51
+ key_id: 'KEY_ID_1',
52
+ private_key_pem: PKCS8_PEM,
53
+ });
54
+
55
+ const tx = await client.getSubscriptionStatus(REAL.originalTransactionId);
56
+
57
+ // Path B reached the sandbox API host (no itunes); Path A decoded the real txn.
58
+ expect(new URL(fetchedUrl).host).toBe('api.storekit-sandbox.apple.com');
59
+ expect(tx).not.toBeNull();
60
+ expect(tx!.transactionId).toBe(REAL.transactionId);
61
+ expect(tx!.bundleId).toBe('io.arcblock.ai.stro');
62
+ expect(tx!.productId).toBe(REAL.productId);
63
+ });
64
+
65
+ it('returns null when no transaction matches (404 path-through)', async () => {
66
+ global.fetch = jest.fn(
67
+ async () => ({ ok: false, status: 404, json: async () => ({}) }) as unknown as Response
68
+ ) as unknown as typeof fetch;
69
+ const client = AppStoreClient.fromSettings({
70
+ bundle_id: 'io.arcblock.ai.stro',
71
+ environment: 'sandbox',
72
+ issuer_id: 'iss_1',
73
+ key_id: 'KEY_ID_1',
74
+ private_key_pem: PKCS8_PEM,
75
+ });
76
+ expect(await client.getSubscriptionStatus('orig_unknown')).toBeNull();
77
+ });
78
+ });