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
@@ -1,49 +1,30 @@
1
- // Real signature verification for App Store JWS payloads.
1
+ // Signature verification for App Store JWS payloads — native (node:crypto +
2
+ // crypto.subtle), no external dependency. The Apple JWS chain/signature logic
3
+ // lives in native-jws.ts (verifyAppleJws); the App Store Server API client lives
4
+ // in native-api.ts (nativeGetAllSubscriptionStatuses). The legacy
5
+ // `@apple/app-store-server-library` path was removed once native became the only
6
+ // path (design §10 Phase 3) — that evicts jsrsasign + the whole apple cluster
7
+ // from both payment-core bundles.
2
8
  //
3
- // Wraps Apple's official `@apple/app-store-server-library` SignedDataVerifier:
4
- // 1. Loads bundled Apple Root CAs (vendored as base64 constants in
5
- // apple-root-certs.ts so tsc compiles to dist without copying assets)
6
- // 2. Caches a per-(bundleId, environment) verifier instance — Apple SDK keeps
7
- // a public-key cache inside each verifier, so reusing is much faster than
8
- // constructing per-call
9
- // 3. Provides verifyTransaction / verifyNotification that throw on bad signatures
10
- //
11
- // Env var `APP_STORE_SKIP_SIGNATURE_VERIFY=true` bypasses the SDK entirely
12
- // (decode-only). Use for unit tests and sandbox debugging — never in production.
13
-
14
- // NOTE: @apple/app-store-server-library has top-level side-effects (jsrsasign
15
- // initializes RNG in global scope, see https://github.com/apple/app-store-server-library-node).
16
- // Cloudflare Workers forbid I/O / random / setTimeout in global scope, so we
17
- // **lazy-load** the SDK inside the first call to getVerifier. This keeps the
18
- // worker startup clean and lets non-Apple endpoints (Google Play, entitlements,
19
- // etc.) mount even when Apple SDK is present in the deps.
20
- import type {
21
- AppStoreServerAPIClient,
22
- JWSTransactionDecodedPayload,
23
- ResponseBodyV2DecodedPayload,
24
- SignedDataVerifier,
25
- StatusResponse,
26
- } from '@apple/app-store-server-library';
9
+ // Env var `APP_STORE_SKIP_SIGNATURE_VERIFY=true` bypasses verification
10
+ // (decode-only) for unit tests / sandbox debugging, fail-closed in production.
27
11
 
28
12
  import logger from '../../libs/logger';
29
13
  import { appStoreSkipSignatureVerify, isProduction } from '../../libs/env';
30
- import { APPLE_ROOT_CERTS } from './apple-root-certs';
31
-
32
- const verifierCache = new Map<string, SignedDataVerifier>();
14
+ import { JwsNotificationPayload, JwsTransactionPayload, verifyAppleJws } from './native-jws';
15
+ import { nativeGetAllSubscriptionStatuses } from './native-api';
33
16
 
