payment-kit 1.29.6 → 1.29.8
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.
- package/api/src/host-glue.ts +79 -0
- package/api/src/integrations/app-store/client.ts +7 -13
- package/api/src/integrations/app-store/native-api.ts +102 -0
- package/api/src/integrations/app-store/native-asn1.ts +49 -0
- package/api/src/integrations/app-store/native-jws.ts +222 -0
- package/api/src/integrations/app-store/native-receipt.ts +105 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +40 -79
- package/api/src/libs/env.ts +64 -22
- package/api/src/routes/hono/credit-grants.ts +1 -1
- package/api/tests/integrations/app-store/client.spec.ts +83 -57
- package/api/tests/integrations/app-store/fixtures/README.md +54 -0
- package/api/tests/integrations/app-store/fixtures/keys/int.key.pem +5 -0
- package/api/tests/integrations/app-store/fixtures/keys/leaf.key.pem +5 -0
- package/api/tests/integrations/app-store/fixtures/keys/root.key.pem +5 -0
- package/api/tests/integrations/app-store/fixtures/keys/wrongint.key.pem +5 -0
- package/api/tests/integrations/app-store/fixtures/make-chain.spec.ts +152 -0
- package/api/tests/integrations/app-store/fixtures/make-chain.ts +326 -0
- package/api/tests/integrations/app-store/fixtures/real-sandbox.json +43 -0
- package/api/tests/integrations/app-store/fixtures/real-sandbox.jws +1 -0
- package/api/tests/integrations/app-store/native-api.spec.ts +172 -0
- package/api/tests/integrations/app-store/native-integration.spec.ts +78 -0
- package/api/tests/integrations/app-store/native-jws.spec.ts +219 -0
- package/api/tests/integrations/app-store/native-receipt.spec.ts +161 -0
- package/api/tests/libs/notification-guard.spec.ts +25 -8
- package/blocklet.yml +1 -1
- package/cloudflare/cf-adapter.ts +11 -46
- package/cloudflare/tests/cf-adapter.spec.ts +2 -1
- package/cloudflare/tests/x509-probe.mjs +260 -0
- package/package.json +7 -10
- package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +0 -17
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|