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