34
- async function getVerifier(bundleId: string, environment: 'production' | 'sandbox'): Promise<SignedDataVerifier> {
35
- const key = `${bundleId}:${environment}`;
36
- const cached = verifierCache.get(key);
37
- if (cached) return cached;
38
- // Dynamic import — keeps the SDK out of the worker bundle's global scope.
39
- const mod = await import('@apple/app-store-server-library');
40
- const env = environment === 'production' ? mod.Environment.PRODUCTION : mod.Environment.SANDBOX;
41
- // enableOnlineChecks=false — turning this on does OCSP revocation lookups on
42
- // every verify, which adds latency + network dependency. Apple's published
43
- // recommendation is to leave it off unless you specifically need it.
44
- const verifier = new mod.SignedDataVerifier(APPLE_ROOT_CERTS, false, env, bundleId);
45
- verifierCache.set(key, verifier);
46
- return verifier;
17
+ /**
18
+ * Minimal local shape of Apple's App Store Server API StatusResponse — only the
19
+ * fields `AppStoreClient.getSubscriptionStatus` reads (client.ts:337-339).
20
+ */
21
+ export interface StatusResponse {
22
+ data?: Array<{
23
+ lastTransactions?: Array<{
24
+ originalTransactionId?: string;
25
+ signedTransactionInfo?: string;
26
+ }>;
27
+ }>;
47
28
  }
48
29
 
49
30
  export function isSignatureVerificationSkipped(): boolean {
@@ -64,28 +45,29 @@ export function isSignatureVerificationSkipped(): boolean {
64
45
 
65
46
  export async function verifySignedTransaction(
66
47
  signedTransaction: string,
67
- bundleId: string,
68
- environment: 'production' | 'sandbox'
69
- ): Promise<JWSTransactionDecodedPayload> {
48
+ _bundleId: string,
49
+ _environment: 'production' | 'sandbox'
50
+ ): Promise<JwsTransactionPayload> {
70
51
  if (isSignatureVerificationSkipped()) {
71
52
  logger.warn('app_store: signature verification skipped via APP_STORE_SKIP_SIGNATURE_VERIFY');
72
- return decodeUnsafe<JWSTransactionDecodedPayload>(signedTransaction);
53
+ return decodeUnsafe<JwsTransactionPayload>(signedTransaction);
73
54
  }
74
- const verifier = await getVerifier(bundleId, environment);
75
- return verifier.verifyAndDecodeTransaction(signedTransaction);
55
+ // Stateless native verify — anchors to vendored Apple roots, validates against
56
+ // the payload's signedDate. bundleId/environment stay in the signature for
57
+ // caller-layer enforcement (client.ts) + API parity.
58
+ return verifyAppleJws<JwsTransactionPayload>(signedTransaction);
76
59
  }
77
60
 
78
61
  export async function verifySignedNotification(
79
62
  signedPayload: string,
80
- bundleId: string,
81
- environment: 'production' | 'sandbox'
82
- ): Promise<ResponseBodyV2DecodedPayload> {
63
+ _bundleId: string,
64
+ _environment: 'production' | 'sandbox'
65
+ ): Promise<JwsNotificationPayload> {
83
66
  if (isSignatureVerificationSkipped()) {
84
67
  logger.warn('app_store: signature verification skipped via APP_STORE_SKIP_SIGNATURE_VERIFY');
85
- return decodeUnsafe<ResponseBodyV2DecodedPayload>(signedPayload);
68
+ return decodeUnsafe<JwsNotificationPayload>(signedPayload);
86
69
  }
87
- const verifier = await getVerifier(bundleId, environment);
88
- return verifier.verifyAndDecodeNotification(signedPayload);
70
+ return verifyAppleJws<JwsNotificationPayload>(signedPayload);
89
71
  }
90
72
 
91
73
  /** Decode-only fallback for when signature verification is intentionally bypassed. */
@@ -101,13 +83,6 @@ function decodeUnsafe<T>(jws: string): T {
101
83
  }
102
84
  }
103
85
 
104
- // Exposed for tests — reset module-level caches between tests.
105
- // eslint-disable-next-line @typescript-eslint/naming-convention
106
- export function __resetVerifierCachesForTests(): void {
107
- verifierCache.clear();
108
- apiClientCache.clear();
109
- }
110
-
111
86
  // ============================================================================
112
87
  // App Store Server API client — for pulling fresh state (subscription status,
113
88
  // transaction history) when our local cache might be stale (e.g. at renewal,
@@ -123,29 +98,15 @@ export type AppStoreApiCredentials = {
123
98
  environment: 'production' | 'sandbox';
124
99
  };
125
100
 
126
- const apiClientCache = new Map<string, AppStoreServerAPIClient>();
127
- async function getApiClient(creds: AppStoreApiCredentials): Promise<AppStoreServerAPIClient> {
128
- const key = `${creds.bundleId}:${creds.environment}:${creds.keyId}`;
129
- const cached = apiClientCache.get(key);
130
- if (cached) return cached;
131
- // Lazy import — same reason as getVerifier (global-scope I/O ban in CF Workers).
132
- const mod = await import('@apple/app-store-server-library');
133
- const env = creds.environment === 'production' ? mod.Environment.PRODUCTION : mod.Environment.SANDBOX;
134
- const client = new mod.AppStoreServerAPIClient(creds.privateKeyPem, creds.keyId, creds.issuerId, creds.bundleId, env);
135
- apiClientCache.set(key, client);
136
- return client;
137
- }
138
-
139
101
  /**
140
- * Fetch all subscription statuses for a given originalTransactionId.
141
- * Returns Apple's StatusResponse which contains `signedTransactionInfo` /
142
- * `signedRenewalInfo` JWS strings verify those with the SignedDataVerifier
143
- * if you want decoded payloads.
102
+ * Fetch all subscription statuses for a given originalTransactionId via the
103
+ * native App Store Server API client (ES256 JWT bearer + fetch). Returns Apple's
104
+ * StatusResponse, whose `signedTransactionInfo` JWS strings are verified by the
105
+ * caller (client.ts) with verifyAppleJws.
144
106
  */
145
107
  export async function getAllSubscriptionStatuses(
146
108
  originalTransactionId: string,
147
109
  creds: AppStoreApiCredentials
148
110
  ): Promise<StatusResponse> {
149
- const client = await getApiClient(creds);
150
- return client.getAllSubscriptionStatuses(originalTransactionId);
111
+ return nativeGetAllSubscriptionStatuses(originalTransactionId, creds);
151
112
  }
@@ -8,18 +8,35 @@ process.env.APP_STORE_SKIP_SIGNATURE_VERIFY = 'true';
8
8
 
9
9
  import { AppStoreClient, AppStoreSettings } from '../../../src/integrations/app-store/client';
10
10
 
11
- const mockConfigApple = jest.fn();
12
- const mockValidateAppleReceipt = jest.fn();
13
- jest.mock('node-apple-receipt-verify', () => ({
14
- config: (...args: any[]) => mockConfigApple(...args),
15
- validate: (...args: any[]) => mockValidateAppleReceipt(...args),
16
- }));
17
-
18
- beforeEach(() => {
19
- mockConfigApple.mockReset();
20
- mockValidateAppleReceipt.mockReset();
11
+ // Path C (verifyLegacyReceipt) is now native fetch — mock the verifyReceipt HTTP
12
+ // call instead of the evicted node-apple-receipt-verify package.
13
+ type LegacyCall = { url: string; body: any };
14
+ let legacyCalls: LegacyCall[] = [];
15
+
16
+ /** Mock Apple's verifyReceipt with one JSON response per call (production→sandbox). */
17
+ function mockVerifyReceipt(responses: Array<{ status: number; latest_receipt_info?: any[]; receipt?: any }>): void {
18
+ let i = 0;
19
+ legacyCalls = [];
20
+ global.fetch = jest.fn(async (url: unknown, init?: unknown) => {
21
+ legacyCalls.push({ url: String(url), body: JSON.parse(String((init as RequestInit).body)) });
22
+ const r = responses[Math.min(i, responses.length - 1)]!;
23
+ i += 1;
24
+ return { ok: true, status: 200, json: async () => r } as unknown as Response;
25
+ }) as unknown as typeof fetch;
26
+ }
27
+
28
+ /** Apple verifyReceipt success body with snake_case item(s). */
29
+ const legacyItem = (over: Record<string, unknown> = {}) => ({
30
+ product_id: 'sub_06',
31
+ transaction_id: 'txn_1',
32
+ original_transaction_id: 'orig_1',
33
+ purchase_date_ms: '1700000000000',
34
+ expires_date_ms: '1800000000000',
35
+ ...over,
21
36
  });
22
37
 
38
+ afterEach(() => jest.restoreAllMocks());
39
+
23
40
  const baseSettings: AppStoreSettings = {
24
41
  bundle_id: 'com.example.app',
25
42
  environment: 'sandbox',
@@ -232,38 +249,20 @@ describe('AppStoreClient.verifyLegacyReceipt', () => {
232
249
  await expect(c.verifyLegacyReceipt('base64-receipt')).rejects.toThrow(/shared_secret is required/);
233
250
  });
234
251
 
235
- it('configures node-apple-receipt-verify with provided secret + both envs', async () => {
236
- mockValidateAppleReceipt.mockResolvedValue([
237
- {
238
- bundleId: 'com.example.app',
239
- productId: 'sub_06',
240
- transactionId: 'txn_1',
241
- originalTransactionId: 'orig_1',
242
- purchaseDate: 1700000000000,
243
- expirationDate: 1800000000000,
244
- },
245
- ]);
252
+ it('POSTs receipt-data + secret + exclude-old-transactions to the production Apple host', async () => {
253
+ mockVerifyReceipt([{ status: 0, receipt: { bundle_id: 'com.example.app' }, latest_receipt_info: [legacyItem()] }]);
246
254
  const c = AppStoreClient.fromSettings(withSecret);
247
255
  await c.verifyLegacyReceipt('base64-receipt');
248
- expect(mockConfigApple).toHaveBeenCalledWith(
249
- expect.objectContaining({
250
- secret: 'sk_test_secret',
251
- environment: ['production', 'sandbox'],
252
- })
253
- );
256
+ expect(legacyCalls[0]!.url).toBe('https://buy.itunes.apple.com/verifyReceipt');
257
+ expect(legacyCalls[0]!.body).toEqual({
258
+ 'receipt-data': 'base64-receipt',
259
+ password: 'sk_test_secret',
260
+ 'exclude-old-transactions': true,
261
+ });
254
262
  });
255
263
 
256
264
  it('normalizes one-item receipt to AppStoreTransactionPayload', async () => {
257
- mockValidateAppleReceipt.mockResolvedValue([
258
- {
259
- bundleId: 'com.example.app',
260
- productId: 'sub_06',
261
- transactionId: 'txn_1',
262
- originalTransactionId: 'orig_1',
263
- purchaseDate: 1700000000000,
264
- expirationDate: 1800000000000,
265
- },
266
- ]);
265
+ mockVerifyReceipt([{ status: 0, receipt: { bundle_id: 'com.example.app' }, latest_receipt_info: [legacyItem()] }]);
267
266
  const c = AppStoreClient.fromSettings(withSecret);
268
267
  const result = await c.verifyLegacyReceipt('base64-receipt');
269
268
  expect(result).toEqual({
@@ -280,10 +279,16 @@ describe('AppStoreClient.verifyLegacyReceipt', () => {
280
279
  });
281
280
 
282
281
  it('picks the item with the latest expirationDate when receipt contains multiple', async () => {
283
- mockValidateAppleReceipt.mockResolvedValue([
284
- { productId: 'sub_06', transactionId: 'old', expirationDate: 1500000000000 },
285
- { productId: 'sub_06', transactionId: 'new', expirationDate: 1800000000000 },
286
- { productId: 'sub_06', transactionId: 'mid', expirationDate: 1600000000000 },
282
+ mockVerifyReceipt([
283
+ {
284
+ status: 0,
285
+ receipt: { bundle_id: 'com.example.app' },
286
+ latest_receipt_info: [
287
+ legacyItem({ transaction_id: 'old', expires_date_ms: '1500000000000' }),
288
+ legacyItem({ transaction_id: 'new', expires_date_ms: '1800000000000' }),
289
+ legacyItem({ transaction_id: 'mid', expires_date_ms: '1600000000000' }),
290
+ ],
291
+ },
287
292
  ]);
288
293
  const c = AppStoreClient.fromSettings(withSecret);
289
294
  const result = await c.verifyLegacyReceipt('base64-receipt');
@@ -291,9 +296,15 @@ describe('AppStoreClient.verifyLegacyReceipt', () => {
291
296
  });
292
297
 
293
298
  it('filters by expectedProductIds when provided', async () => {
294
- mockValidateAppleReceipt.mockResolvedValue([
295
- { productId: 'other_sku', transactionId: 'wrong', expirationDate: 1900000000000 },
296
- { productId: 'sub_06', transactionId: 'right', expirationDate: 1800000000000 },
299
+ mockVerifyReceipt([
300
+ {
301
+ status: 0,
302
+ receipt: { bundle_id: 'com.example.app' },
303
+ latest_receipt_info: [
304
+ legacyItem({ product_id: 'other_sku', transaction_id: 'wrong', expires_date_ms: '1900000000000' }),
305
+ legacyItem({ product_id: 'sub_06', transaction_id: 'right', expires_date_ms: '1800000000000' }),
306
+ ],
307
+ },
297
308
  ]);
298
309
  const c = AppStoreClient.fromSettings(withSecret);
299
310
  const result = await c.verifyLegacyReceipt('base64-receipt', { expectedProductIds: ['sub_06'] });
@@ -301,8 +312,12 @@ describe('AppStoreClient.verifyLegacyReceipt', () => {
301
312
  });
302
313
 
303
314
  it('throws when no item matches expectedProductIds', async () => {
304
- mockValidateAppleReceipt.mockResolvedValue([
305
- { productId: 'unknown_sku', transactionId: 'x', expirationDate: 1800000000000 },
315
+ mockVerifyReceipt([
316
+ {
317
+ status: 0,
318
+ receipt: { bundle_id: 'com.example.app' },
319
+ latest_receipt_info: [legacyItem({ product_id: 'unknown_sku', transaction_id: 'x' })],
320
+ },
306
321
  ]);
307
322
  const c = AppStoreClient.fromSettings(withSecret);
308
323
  await expect(c.verifyLegacyReceipt('base64-receipt', { expectedProductIds: ['sub_06'] })).rejects.toThrow(
@@ -311,25 +326,36 @@ describe('AppStoreClient.verifyLegacyReceipt', () => {
311
326
  });
312
327
 
313
328
  it('rejects when receipt bundleId disagrees with configured bundle_id', async () => {
314
- mockValidateAppleReceipt.mockResolvedValue([
315
- {
316
- bundleId: 'com.evil.app',
317
- productId: 'sub_06',
318
- transactionId: 'txn_1',
319
- originalTransactionId: 'orig_1',
320
- expirationDate: 1800000000000,
321
- },
322
- ]);
329
+ mockVerifyReceipt([{ status: 0, receipt: { bundle_id: 'com.evil.app' }, latest_receipt_info: [legacyItem()] }]);
323
330
  const c = AppStoreClient.fromSettings(withSecret);
324
331
  await expect(c.verifyLegacyReceipt('base64-receipt')).rejects.toThrow(/bundleId mismatch/);
325
332
  });
326
333
 
327
334
  it('falls back to transactionId when originalTransactionId is absent', async () => {
328
- mockValidateAppleReceipt.mockResolvedValue([
329
- { productId: 'sub_06', transactionId: 'txn_only', expirationDate: 1800000000000 },
335
+ mockVerifyReceipt([
336
+ {
337
+ status: 0,
338
+ receipt: { bundle_id: 'com.example.app' },
339
+ latest_receipt_info: [legacyItem({ transaction_id: 'txn_only', original_transaction_id: undefined })],
340
+ },
330
341
  ]);
331
342
  const c = AppStoreClient.fromSettings(withSecret);
332
343
  const result = await c.verifyLegacyReceipt('base64-receipt');
333
344
  expect(result.originalTransactionId).toBe('txn_only');
334
345
  });
346
+
347
+ it('retries the sandbox host when production returns 21007', async () => {
348
+ mockVerifyReceipt([
349
+ { status: 21007 },
350
+ {
351
+ status: 0,
352
+ receipt: { bundle_id: 'com.example.app' },
353
+ latest_receipt_info: [legacyItem({ transaction_id: 'sb' })],
354
+ },
355
+ ]);
356
+ const c = AppStoreClient.fromSettings(withSecret);
357
+ const result = await c.verifyLegacyReceipt('base64-receipt');
358
+ expect(legacyCalls.map((x) => new URL(x.url).host)).toEqual(['buy.itunes.apple.com', 'sandbox.itunes.apple.com']);
359
+ expect(result.transactionId).toBe('sb');
360
+ });
335
361
  });
@@ -0,0 +1,54 @@
1
+ # App Store native-verify test fixtures
2
+
3
+ ## `make-chain.ts` — synthetic chain generator (0.1)
4
+
5
+ Generates a self-signed Apple-style 3-cert chain (root → CA intermediate w/ Apple
6
+ OID `1.2.840.113635.100.6.2.1` → leaf w/ Apple OID `1.2.840.113635.100.6.11.1`)
7
+ plus an ES256 `header.payload.p1363sig` JWS signed by the leaf key, and a
8
+ `crossSignedLeaf` (same subject DN, different key) for chain-failure negatives.
9
+ Cert validity windows are pinned via `certValidFrom`/`certValidTo` so skew
10
+ boundary tests can position `signedDate` precisely. See the file header for the
11
+ full contract — chiefly: **`signedDate` is the `effectiveDate`, never `Date.now()`.**
12
+
13
+ **Determinism:** the EC P-256 signing keys are committed under `keys/`
14
+ (`{root,int,leaf,wrongint}.key.pem`, synthetic + test-only) and certs are derived
15
+ from them at runtime with pinned windows — so value assertions are stable and the
16
+ certs never expire. Pass `freshKeys: true` for unique per-call keys. These keys
17
+ are test-only and **must never be imported by `src/`** — `make-chain.spec.ts`
18
+ asserts that structurally (a data-leak regression fails CI).
19
+
20
+ ## `real-sandbox.jws` / `real-sandbox.json` — real Apple fixture (0.4)
21
+
22
+ A genuine Apple-signed (ES256) sandbox `signedTransactionInfo`, fetched via the
23
+ App Store Server API `getTransactionInfo()` (`bundle_id: io.arcblock.ai.stro`,
24
+ `transaction_id: 2000001186283296`). The real x5c chain is:
25
+
26
+ ```
27
+ leaf: Prod ECC Mac App Store and iTunes Store Receipt Signing (expires 2027-10-13)
28
+ int: Apple Worldwide Developer Relations Certification Authority G6
29
+ root: Apple Root CA - G3 (expires 2039-04-30)
30
+ ```
31
+
32
+ This is **public, Apple-signed data — it contains no credentials** (a JWS is the
33
+ signed transaction itself; the signing keys stay at Apple). Committing it is safe
34
+ and it is the golden vector that exercises Apple's **real** OID + 3-cert chain
35
+ end-to-end — the gap synthetic self-signed chains cannot close (design §11).
36
+
37
+ `real-sandbox.json` carries the decoded header/payload + provenance `_meta` for
38
+ reference; tests assert against `real-sandbox.jws`.
39
+
40
+ **Maintenance:** the leaf cert expires 2027-10-13. After that, `verifyAppleJws`
41
+ will reject this fixture on the validity check unless tests pin `signedDate` to
42
+ the original `2026-06-15T11:02:14.379Z` (`payload.signedDate = 1781521334379`),
43
+ which is what the native-jws spec does — so the fixture stays valid indefinitely
44
+ for the algorithm assertions.
45
+
46
+ ## 0.3 — OID-encoding contract (production vs probe)
47
+
48
+ Production code (`native-asn1.ts`, Phase 1) matches the **FULL DER OID tag**
49
+ `[0x06, len, ...content]` against `cert.raw`. This is intentionally STRICTER than
50
+ the workerd probe's content-only `oidContentHex` scan (`x509-probe.mjs`), which
51
+ stays looser because a probe only needs a presence signal. Defense-in-depth: the
52
+ OID marker is **not** the trust anchor — the chain-to-pinned-Apple-root is
53
+ (design §4/§5.5). Both certs in `make-chain.ts` carry the OIDs as `DER:0500`
54
+ extensions so the full-tag scan finds them.
@@ -0,0 +1,5 @@
1
+ -----BEGIN EC PRIVATE KEY-----
2
+ MHcCAQEEIOLX/gC4P1qjaOE/Ed/QHleT9P2Ib5bQ9YOZP8SXpFuNoAoGCCqGSM49
3
+ AwEHoUQDQgAEnIlK3BCZxjk0kCXqbaNe/BYVG4VQ69YAkENkl9sL8sjDZGVwasEa
4
+ OmnFMayUJIJPhfs+GyfSeY4p6m9kMiojrg==
5
+ -----END EC PRIVATE KEY-----
@@ -0,0 +1,5 @@
1
+ -----BEGIN EC PRIVATE KEY-----
2
+ MHcCAQEEIFKVpAVLag9q7o02jUj0hsrZs2+Wp9VlZ1rvvXAdRDnNoAoGCCqGSM49
3
+ AwEHoUQDQgAEi6jIb2EAAEGl8zcWyBd1No5Qz6wyZJLTS5sjbAA04FPX1FDlxReK
4
+ X7q8vLFRhtkiR8OYnpCfLAeFng0rNi6Vdw==
5
+ -----END EC PRIVATE KEY-----
@@ -0,0 +1,5 @@
1
+ -----BEGIN EC PRIVATE KEY-----
2
+ MHcCAQEEIG+D+QBzI7gMWT4Zuk9iqw663wEtMWol4ccQfydNehdNoAoGCCqGSM49
3
+ AwEHoUQDQgAE7hyZ2JQcvmLQIrzj8j5UUSl/tOBgJG5bhFRwMu3isC8xibmLb/cj
4
+ P2IIOGfGAXz0Iu5NLEm7etUmR3HKvQlKTg==
5
+ -----END EC PRIVATE KEY-----
@@ -0,0 +1,5 @@
1
+ -----BEGIN EC PRIVATE KEY-----
2
+ MHcCAQEEIB4LVWQHO0Sx74I6JjkEmglD6b5sfnt+CmU6Tf711WNtoAoGCCqGSM49
3
+ AwEHoUQDQgAE3fF1vJNolgjgt+rIqLDQCzZ1Irrr4VKxwxTwNzTD4p16sa5U6DOR
4
+ qPd67ubvIOmQldtn4DJ8yi+sAxNjLVevGA==
5
+ -----END EC PRIVATE KEY-----
@@ -0,0 +1,152 @@
1
+ // Structural coverage for the synthetic chain generator (Phase 0, task 0.1).
2
+ // These assertions ARE the fixtures' contract: if they hold, the negative
3
+ // vectors the Phase 1 verifier specs rely on are sound.
4
+
5
+ import { X509Certificate } from 'node:crypto';
6
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ import { APPLE_INT_OID, APPLE_LEAF_OID, buildJws, makeChain } from './make-chain';
10
+
11
+ // Full DER OID tag (mirrors native-asn1.ts, see 0.3) — the strict form.
12
+ function oidToDerTag(oid: string): Buffer {
13
+ const parts = oid.split('.').map(Number);
14
+ const body = [40 * parts[0]! + parts[1]!];
15
+ for (let i = 2; i < parts.length; i++) {
16
+ let v = parts[i]!;
17
+ const stack = [v & 0x7f];
18
+ v = Math.floor(v / 128);
19
+ while (v > 0) {
20
+ stack.unshift((v & 0x7f) | 0x80);
21
+ v = Math.floor(v / 128);
22
+ }
23
+ body.push(...stack);
24
+ }
25
+ return Buffer.from([0x06, body.length, ...body]);
26
+ }
27
+
28
+ const certOf = (b64: string): X509Certificate => new X509Certificate(Buffer.from(b64, 'base64'));
29
+
30
+ describe('make-chain — happy path', () => {
31
+ const chain = makeChain({ signedDate: 1_781_521_334_379 });
32
+
33
+ it('leaf verifies against intermediate, intermediate against root', () => {
34
+ expect(chain.certs.leaf.verify(chain.certs.intermediate.publicKey)).toBe(true);
35
+ expect(chain.certs.intermediate.verify(chain.certs.root.publicKey)).toBe(true);
36
+ });
37
+
38
+ it('issuer/subject equality holds up the chain', () => {
39
+ expect(chain.certs.leaf.issuer).toBe(chain.certs.intermediate.subject);
40
+ expect(chain.certs.intermediate.issuer).toBe(chain.certs.root.subject);
41
+ expect(chain.certs.root.issuer).toBe(chain.certs.root.subject); // self-signed
42
+ });
43
+
44
+ it('intermediate is a CA and carries the Apple intermediate OID', () => {
45
+ expect(chain.certs.intermediate.ca).toBe(true);
46
+ expect(Buffer.from(chain.certs.intermediate.raw).includes(oidToDerTag(APPLE_INT_OID))).toBe(true);
47
+ });
48
+
49
+ it('leaf is not a CA and carries the Apple leaf OID', () => {
50
+ expect(chain.certs.leaf.ca).toBe(false);
51
+ expect(Buffer.from(chain.certs.leaf.raw).includes(oidToDerTag(APPLE_LEAF_OID))).toBe(true);
52
+ });
53
+
54
+ it('emits a 3-segment JWS with a 64-byte P1363 signature', () => {
55
+ expect(chain.jws.split('.')).toHaveLength(3);
56
+ expect(chain.p1363Sig).toHaveLength(64);
57
+ expect(chain.payload.signedDate).toBe(1_781_521_334_379);
58
+ });
59
+ });
60
+
61
+ describe('make-chain — security negative controls', () => {
62
+ const chain = makeChain();
63
+
64
+ it('cross-signed leaf fails leaf.verify(intermediate.publicKey) but keeps issuer/subject equality', () => {
65
+ const cross = certOf(chain.crossSignedLeaf);
66
+ expect(cross.issuer).toBe(chain.certs.intermediate.subject); // same DN — isolates the signature failure
67
+ expect(cross.verify(chain.certs.intermediate.publicKey)).toBe(false);
68
+ });
69
+
70
+ it('an OID we did NOT add is not present (mirrors probe intOidPresent === false)', () => {
71
+ // The leaf carries the LEAF OID, not the INTERMEDIATE OID — negative control.
72
+ expect(Buffer.from(chain.certs.leaf.raw).includes(oidToDerTag(APPLE_INT_OID))).toBe(false);
73
+ });
74
+
75
+ it('omits the leaf OID on demand (leafOid: false)', () => {
76
+ const noOid = makeChain({ leafOid: false });
77
+ expect(Buffer.from(noOid.certs.leaf.raw).includes(oidToDerTag(APPLE_LEAF_OID))).toBe(false);
78
+ });
79
+ });
80
+
81
+ describe('make-chain — data damage / round-trip', () => {
82
+ const chain = makeChain();
83
+
84
+ it('decode(x5c[i]) byte-equals the DER we encoded', () => {
85
+ expect(Buffer.from(chain.x5c[0]!, 'base64').equals(Buffer.from(chain.certs.leaf.raw))).toBe(true);
86
+ expect(Buffer.from(chain.x5c[1]!, 'base64').equals(Buffer.from(chain.certs.intermediate.raw))).toBe(true);
87
+ expect(Buffer.from(chain.x5c[2]!, 'base64').equals(Buffer.from(chain.certs.root.raw))).toBe(true);
88
+ });
89
+
90
+ it('the b64url JWS header round-trips to the header object', () => {
91
+ const [h] = chain.jws.split('.');
92
+ const header = JSON.parse(Buffer.from(h!, 'base64url').toString('utf8'));
93
+ expect(header.alg).toBe('ES256');
94
+ expect(header.x5c).toEqual(chain.x5c);
95
+ });
96
+ });
97
+
98
+ describe('make-chain — validity window pinning', () => {
99
+ it('pins notBefore/notAfter relative to signedDate', () => {
100
+ const signedDate = 1_700_000_000_000;
101
+ const chain = makeChain({
102
+ signedDate,
103
+ certValidFrom: new Date(signedDate - 86_400_000),
104
+ certValidTo: new Date(signedDate + 86_400_000),
105
+ });
106
+ const vf = Date.parse(chain.certs.leaf.validFrom);
107
+ const vt = Date.parse(chain.certs.leaf.validTo);
108
+ expect(vf).toBeLessThanOrEqual(signedDate);
109
+ expect(vt).toBeGreaterThanOrEqual(signedDate);
110
+ // window is ~2 days wide (second-granularity rounding aside)
111
+ expect(vt - vf).toBeGreaterThan(86_400_000);
112
+ });
113
+ });
114
+
115
+ describe('make-chain — data leak (committed test keys never reach src/)', () => {
116
+ // The synthetic signing keys under fixtures/keys/ are test-only. Production
117
+ // code must NEVER import the fixtures or the keys. This is a structural gate
118
+ // so a future regression (e.g. someone wiring a fixture into src) fails CI.
119
+ const srcDir = path.join(__dirname, '..', '..', '..', '..', 'src');
120
+
121
+ function walk(dir: string): string[] {
122
+ return readdirSync(dir).flatMap((name) => {
123
+ const full = path.join(dir, name);
124
+ if (statSync(full).isDirectory()) return walk(full);
125
+ return /\.(ts|tsx|js|jsx|mjs)$/.test(name) ? [full] : [];
126
+ });
127
+ }
128
+
129
+ it('no src/ file references the fixtures, the generator, or the test keys', () => {
130
+ const offenders = walk(srcDir).filter((file) => {
131
+ const text = readFileSync(file, 'utf8');
132
+ return /make-chain|app-store\/fixtures|fixtures\/keys|real-sandbox/.test(text);
133
+ });
134
+ expect(offenders).toEqual([]);
135
+ });
136
+ });
137
+
138
+ describe('make-chain — buildJws crafts variants', () => {
139
+ const chain = makeChain();
140
+
141
+ it('re-signs a custom header/payload with the leaf key', () => {
142
+ const { jws, p1363Sig } = buildJws({ alg: 'ES256', x5c: chain.x5c }, { foo: 'bar' }, chain.keys.leafKeyPem);
143
+ expect(jws.split('.')).toHaveLength(3);
144
+ expect(p1363Sig).toHaveLength(64);
145
+ });
146
+
147
+ it('can forge a wrong-length x5c header (negative vector source)', () => {
148
+ const { jws } = buildJws({ alg: 'ES256', x5c: chain.x5c.slice(0, 2) }, chain.payload, chain.keys.leafKeyPem);
149
+ const header = JSON.parse(Buffer.from(jws.split('.')[0]!, 'base64url').toString('utf8'));
150
+ expect(header.x5c).toHaveLength(2);
151
+ });
152
+ });