payment-kit 1.29.6 → 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 (24) 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/tests/x509-probe.mjs +260 -0
  23. package/package.json +6 -9
  24. package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +0 -17
@@ -0,0 +1,219 @@
1
+ // Unit tests for the native Apple JWS verifier (Phase 1, Path A).
2
+ //
3
+ // Synthetic chains (make-chain.ts) exercise the algorithm with an injected test
4
+ // root; the committed REAL Apple sandbox JWS (real-sandbox.jws) proves the real
5
+ // OID + 3-cert chain end-to-end (design §11). No env flag needed — verifyAppleJws
6
+ // is pure (the flag only selects native vs legacy in signed-data-verifier).
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ import { buildJws, makeChain } from './fixtures/make-chain';
12
+ import { AppleJwsVerifyError, verifyAppleJws } from '../../../src/integrations/app-store/native-jws';
13
+ import type { JwsTransactionPayload } from '../../../src/integrations/app-store/native-jws';
14
+
15
+ const FIXED_SIGNED_DATE = 1_700_000_000_000;
16
+
17
+ /** A valid chain + the trustedRoots list that anchors it (the chain's own root). */
18
+ function freshChainCtx(opts: Parameters<typeof makeChain>[0] = {}) {
19
+ const chain = makeChain({ signedDate: FIXED_SIGNED_DATE, ...opts });
20
+ const trustedRoots = [Buffer.from(chain.x5c[2]!, 'base64')];
21
+ return { chain, trustedRoots };
22
+ }
23
+
24
+ async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
25
+ await expect(promise).rejects.toMatchObject({ code });
26
+ }
27
+
28
+ describe('verifyAppleJws — happy path', () => {
29
+ it('decodes a synthetic chain when its root is trusted; fields match the signed payload', async () => {
30
+ const { chain, trustedRoots } = freshChainCtx({
31
+ payload: { productId: 'sub_happy', expiresDate: 1_900_000_000_000, appAccountToken: 'uuid-happy' },
32
+ });
33
+ const decoded = await verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots });
34
+ expect(decoded.transactionId).toBe('txn_synth_1');
35
+ expect(decoded.productId).toBe('sub_happy');
36
+ expect(decoded.expiresDate).toBe(1_900_000_000_000);
37
+ expect(decoded.appAccountToken).toBe('uuid-happy');
38
+ expect(decoded.signedDate).toBe(FIXED_SIGNED_DATE);
39
+ });
40
+
41
+ it('verifies the REAL Apple sandbox JWS against the vendored Apple roots end-to-end', async () => {
42
+ const jws = readFileSync(path.join(__dirname, 'fixtures', 'real-sandbox.jws'), 'utf8').trim();
43
+ const meta = JSON.parse(readFileSync(path.join(__dirname, 'fixtures', 'real-sandbox.json'), 'utf8'));
44
+ // default trustedRoots = APPLE_ROOT_CERTS (the real chain anchors to Apple Root CA G3)
45
+ const decoded = await verifyAppleJws<JwsTransactionPayload>(jws);
46
+ expect(decoded.transactionId).toBe(meta.decoded_payload.transactionId);
47
+ expect(decoded.bundleId).toBe('io.arcblock.ai.stro');
48
+ expect(decoded.productId).toBe(meta.decoded_payload.productId);
49
+ expect(decoded.signedDate).toBe(meta.decoded_payload.signedDate);
50
+ });
51
+ });
52
+
53
+ describe('verifyAppleJws — bad input', () => {
54
+ it('rejects non-3-segment JWS', async () => {
55
+ await expectCode(verifyAppleJws('a.b'), 'FORMAT');
56
+ });
57
+
58
+ it('rejects x5c.length !== 3 with INVALID_CHAIN_LENGTH', async () => {
59
+ const { chain, trustedRoots } = freshChainCtx();
60
+ const { jws } = buildJws({ alg: 'ES256', x5c: chain.x5c.slice(0, 2) }, chain.payload, chain.keys.leafKeyPem);
61
+ await expectCode(verifyAppleJws(jws, { trustedRoots }), 'INVALID_CHAIN_LENGTH');
62
+ });
63
+
64
+ it('rejects a header that is not base64url JSON', async () => {
65
+ await expectCode(verifyAppleJws('!!!notjson.payload.sig'), 'HEADER');
66
+ });
67
+
68
+ it('rejects a payload that is not valid JSON (after a valid chain)', async () => {
69
+ const { chain, trustedRoots } = freshChainCtx();
70
+ const headerSeg = chain.jws.split('.')[0]!;
71
+ const badPayload = Buffer.from('not json at all').toString('base64url');
72
+ const jws = `${headerSeg}.${badPayload}.${chain.jws.split('.')[2]}`;
73
+ await expectCode(verifyAppleJws(jws, { trustedRoots }), 'PAYLOAD');
74
+ });
75
+ });
76
+
77
+ describe('verifyAppleJws — security', () => {
78
+ it('rejects a tampered payload (signature no longer matches)', async () => {
79
+ const { chain, trustedRoots } = freshChainCtx();
80
+ const [h, , s] = chain.jws.split('.');
81
+ const tampered = Buffer.from(JSON.stringify({ ...chain.payload, productId: 'EVIL' })).toString('base64url');
82
+ await expectCode(verifyAppleJws(`${h}.${tampered}.${s}`, { trustedRoots }), 'SIGNATURE');
83
+ });
84
+
85
+ it('rejects a tampered signature', async () => {
86
+ const { chain, trustedRoots } = freshChainCtx();
87
+ const [h, p] = chain.jws.split('.');
88
+ const sigBytes = Buffer.from(chain.jws.split('.')[2]!, 'base64url');
89
+ sigBytes[0] = sigBytes[0]! ^ 0xff;
90
+ await expectCode(verifyAppleJws(`${h}.${p}.${sigBytes.toString('base64url')}`, { trustedRoots }), 'SIGNATURE');
91
+ });
92
+
93
+ it('rejects alg: "none" and alg: "RS256" (alg whitelist)', async () => {
94
+ const none = freshChainCtx({ alg: 'none' });
95
+ await expectCode(verifyAppleJws(none.chain.jws, { trustedRoots: none.trustedRoots }), 'ALG');
96
+ const rs = freshChainCtx({ alg: 'RS256' });
97
+ await expectCode(verifyAppleJws(rs.chain.jws, { trustedRoots: rs.trustedRoots }), 'ALG');
98
+ });
99
+
100
+ it('rejects when the presented root is NOT a trusted anchor', async () => {
101
+ const { chain } = freshChainCtx();
102
+ // default APPLE_ROOT_CERTS does NOT contain the synthetic root
103
+ await expectCode(verifyAppleJws(chain.jws), 'ANCHOR');
104
+ });
105
+
106
+ it('rejects a cross-signed leaf (chain break) before signature', async () => {
107
+ const { chain, trustedRoots } = freshChainCtx();
108
+ const header = { alg: 'ES256', x5c: [chain.crossSignedLeaf, chain.x5c[1], chain.x5c[2]] };
109
+ const { jws } = buildJws(header, chain.payload, chain.keys.leafKeyPem);
110
+ await expectCode(verifyAppleJws(jws, { trustedRoots }), 'CHAIN');
111
+ });
112
+
113
+ it('rejects when the leaf is missing the Apple OID', async () => {
114
+ const { chain, trustedRoots } = freshChainCtx({ leafOid: false });
115
+ await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'OID');
116
+ });
117
+
118
+ it('rejects when the intermediate is missing the Apple OID', async () => {
119
+ const { chain, trustedRoots } = freshChainCtx({ intOid: false });
120
+ await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'OID');
121
+ });
122
+
123
+ it('does not pollute Object.prototype from a hostile __proto__ payload', async () => {
124
+ const { chain, trustedRoots } = freshChainCtx();
125
+ const headerSeg = chain.jws.split('.')[0]!;
126
+ // own __proto__ key via JSON.parse, then sign it so the chain is valid
127
+ const hostile = JSON.parse(
128
+ '{"__proto__":{"polluted":true},"transactionId":"t","productId":"p","signedDate":1700000000000}'
129
+ );
130
+ const { jws } = buildJws(
131
+ JSON.parse(Buffer.from(headerSeg, 'base64url').toString('utf8')),
132
+ hostile,
133
+ chain.keys.leafKeyPem
134
+ );
135
+ const decoded = await verifyAppleJws<Record<string, unknown>>(jws, { trustedRoots });
136
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
137
+ expect(Object.prototype.hasOwnProperty.call(decoded, '__proto__')).toBe(false);
138
+ });
139
+ });
140
+
141
+ describe('verifyAppleJws — validity & clock skew (±60s, all 3 certs)', () => {
142
+ // boundary: reject if validTo < effectiveDate − 60s; pass at exactly −60s.
143
+ it('passes at the expiry boundary (effectiveDate = validTo + 60s) and rejects 1ms past it', async () => {
144
+ const validTo = new Date(FIXED_SIGNED_DATE);
145
+ const ctxPass = freshChainCtx({ certValidTo: validTo, signedDate: FIXED_SIGNED_DATE + 60_000 });
146
+ await expect(verifyAppleJws(ctxPass.chain.jws, { trustedRoots: ctxPass.trustedRoots })).resolves.toBeDefined();
147
+ const ctxFail = freshChainCtx({ certValidTo: validTo, signedDate: FIXED_SIGNED_DATE + 60_000 + 1 });
148
+ await expectCode(verifyAppleJws(ctxFail.chain.jws, { trustedRoots: ctxFail.trustedRoots }), 'CERT_EXPIRED');
149
+ });
150
+
151
+ it('passes at the not-yet-valid boundary (effectiveDate = validFrom − 60s) and rejects 1ms before it', async () => {
152
+ const validFrom = new Date(FIXED_SIGNED_DATE);
153
+ const ctxPass = freshChainCtx({ certValidFrom: validFrom, signedDate: FIXED_SIGNED_DATE - 60_000 });
154
+ await expect(verifyAppleJws(ctxPass.chain.jws, { trustedRoots: ctxPass.trustedRoots })).resolves.toBeDefined();
155
+ const ctxFail = freshChainCtx({ certValidFrom: validFrom, signedDate: FIXED_SIGNED_DATE - 60_000 - 1 });
156
+ await expectCode(verifyAppleJws(ctxFail.chain.jws, { trustedRoots: ctxFail.trustedRoots }), 'CERT_NOT_YET_VALID');
157
+ });
158
+
159
+ it('rejects when ONLY the intermediate is expired (proves int is checked, not just leaf)', async () => {
160
+ const { chain, trustedRoots } = freshChainCtx({
161
+ certWindows: { intermediate: { to: new Date(FIXED_SIGNED_DATE - 3_600_000) } },
162
+ });
163
+ await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'CERT_EXPIRED');
164
+ });
165
+
166
+ it('rejects when ONLY the root is expired (proves root is checked)', async () => {
167
+ const { chain, trustedRoots } = freshChainCtx({
168
+ certWindows: { root: { to: new Date(FIXED_SIGNED_DATE - 3_600_000) } },
169
+ });
170
+ await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'CERT_EXPIRED');
171
+ });
172
+ });
173
+
174
+ describe('verifyAppleJws — data loss (concurrency / statelessness)', () => {
175
+ it('resolves all concurrent verifications correctly (no shared mutable state)', async () => {
176
+ const { chain, trustedRoots } = freshChainCtx();
177
+ const results = await Promise.all(
178
+ Array.from({ length: 12 }, () => verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots }))
179
+ );
180
+ for (const r of results) expect(r.transactionId).toBe('txn_synth_1');
181
+ });
182
+ });
183
+
184
+ describe('verifyAppleJws — data damage (type/encoding fidelity)', () => {
185
+ it('keeps numbers as numbers and round-trips unicode byte-faithfully', async () => {
186
+ const { chain, trustedRoots } = freshChainCtx({
187
+ payload: { productId: '产品_🎉', expiresDate: 1_888_888_888_888, appAccountToken: 'uuid-😀' },
188
+ });
189
+ const decoded = await verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots });
190
+ expect(decoded.productId).toBe('产品_🎉');
191
+ expect(decoded.appAccountToken).toBe('uuid-😀');
192
+ expect(typeof decoded.expiresDate).toBe('number');
193
+ expect(decoded.expiresDate).toBe(1_888_888_888_888);
194
+ });
195
+
196
+ it('leaves a missing expiresDate as undefined (never coerced to 0/null)', async () => {
197
+ const { chain, trustedRoots } = freshChainCtx(); // default payload has no expiresDate
198
+ const decoded = await verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots });
199
+ expect('expiresDate' in decoded).toBe(false);
200
+ expect(decoded.expiresDate).toBeUndefined();
201
+ });
202
+ });
203
+
204
+ describe('verifyAppleJws — data leak (errors name the failure, not secrets)', () => {
205
+ it('throws AppleJwsVerifyError with a code and never echoes the raw cert/JWS material', async () => {
206
+ const { chain } = freshChainCtx();
207
+ try {
208
+ await verifyAppleJws(chain.jws); // ANCHOR (synthetic root not trusted)
209
+ throw new Error('should have rejected');
210
+ } catch (err) {
211
+ expect(err).toBeInstanceOf(AppleJwsVerifyError);
212
+ const message = (err as Error).message;
213
+ expect((err as AppleJwsVerifyError).code).toBe('ANCHOR');
214
+ // the long base64 cert/jws material must not leak into the message
215
+ expect(message).not.toContain(chain.x5c[0]!.slice(0, 40));
216
+ expect(message).not.toContain(chain.jws.split('.')[2]!.slice(0, 20));
217
+ }
218
+ });
219
+ });
@@ -0,0 +1,161 @@
1
+ // Unit tests for native StoreKit 1 receipt verification (Phase 3, Path C).
2
+ // fetch is mocked — we never hit Apple. Asserts parity with the evicted
3
+ // node-apple-receipt-verify: hosts, body, sandbox fallback, output shape.
4
+
5
+ import { verifyAppleReceiptNative } from '../../../src/integrations/app-store/native-receipt';
6
+
7
+ type Call = { url: string; body: any };
8
+ let calls: Call[] = [];
9
+
10
+ /** Mock fetch with one response per call (for the production→sandbox retry). */
11
+ function mockFetchSequence(responses: Array<{ ok?: boolean; status: number; json: object }>): void {
12
+ let i = 0;
13
+ calls = [];
14
+ global.fetch = jest.fn(async (url: unknown, init?: unknown) => {
15
+ const reqInit = init as RequestInit;
16
+ calls.push({ url: String(url), body: JSON.parse(String(reqInit.body)) });
17
+ const r = responses[Math.min(i, responses.length - 1)]!;
18
+ i += 1;
19
+ return { ok: r.ok ?? true, status: 200, json: async () => r.json } as unknown as Response;
20
+ }) as unknown as typeof fetch;
21
+ }
22
+
23
+ const item = (over: Record<string, unknown> = {}) => ({
24
+ product_id: 'sub_06',
25
+ transaction_id: 'txn_1',
26
+ original_transaction_id: 'orig_1',
27
+ purchase_date_ms: '1700000000000',
28
+ expires_date_ms: '1800000000000',
29
+ web_order_line_item_id: 'wo_1',
30
+ ...over,
31
+ });
32
+
33
+ const appleOk = (items: object[], bundleId = 'com.example.app') => ({
34
+ status: 0,
35
+ receipt: { bundle_id: bundleId, in_app: items },
36
+ latest_receipt_info: items,
37
+ });
38
+
39
+ afterEach(() => jest.restoreAllMocks());
40
+
41
+ describe('verifyAppleReceiptNative — happy path', () => {
42
+ it('maps Apple latest_receipt_info to the normalized shape (snake→camel, ms→Number)', async () => {
43
+ mockFetchSequence([{ status: 200, json: appleOk([item()]) }]);
44
+ const result = await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
45
+ expect(result).toEqual([
46
+ {
47
+ productId: 'sub_06',
48
+ transactionId: 'txn_1',
49
+ originalTransactionId: 'orig_1',
50
+ purchaseDate: 1700000000000,
51
+ expirationDate: 1800000000000,
52
+ bundleId: 'com.example.app',
53
+ webOrderLineItemId: 'wo_1',
54
+ },
55
+ ]);
56
+ expect(typeof result[0]!.expirationDate).toBe('number');
57
+ });
58
+
59
+ it('falls back to receipt.in_app when latest_receipt_info is absent', async () => {
60
+ mockFetchSequence([
61
+ { status: 200, json: { status: 0, receipt: { bundle_id: 'com.example.app', in_app: [item()] } } },
62
+ ]);
63
+ const result = await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
64
+ expect(result[0]!.transactionId).toBe('txn_1');
65
+ });
66
+ });
67
+
68
+ describe('verifyAppleReceiptNative — bad input', () => {
69
+ it('returns [] when both latest_receipt_info and in_app are empty', async () => {
70
+ mockFetchSequence([{ status: 200, json: { status: 0, receipt: { bundle_id: 'x', in_app: [] } } }]);
71
+ expect(await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' })).toEqual([]);
72
+ });
73
+
74
+ it('handles a non-numeric expires_date_ms as undefined (no NaN leak)', async () => {
75
+ mockFetchSequence([{ status: 200, json: appleOk([item({ expires_date_ms: 'not-a-number' })]) }]);
76
+ const result = await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
77
+ expect(result[0]!.expirationDate).toBeUndefined();
78
+ });
79
+ });
80
+
81
+ describe('verifyAppleReceiptNative — security', () => {
82
+ it('POSTs password + exclude-old-transactions to the production Apple host only', async () => {
83
+ mockFetchSequence([{ status: 200, json: appleOk([item()]) }]);
84
+ await verifyAppleReceiptNative({ receipt: 'RECEIPT_B64', sharedSecret: 'sk_secret' });
85
+ expect(calls).toHaveLength(1);
86
+ expect(calls[0]!.url).toBe('https://buy.itunes.apple.com/verifyReceipt');
87
+ expect(calls[0]!.body).toEqual({
88
+ 'receipt-data': 'RECEIPT_B64',
89
+ password: 'sk_secret',
90
+ 'exclude-old-transactions': true,
91
+ });
92
+ });
93
+
94
+ it('only ever talks to the two Apple verifyReceipt hosts (no host injection)', async () => {
95
+ mockFetchSequence([
96
+ { status: 200, json: { status: 21007 } },
97
+ { status: 200, json: appleOk([item()]) },
98
+ ]);
99
+ await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
100
+ const hosts = calls.map((c) => new URL(c.url).host);
101
+ expect(hosts).toEqual(['buy.itunes.apple.com', 'sandbox.itunes.apple.com']);
102
+ });
103
+ });
104
+
105
+ describe('verifyAppleReceiptNative — data loss (sandbox fallback + concurrency)', () => {
106
+ it.each([21007, 21002])('retries sandbox on status %i and returns the sandbox result', async (prodStatus) => {
107
+ mockFetchSequence([
108
+ { status: 200, json: { status: prodStatus } },
109
+ { status: 200, json: appleOk([item({ transaction_id: 'sandbox_txn' })]) },
110
+ ]);
111
+ const result = await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
112
+ expect(calls).toHaveLength(2);
113
+ expect(calls[1]!.url).toBe('https://sandbox.itunes.apple.com/verifyReceipt');
114
+ expect(result[0]!.transactionId).toBe('sandbox_txn');
115
+ });
116
+
117
+ it('two concurrent calls with different secrets each POST their OWN secret (no shared state)', async () => {
118
+ const seen: string[] = [];
119
+ global.fetch = jest.fn(async (_url: unknown, init?: unknown) => {
120
+ const body = JSON.parse(String((init as RequestInit).body));
121
+ seen.push(body.password);
122
+ return { ok: true, status: 200, json: async () => appleOk([item()]) } as unknown as Response;
123
+ }) as unknown as typeof fetch;
124
+ await Promise.all([
125
+ verifyAppleReceiptNative({ receipt: 'r1', sharedSecret: 'secret_A' }),
126
+ verifyAppleReceiptNative({ receipt: 'r2', sharedSecret: 'secret_B' }),
127
+ ]);
128
+ expect(seen.sort()).toEqual(['secret_A', 'secret_B']);
129
+ });
130
+ });
131
+
132
+ describe('verifyAppleReceiptNative — data damage', () => {
133
+ it('returns all multi-item entries (client.ts picks the latest-expiry one)', async () => {
134
+ mockFetchSequence([
135
+ {
136
+ status: 200,
137
+ json: appleOk([
138
+ item({ transaction_id: 'old', expires_date_ms: '1500000000000' }),
139
+ item({ transaction_id: 'new', expires_date_ms: '1800000000000' }),
140
+ ]),
141
+ },
142
+ ]);
143
+ const result = await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
144
+ expect(result.map((r) => r.transactionId)).toEqual(['old', 'new']);
145
+ expect(result.every((r) => typeof r.expirationDate === 'number')).toBe(true);
146
+ });
147
+ });
148
+
149
+ describe('verifyAppleReceiptNative — data leak', () => {
150
+ it('throws on a non-retryable non-zero status with the code but NOT the shared secret', async () => {
151
+ mockFetchSequence([{ status: 200, json: { status: 21004 } }]);
152
+ let caught: Error | undefined;
153
+ try {
154
+ await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'super_secret_value' });
155
+ } catch (e) {
156
+ caught = e as Error;
157
+ }
158
+ expect(caught?.message).toMatch(/21004/);
159
+ expect(caught?.message).not.toContain('super_secret_value');
160
+ });
161
+ });
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.29.6
17
+ version: 1.29.7
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -0,0 +1,260 @@
1
+ // X509 / crypto probe — runs inside REAL workerd (via miniflare) with the EXACT
2
+ // production CF config (compatibility_date 2024-12-01 + nodejs_compat).
3
+ //
4
+ // Settles the Apple-IAP native-verify plan (intent/apple-iap-native-verify-plan/)
5
+ // AND is the committed regression gate proving the §5 algorithm holds under
6
+ // workerd. Rounds:
7
+ // Round 1 (baseline): X509Certificate construct/publicKey/verify, node ES256
8
+ // (DER sig), subtle ECDSA P-256, subtle importKey spki.
9
+ // Round 2 (§4 prep): the verify-path specifics NOT covered by round 1 —
10
+ // P1: leafCert.publicKey.export({type:'spki',format:'der'}) in workerd
11
+ // P2: verify a REAL P1363 (raw r||s) ES256 sig — (a) via crypto.subtle,
12
+ // (b) via createVerify dsaEncoding:'ieee-p1363'
13
+ // P3: read an Apple custom extension OID (1.2.840.113635.*) WITHOUT an ASN.1
14
+ // lib — DER-encode the OID and indexOf() it in cert.raw. (Node's
15
+ // X509Certificate has NO generic extension-by-OID accessor.)
16
+ // chain_round (0.2): a synthetic 3-cert chain (root → CA int w/ Apple OID →
17
+ // leaf w/ Apple OID) — proves §5.4 walk (intermediate.verify(root),
18
+ // leaf.verify(int)) + §5.5 OID presence on BOTH certs under workerd,
19
+ // plus a cross-signed leaf that MUST fail leaf.verify(int).
20
+ // Mirrors api/tests/integrations/app-store/fixtures/make-chain.ts —
21
+ // keep the two in sync.
22
+ // pkcs8_sign_round (0.5): importKey('pkcs8') for SIGN + round-trip verify —
23
+ // de-risks the App Store Server API JWT bearer (Phase 2) BEFORE it is
24
+ // built. If workerd rejects pkcs8-for-sign, Phase 2 takes Approach J.
25
+ //
26
+ // Vectors are generated at runtime (openssl + node:crypto) so cert validity is
27
+ // always current and the P1363 sig is real.
28
+ //
29
+ // Run: node cloudflare/tests/x509-probe.mjs (cwd = blocklets/core)
30
+ import { Miniflare } from 'miniflare';
31
+ import { execSync, execFileSync } from 'node:child_process';
32
+ import { generateKeyPairSync, sign as nodeSign, X509Certificate } from 'node:crypto';
33
+ import fs from 'node:fs';
34
+ import os from 'node:os';
35
+ import path from 'node:path';
36
+
37
+ const LEAF_OID = '1.2.840.113635.100.6.11.1'; // Apple: present on the leaf
38
+ const INT_OID = '1.2.840.113635.100.6.2.1'; // Apple: present on the intermediate (ABSENT from our leaf — negative control)
39
+ const SIGNING_INPUT = 'header.payload';
40
+
41
+ // DER-encode an OID's *content* octets (no 0x06 tag / length) — enough to scan
42
+ // for inside a cert's DER with indexOf (the content is unique enough).
43
+ function oidContentHex(oid) {
44
+ const parts = oid.split('.').map(Number);
45
+ const bytes = [40 * parts[0] + parts[1]];
46
+ for (let i = 2; i < parts.length; i++) {
47
+ let v = parts[i];
48
+ const stack = [v & 0x7f];
49
+ v = Math.floor(v / 128);
50
+ while (v > 0) {
51
+ stack.unshift((v & 0x7f) | 0x80);
52
+ v = Math.floor(v / 128);
53
+ }
54
+ bytes.push(...stack);
55
+ }
56
+ return Buffer.from(bytes).toString('hex');
57
+ }
58
+
59
+ // ---- generate vectors (node side) ----
60
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'x509-probe-'));
61
+ const keyPath = path.join(dir, 'leaf.key');
62
+ const certPem = path.join(dir, 'leaf.pem');
63
+ const certDer = path.join(dir, 'leaf.der');
64
+ execSync(`openssl ecparam -name prime256v1 -genkey -noout -out ${keyPath}`, { stdio: 'ignore' });
65
+ // self-signed leaf carrying the Apple LEAF extension OID (value = ASN.1 NULL)
66
+ execSync(
67
+ `openssl req -new -x509 -key ${keyPath} -out ${certPem} -days 3650 -subj "/CN=apple-leaf-probe" -addext "${LEAF_OID}=DER:0500"`,
68
+ { stdio: 'ignore' }
69
+ );
70
+ execSync(`openssl x509 -in ${certPem} -outform DER -out ${certDer}`, { stdio: 'ignore' });
71
+
72
+ const certDerB64 = fs.readFileSync(certDer).toString('base64');
73
+ const keyPem = fs.readFileSync(keyPath, 'utf8');
74
+ // REAL P1363 ES256 signature (raw 64-byte r||s — the JWS form), NOT DER.
75
+ const p1363SigB64 = nodeSign('sha256', Buffer.from(SIGNING_INPUT), { key: keyPem, dsaEncoding: 'ieee-p1363' }).toString(
76
+ 'base64'
77
+ );
78
+
79
+ // ---- chain_round vectors (0.2): synthetic root → CA int (Apple OID) → leaf (Apple OID) ----
80
+ // Mirrors fixtures/make-chain.ts. The cross-signed leaf shares the int's subject
81
+ // DN but is signed by a DIFFERENT key, so leaf.verify(int.publicKey) MUST fail.
82
+ function buildChain() {
83
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'x509-chain-'));
84
+ const f = (n) => path.join(d, n);
85
+ const run = (args) => execFileSync('openssl', args, { cwd: d, stdio: ['ignore', 'pipe', 'pipe'] });
86
+ const key = (n) => run(['ecparam', '-name', 'prime256v1', '-genkey', '-noout', '-out', f(n)]);
87
+ const SUBJ_INT = '/CN=Probe Apple WWDR Intermediate';
88
+ try {
89
+ key('root.key');
90
+ run(['req', '-new', '-x509', '-key', f('root.key'), '-out', f('root.pem'), '-days', '3650',
91
+ '-subj', '/CN=Probe Apple Root CA', '-addext', 'basicConstraints=critical,CA:TRUE']);
92
+ key('int.key');
93
+ run(['req', '-new', '-key', f('int.key'), '-out', f('int.csr'), '-subj', SUBJ_INT]);
94
+ fs.writeFileSync(f('int.ext'), `[v3]\nbasicConstraints=critical,CA:TRUE\n${INT_OID}=DER:0500`);
95
+ run(['x509', '-req', '-in', f('int.csr'), '-CA', f('root.pem'), '-CAkey', f('root.key'), '-CAcreateserial',
96
+ '-out', f('int.pem'), '-days', '3650', '-extfile', f('int.ext'), '-extensions', 'v3']);
97
+ key('leaf.key');
98
+ run(['req', '-new', '-key', f('leaf.key'), '-out', f('leaf.csr'), '-subj', '/CN=Probe Apple Leaf']);
99
+ fs.writeFileSync(f('leaf.ext'), `[v3]\nbasicConstraints=critical,CA:FALSE\n${LEAF_OID}=DER:0500`);
100
+ run(['x509', '-req', '-in', f('leaf.csr'), '-CA', f('int.pem'), '-CAkey', f('int.key'), '-CAcreateserial',
101
+ '-out', f('leaf.pem'), '-days', '3650', '-extfile', f('leaf.ext'), '-extensions', 'v3']);
102
+ // wrong intermediate: same subject DN, different key
103
+ key('wrongint.key');
104
+ run(['req', '-new', '-x509', '-key', f('wrongint.key'), '-out', f('wrongint.pem'), '-days', '3650',
105
+ '-subj', SUBJ_INT, '-addext', 'basicConstraints=critical,CA:TRUE']);
106
+ key('crossleaf.key');
107
+ run(['req', '-new', '-key', f('crossleaf.key'), '-out', f('crossleaf.csr'), '-subj', '/CN=Probe Apple Leaf']);
108
+ run(['x509', '-req', '-in', f('crossleaf.csr'), '-CA', f('wrongint.pem'), '-CAkey', f('wrongint.key'),
109
+ '-CAcreateserial', '-out', f('crossleaf.pem'), '-days', '3650', '-extfile', f('leaf.ext'), '-extensions', 'v3']);
110
+ const der = (n) => Buffer.from(new X509Certificate(fs.readFileSync(f(n))).raw).toString('base64');
111
+ return { leaf: der('leaf.pem'), int: der('int.pem'), root: der('root.pem'), crossLeaf: der('crossleaf.pem') };
112
+ } finally {
113
+ fs.rmSync(d, { recursive: true, force: true });
114
+ }
115
+ }
116
+ const chain = buildChain();
117
+
118
+ // ---- pkcs8_sign_round vectors (0.5): EC P-256 pkcs8 (DER) + its spki, for SIGN ----
119
+ const { privateKey: ecPriv, publicKey: ecPub } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
120
+ const pkcs8B64 = ecPriv.export({ type: 'pkcs8', format: 'der' }).toString('base64');
121
+ const spkiSignB64 = ecPub.export({ type: 'spki', format: 'der' }).toString('base64');
122
+ const badPkcs8B64 = Buffer.from('not-a-real-pkcs8-key').toString('base64');
123
+
124
+ const script = `
125
+ import { X509Certificate, createVerify } from 'node:crypto';
126
+ import { Buffer } from 'node:buffer';
127
+
128
+ const CERT_DER_B64 = ${JSON.stringify(certDerB64)};
129
+ const P1363_SIG_B64 = ${JSON.stringify(p1363SigB64)};
130
+ const LEAF_OID_HEX = ${JSON.stringify(oidContentHex(LEAF_OID))};
131
+ const INT_OID_HEX = ${JSON.stringify(oidContentHex(INT_OID))};
132
+ const MSG = ${JSON.stringify(SIGNING_INPUT)};
133
+
134
+ // chain_round (0.2)
135
+ const CHAIN = ${JSON.stringify(chain)};
136
+ // pkcs8_sign_round (0.5)
137
+ const PKCS8_B64 = ${JSON.stringify(pkcs8B64)};
138
+ const SPKI_SIGN_B64 = ${JSON.stringify(spkiSignB64)};
139
+ const BAD_PKCS8_B64 = ${JSON.stringify(badPkcs8B64)};
140
+
141
+ function ok(v){ return { ok: true, ...v }; }
142
+ function err(e){ return { ok: false, error: String(e && e.message || e) }; }
143
+ function hex(buf){ return Buffer.from(buf).toString('hex'); }
144
+
145
+ export default {
146
+ async fetch() {
147
+ const out = {};
148
+ const cert = new X509Certificate(Buffer.from(CERT_DER_B64, 'base64'));
149
+
150
+ // P1 — export leaf public key as SPKI DER (needed to feed crypto.subtle)
151
+ let spkiDer = null;
152
+ try {
153
+ spkiDer = cert.publicKey.export({ type: 'spki', format: 'der' });
154
+ out.P1_export_spki_der = ok({ bytes: spkiDer.byteLength ?? spkiDer.length });
155
+ } catch (e) { out.P1_export_spki_der = err(e); }
156
+
157
+ // P2a — verify REAL P1363 ES256 sig via crypto.subtle (preferred)
158
+ try {
159
+ if (!spkiDer) throw new Error('no spki from P1');
160
+ const key = await crypto.subtle.importKey(
161
+ 'spki', spkiDer, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']
162
+ );
163
+ const valid = await crypto.subtle.verify(
164
+ { name: 'ECDSA', hash: 'SHA-256' }, key,
165
+ Buffer.from(P1363_SIG_B64, 'base64'), new TextEncoder().encode(MSG)
166
+ );
167
+ out.P2a_subtle_p1363_verify = ok({ valid });
168
+ } catch (e) { out.P2a_subtle_p1363_verify = err(e); }
169
+
170
+ // P2b — verify REAL P1363 sig via node createVerify dsaEncoding:'ieee-p1363' (fallback)
171
+ try {
172
+ const v = createVerify('SHA256');
173
+ v.update(MSG); v.end();
174
+ const valid = v.verify({ key: cert.publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(P1363_SIG_B64, 'base64'));
175
+ out.P2b_node_p1363_verify = ok({ valid });
176
+ } catch (e) { out.P2b_node_p1363_verify = err(e); }
177
+
178
+ // P3 — Apple extension-OID presence via indexOf in cert.raw (no ASN.1 lib)
179
+ try {
180
+ const rawHex = hex(cert.raw);
181
+ const leafPresent = rawHex.includes(LEAF_OID_HEX); // expect TRUE (we added it)
182
+ const intPresent = rawHex.includes(INT_OID_HEX); // expect FALSE (negative control)
183
+ // also confirm Node has NO generic extension accessor (documents the gap)
184
+ const hasGenericExtAccessor =
185
+ typeof cert.getExtension === 'function' || typeof cert.extensions !== 'undefined';
186
+ out.P3_oid_scan = ok({
187
+ rawIsBuffer: cert.raw instanceof Uint8Array,
188
+ leafOidPresent: leafPresent,
189
+ intOidPresent: intPresent,
190
+ hasGenericExtAccessor,
191
+ });
192
+ } catch (e) { out.P3_oid_scan = err(e); }
193
+
194
+ // chain_round (0.2) — full §5.4 walk + §5.5 OID presence on a 3-cert chain
195
+ try {
196
+ const leaf = new X509Certificate(Buffer.from(CHAIN.leaf, 'base64'));
197
+ const intermediate = new X509Certificate(Buffer.from(CHAIN.int, 'base64'));
198
+ const root = new X509Certificate(Buffer.from(CHAIN.root, 'base64'));
199
+ const crossLeaf = new X509Certificate(Buffer.from(CHAIN.crossLeaf, 'base64'));
200
+ out.chain_round = ok({
201
+ intVerifiesRoot: intermediate.verify(root.publicKey),
202
+ leafVerifiesInt: leaf.verify(intermediate.publicKey),
203
+ leafOidPresent: hex(leaf.raw).includes(LEAF_OID_HEX),
204
+ intOidPresent: hex(intermediate.raw).includes(INT_OID_HEX),
205
+ intIsCa: intermediate.ca === true,
206
+ crossSignedLeafVerifies: crossLeaf.verify(intermediate.publicKey), // expect FALSE
207
+ });
208
+ } catch (e) { out.chain_round = err(e); }
209
+
210
+ // pkcs8_sign_round (0.5) — importKey('pkcs8') for SIGN + round-trip verify
211
+ try {
212
+ const signKey = await crypto.subtle.importKey(
213
+ 'pkcs8', Buffer.from(PKCS8_B64, 'base64'), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']
214
+ );
215
+ const sig = await crypto.subtle.sign(
216
+ { name: 'ECDSA', hash: 'SHA-256' }, signKey, new TextEncoder().encode(MSG)
217
+ );
218
+ const verifyKey = await crypto.subtle.importKey(
219
+ 'spki', Buffer.from(SPKI_SIGN_B64, 'base64'), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']
220
+ );
221
+ const roundTripVerifyOk = await crypto.subtle.verify(
222
+ { name: 'ECDSA', hash: 'SHA-256' }, verifyKey, sig, new TextEncoder().encode(MSG)
223
+ );
224
+ // negative control: a malformed pkcs8 must surface as importPkcs8Ok:false, not a silent unsigned key
225
+ let importBadOk = true;
226
+ try {
227
+ await crypto.subtle.importKey(
228
+ 'pkcs8', Buffer.from(BAD_PKCS8_B64, 'base64'), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']
229
+ );
230
+ } catch { importBadOk = false; }
231
+ out.pkcs8_sign_round = ok({
232
+ importPkcs8Ok: true,
233
+ signOk: sig.byteLength > 0,
234
+ roundTripVerifyOk,
235
+ malformedPkcs8Rejected: importBadOk === false,
236
+ });
237
+ } catch (e) { out.pkcs8_sign_round = err(e); }
238
+
239
+ return new Response(JSON.stringify(out, null, 2), { headers: { 'content-type': 'application/json' } });
240
+ }
241
+ };
242
+ `;
243
+
244
+ const mf = new Miniflare({
245
+ modules: true,
246
+ script,
247
+ compatibilityDate: '2024-12-01',
248
+ compatibilityFlags: ['nodejs_compat'],
249
+ });
250
+
251
+ try {
252
+ const res = await mf.dispatchFetch('http://localhost/');
253
+ console.log(await res.text());
254
+ } catch (e) {
255
+ console.error('PROBE FAILED TO RUN:', e);
256
+ process.exitCode = 1;
257
+ } finally {
258
+ await mf.dispose();
259
+ fs.rmSync(dir, { recursive: true, force: true });
260
+ }