payment-kit 1.29.6 → 1.29.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/blocklet.yml +1 -1
- package/cloudflare/tests/x509-probe.mjs +260 -0
- package/package.json +6 -9
- package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +0 -17
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
//
|
|
18
18
|
// Tests replace this class via jest mocks.
|
|
19
19
|
|
|
20
|
-
import { config as configApple, validate as validateAppleReceipt } from 'node-apple-receipt-verify';
|
|
21
20
|
import { appStoreWriteEnabled } from '../../libs/env';
|
|
22
21
|
|
|
23
22
|
import logger from '../../libs/logger';
|
|
@@ -27,6 +26,7 @@ import {
|
|
|
27
26
|
verifySignedNotification,
|
|
28
27
|
verifySignedTransaction,
|
|
29
28
|
} from './signed-data-verifier';
|
|
29
|
+
import { verifyAppleReceiptNative } from './native-receipt';
|
|
30
30
|
|
|
31
31
|
/** App Store Server Notification V2 `notificationType` — Apple-defined string enum. */
|
|
32
32
|
export type AppStoreNotificationType =
|
|
@@ -239,9 +239,9 @@ export class AppStoreClient {
|
|
|
239
239
|
/**
|
|
240
240
|
* Verify a StoreKit 1 (legacy) base64 receipt via Apple's verifyReceipt endpoint.
|
|
241
241
|
*
|
|
242
|
-
* Calls `
|
|
242
|
+
* Calls the native `verifyAppleReceiptNative` which POSTs to
|
|
243
243
|
* https://buy.itunes.apple.com/verifyReceipt (production)
|
|
244
|
-
* https://sandbox.itunes.apple.com/verifyReceipt (sandbox, auto fallback)
|
|
244
|
+
* https://sandbox.itunes.apple.com/verifyReceipt (sandbox, auto fallback on 21007/21002)
|
|
245
245
|
*
|
|
246
246
|
* Returns a payload shaped like a StoreKit 2 transaction so downstream code
|
|
247
247
|
* (ingestVerifiedAppStorePurchase) can stay agnostic. Note: legacy receipts
|
|
@@ -259,16 +259,10 @@ export class AppStoreClient {
|
|
|
259
259
|
throw new Error('AppStoreClient: shared_secret is required for legacy receipt verification');
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
secret: this.sharedSecret,
|
|
267
|
-
verbose: false,
|
|
268
|
-
environment: ['production', 'sandbox'],
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
const items = (await validateAppleReceipt({ receipt })) as Array<any>;
|
|
262
|
+
// Stateless native verifyReceipt — each call carries its own shared secret,
|
|
263
|
+
// so concurrent PaymentMethods can't shadow each other (the old module-level
|
|
264
|
+
// `configApple` had that race).
|
|
265
|
+
const items = await verifyAppleReceiptNative({ receipt, sharedSecret: this.sharedSecret });
|
|
272
266
|
const filtered = options.expectedProductIds
|
|
273
267
|
? items.filter((i) => options.expectedProductIds!.includes(i.productId))
|
|
274
268
|
: items;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Native App Store Server API client (Path B) — replaces the apple lib's
|
|
2
|
+
// AppStoreServerAPIClient for the one call we use (getAllSubscriptionStatuses),
|
|
3
|
+
// so dist/index.js stops importing the apple lib for the API path too (design §3B).
|
|
4
|
+
//
|
|
5
|
+
// It signs an ES256 JWT bearer with `crypto.subtle` (pkcs8 import — proven under
|
|
6
|
+
// workerd in the probe's pkcs8_sign_round, design §2/§4) and `fetch`es Apple's
|
|
7
|
+
// endpoint. NO new dependency. The returned signedTransactionInfo is verified by
|
|
8
|
+
// the caller via the native verifier (Phase 1), not here.
|
|
9
|
+
|
|
10
|
+
import type { AppStoreApiCredentials, StatusResponse } from './signed-data-verifier';
|
|
11
|
+
|
|
12
|
+
// App Store Server API hosts — NOTE: NO `itunes` (unlike the legacy verifyReceipt
|
|
13
|
+
// host in native-receipt.ts). Do not conflate the two.
|
|
14
|
+
const API_HOST_PRODUCTION = 'https://api.storekit.apple.com';
|
|
15
|
+
const API_HOST_SANDBOX = 'https://api.storekit-sandbox.apple.com';
|
|
16
|
+
const JWT_TTL_SECONDS = 300; // 5 min — matches the evicted apple lib (expiresIn:'5m'); Apple's ceiling is 60 min.
|
|
17
|
+
|
|
18
|
+
/** Strip a PEM armor + whitespace to the raw DER bytes (for an `.p8` PKCS#8 key). */
|
|
19
|
+
function pemToDer(pem: string): Buffer {
|
|
20
|
+
const body = pem
|
|
21
|
+
.replace(/-----BEGIN [^-]+-----/g, '')
|
|
22
|
+
.replace(/-----END [^-]+-----/g, '')
|
|
23
|
+
.replace(/\s+/g, '');
|
|
24
|
+
if (!body) {
|
|
25
|
+
throw new Error('AppStore API: private_key_pem is empty or not PEM-armored');
|
|
26
|
+
}
|
|
27
|
+
return Buffer.from(body, 'base64');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Sign an ES256 JWT bearer for the App Store Server API. */
|
|
31
|
+
async function signBearerJwt(creds: AppStoreApiCredentials): Promise<string> {
|
|
32
|
+
const header = { alg: 'ES256', kid: creds.keyId, typ: 'JWT' };
|
|
33
|
+
// Date.now() is fine here (request scope, not global scope) — this is a live
|
|
34
|
+
// API token timestamp, unrelated to the offline cert-validity effectiveDate.
|
|
35
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
36
|
+
const payload = {
|
|
37
|
+
iss: creds.issuerId,
|
|
38
|
+
iat,
|
|
39
|
+
exp: iat + JWT_TTL_SECONDS,
|
|
40
|
+
aud: 'appstoreconnect-v1',
|
|
41
|
+
bid: creds.bundleId,
|
|
42
|
+
};
|
|
43
|
+
const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
|
|
44
|
+
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
45
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
46
|
+
|
|
47
|
+
let key: CryptoKey;
|
|
48
|
+
try {
|
|
49
|
+
key = await crypto.subtle.importKey(
|
|
50
|
+
'pkcs8',
|
|
51
|
+
pemToDer(creds.privateKeyPem) as unknown as ArrayBuffer,
|
|
52
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
53
|
+
false,
|
|
54
|
+
['sign']
|
|
55
|
+
);
|
|
56
|
+
} catch {
|
|
57
|
+
// Surface as a clear import failure — never a silent unsigned request.
|
|
58
|
+
throw new Error('AppStore API: private_key_pem is not a valid EC P-256 PKCS#8 key');
|
|
59
|
+
}
|
|
60
|
+
const signature = await crypto.subtle.sign(
|
|
61
|
+
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
62
|
+
key,
|
|
63
|
+
Buffer.from(signingInput, 'ascii')
|
|
64
|
+
);
|
|
65
|
+
return `${signingInput}.${Buffer.from(signature).toString('base64url')}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fetch all subscription statuses for an originalTransactionId via the App Store
|
|
70
|
+
* Server API. Returns Apple's StatusResponse (whose signedTransactionInfo JWS the
|
|
71
|
+
* caller re-verifies). A 404 / not-found yields an empty response (parity with
|
|
72
|
+
* the apple lib path → caller returns null + warns, never drops a pending reconcile).
|
|
73
|
+
*/
|
|
74
|
+
export async function nativeGetAllSubscriptionStatuses(
|
|
75
|
+
originalTransactionId: string,
|
|
76
|
+
creds: AppStoreApiCredentials
|
|
77
|
+
): Promise<StatusResponse> {
|
|
78
|
+
if (!creds.issuerId || !creds.keyId || !creds.privateKeyPem) {
|
|
79
|
+
throw new Error('AppStore API: issuer_id/key_id/private_key_pem are required');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const host = creds.environment === 'production' ? API_HOST_PRODUCTION : API_HOST_SANDBOX;
|
|
83
|
+
// encodeURIComponent keeps a crafted id confined to ONE path segment — it can
|
|
84
|
+
// never redirect the host or climb the path (SSRF guard).
|
|
85
|
+
const url = `${host}/inApps/v1/subscriptions/${encodeURIComponent(originalTransactionId)}`;
|
|
86
|
+
const jwt = await signBearerJwt(creds);
|
|
87
|
+
|
|
88
|
+
const response = await fetch(url, {
|
|
89
|
+
method: 'GET',
|
|
90
|
+
headers: { Authorization: `Bearer ${jwt}`, Accept: 'application/json' },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// 404 = no subscription for this id — treat as empty, do not throw (parity).
|
|
94
|
+
if (response.status === 404) {
|
|
95
|
+
return { data: [] };
|
|
96
|
+
}
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
// Surface the status only — never the bearer JWT or the .p8 key.
|
|
99
|
+
throw new Error(`AppStore API: getAllSubscriptionStatuses failed with HTTP ${response.status}`);
|
|
100
|
+
}
|
|
101
|
+
return (await response.json()) as StatusResponse;
|
|
102
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Minimal, dependency-free ASN.1 helper for Apple extension-OID presence checks.
|
|
2
|
+
//
|
|
3
|
+
// Apple's IAP signing chain marks the intermediate and leaf certs with custom
|
|
4
|
+
// extension OIDs (intermediate `1.2.840.113635.100.6.2.1`, leaf
|
|
5
|
+
// `1.2.840.113635.100.6.11.1`). node's / workerd's `X509Certificate` exposes NO
|
|
6
|
+
// generic extension-by-OID accessor (proven in the x509 probe, design §4), so we
|
|
7
|
+
// DER-encode the OID and scan the cert's raw DER for it.
|
|
8
|
+
//
|
|
9
|
+
// HARDENING (design §4/§5.5): we match the FULL DER OID tag `[0x06, len, ...content]`
|
|
10
|
+
// — STRICTER than scanning for the bare content octets — to shrink false-positive
|
|
11
|
+
// odds. This OID check is DEFENSE IN DEPTH, never the trust anchor: the real anchor
|
|
12
|
+
// is the chain-to-pinned-Apple-root (native-jws.ts §5.3–5.4). A spoofed cert could
|
|
13
|
+
// embed these public OID bytes, but it cannot forge the chain signature.
|
|
14
|
+
|
|
15
|
+
import type { X509Certificate } from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Encode an OID to its full DER tag-length-value form `[0x06, len, ...content]`.
|
|
19
|
+
* Only handles the single-byte definite-length form (len < 128), which covers
|
|
20
|
+
* every Apple OID (their content is ~9 bytes). Throws on a sub-identifier or
|
|
21
|
+
* total length that would need multi-byte length encoding — we never expect one.
|
|
22
|
+
*/
|
|
23
|
+
export function oidToDerTag(oid: string): Buffer {
|
|
24
|
+
const parts = oid.split('.').map((p) => Number.parseInt(p, 10));
|
|
25
|
+
if (parts.length < 2 || parts.some((n) => !Number.isInteger(n) || n < 0)) {
|
|
26
|
+
throw new Error(`oidToDerTag: invalid OID "${oid}"`);
|
|
27
|
+
}
|
|
28
|
+
// First two arcs collapse into a single byte: 40*X + Y.
|
|
29
|
+
const content = [40 * parts[0]! + parts[1]!];
|
|
30
|
+
for (let i = 2; i < parts.length; i++) {
|
|
31
|
+
let v = parts[i]!;
|
|
32
|
+
const sevenBitGroups = [v & 0x7f];
|
|
33
|
+
v = Math.floor(v / 128);
|
|
34
|
+
while (v > 0) {
|
|
35
|
+
sevenBitGroups.unshift((v & 0x7f) | 0x80); // continuation bit on all but the last
|
|
36
|
+
v = Math.floor(v / 128);
|
|
37
|
+
}
|
|
38
|
+
content.push(...sevenBitGroups);
|
|
39
|
+
}
|
|
40
|
+
if (content.length > 127) {
|
|
41
|
+
throw new Error(`oidToDerTag: OID "${oid}" content too long for single-byte length`);
|
|
42
|
+
}
|
|
43
|
+
return Buffer.from([0x06, content.length, ...content]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True if the cert's raw DER contains the full DER-encoded OID tag. */
|
|
47
|
+
export function certHasOid(cert: X509Certificate, oid: string): boolean {
|
|
48
|
+
return Buffer.from(cert.raw).includes(oidToDerTag(oid));
|
|
49
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Native Apple JWS verifier — reproduces Apple's `jws_verification.ts` using
|
|
2
|
+
// runtime built-ins only (`node:crypto` X509Certificate + `crypto.subtle`), so
|
|
3
|
+
// the `@apple/app-store-server-library` + `jsrsasign` cluster can be evicted from
|
|
4
|
+
// both payment-core bundles (design §1, §5). NO new dependency, NO OCSP.
|
|
5
|
+
//
|
|
6
|
+
// This is a TRUST BOUNDARY (CWE-347). The checks below are NOT optional and must
|
|
7
|
+
// not be "simplified" — read design §5 + §8 before touching them. The trust
|
|
8
|
+
// anchor is the chain-to-pinned-Apple-root (steps 3–4); the Apple extension-OID
|
|
9
|
+
// check (step 5) is defense in depth.
|
|
10
|
+
//
|
|
11
|
+
// Cross-runtime: the same source compiles into dist/index.js (node) and
|
|
12
|
+
// dist/cf.js (workerd). Approach S (subtle for the signature) is the only
|
|
13
|
+
// cross-runtime-safe path — workerd rejects node's KeyObject in createVerify
|
|
14
|
+
// (probe P2b). Proven green under workerd in cloudflare/tests/x509-probe.mjs.
|
|
15
|
+
|
|
16
|
+
import { X509Certificate } from 'node:crypto';
|
|
17
|
+
|
|
18
|
+
import { APPLE_ROOT_CERTS } from './apple-root-certs';
|
|
19
|
+
import { certHasOid } from './native-asn1';
|
|
20
|
+
|
|
21
|
+
const APPLE_LEAF_OID = '1.2.840.113635.100.6.11.1';
|
|
22
|
+
const APPLE_INTERMEDIATE_OID = '1.2.840.113635.100.6.2.1';
|
|
23
|
+
const MAX_SKEW_MS = 60_000; // ±60s clock skew, matching Apple's lib
|
|
24
|
+
|
|
25
|
+
/** JWS protected header (only the fields we trust + need). */
|
|
26
|
+
export interface JwsHeader {
|
|
27
|
+
alg: string;
|
|
28
|
+
/** base64 (NOT base64url) DER certs: [leaf, intermediate, root]. */
|
|
29
|
+
x5c: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Decoded StoreKit 2 `signedTransactionInfo` payload — fields consumed by client.ts. */
|
|
33
|
+
export interface JwsTransactionPayload {
|
|
34
|
+
transactionId: string;
|
|
35
|
+
originalTransactionId?: string;
|
|
36
|
+
productId: string;
|
|
37
|
+
purchaseDate?: number;
|
|
38
|
+
expiresDate?: number;
|
|
39
|
+
appAccountToken?: string;
|
|
40
|
+
environment?: 'Production' | 'Sandbox' | string;
|
|
41
|
+
signedDate?: number;
|
|
42
|
+
bundleId?: string;
|
|
43
|
+
type?: string;
|
|
44
|
+
webOrderLineItemId?: string;
|
|
45
|
+
revocationDate?: number;
|
|
46
|
+
revocationReason?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Decoded App Store Server Notification V2 `signedPayload` — fields consumed by client.ts. */
|
|
50
|
+
export interface JwsNotificationPayload {
|
|
51
|
+
notificationType: string;
|
|
52
|
+
notificationUUID: string;
|
|
53
|
+
subtype?: string;
|
|
54
|
+
version?: string;
|
|
55
|
+
signedDate?: number;
|
|
56
|
+
data?: {
|
|
57
|
+
appAppleId?: number;
|
|
58
|
+
bundleId?: string;
|
|
59
|
+
bundleVersion?: string;
|
|
60
|
+
environment?: 'Production' | 'Sandbox' | string;
|
|
61
|
+
signedTransactionInfo?: string;
|
|
62
|
+
signedRenewalInfo?: string;
|
|
63
|
+
status?: 1 | 2 | 3 | 4 | 5;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface VerifyAppleJwsOptions {
|
|
68
|
+
/** Trust anchors (default: vendored APPLE_ROOT_CERTS). Tests inject a synthetic root. */
|
|
69
|
+
trustedRoots?: Buffer[];
|
|
70
|
+
/** Override the effectiveDate for cert validity. Default: the payload's `signedDate`. */
|
|
71
|
+
signedDate?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Named, non-secret-bearing failure for a JWS verification step (CWE-347 audit trail). */
|
|
75
|
+
export class AppleJwsVerifyError extends Error {
|
|
76
|
+
public readonly code: string;
|
|
77
|
+
|
|
78
|
+
constructor(code: string, detail?: string) {
|
|
79
|
+
super(detail ? `AppStore JWS verify failed [${code}]: ${detail}` : `AppStore JWS verify failed [${code}]`);
|
|
80
|
+
this.name = 'AppleJwsVerifyError';
|
|
81
|
+
this.code = code;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function base64UrlToJson(segment: string, code: string): Record<string, unknown> {
|
|
86
|
+
let text: string;
|
|
87
|
+
try {
|
|
88
|
+
text = Buffer.from(segment, 'base64url').toString('utf8');
|
|
89
|
+
} catch {
|
|
90
|
+
throw new AppleJwsVerifyError(code, 'segment is not valid base64url');
|
|
91
|
+
}
|
|
92
|
+
let parsed: unknown;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(text);
|
|
95
|
+
} catch {
|
|
96
|
+
throw new AppleJwsVerifyError(code, 'segment is not valid JSON');
|
|
97
|
+
}
|
|
98
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
99
|
+
throw new AppleJwsVerifyError(code, 'segment is not a JSON object');
|
|
100
|
+
}
|
|
101
|
+
// Strip an own `__proto__`/`constructor`/`prototype` key so a hostile payload
|
|
102
|
+
// can never reach Object.prototype downstream (defensive; JSON.parse already
|
|
103
|
+
// makes `__proto__` an own prop rather than a prototype mutation).
|
|
104
|
+
const out = parsed as Record<string, unknown>;
|
|
105
|
+
for (const key of ['__proto__', 'constructor', 'prototype']) {
|
|
106
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) {
|
|
107
|
+
delete out[key];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildCert(b64: string, label: string): X509Certificate {
|
|
114
|
+
try {
|
|
115
|
+
return new X509Certificate(Buffer.from(b64, 'base64'));
|
|
116
|
+
} catch {
|
|
117
|
+
throw new AppleJwsVerifyError('MALFORMED_CERT', `${label} cert is not valid DER`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Reject if the cert's validity window does not cover `effectiveDate` (±MAX_SKEW). */
|
|
122
|
+
function assertCertValid(cert: X509Certificate, effectiveDate: number, label: string): void {
|
|
123
|
+
const validFrom = Date.parse(cert.validFrom);
|
|
124
|
+
const validTo = Date.parse(cert.validTo);
|
|
125
|
+
if (Number.isNaN(validFrom) || Number.isNaN(validTo)) {
|
|
126
|
+
throw new AppleJwsVerifyError('CERT_VALIDITY', `${label} cert has unparseable validity dates`);
|
|
127
|
+
}
|
|
128
|
+
if (validFrom > effectiveDate + MAX_SKEW_MS) {
|
|
129
|
+
throw new AppleJwsVerifyError('CERT_NOT_YET_VALID', `${label} cert not valid until after signedDate`);
|
|
130
|
+
}
|
|
131
|
+
if (validTo < effectiveDate - MAX_SKEW_MS) {
|
|
132
|
+
throw new AppleJwsVerifyError('CERT_EXPIRED', `${label} cert expired before signedDate`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Verify + decode an Apple JWS (StoreKit 2 transaction or notification) exactly
|
|
138
|
+
* per design §5. Throws AppleJwsVerifyError (named code, never echoes the raw
|
|
139
|
+
* JWS / cert material) on any failure. Stateless — safe for concurrent calls.
|
|
140
|
+
*/
|
|
141
|
+
export async function verifyAppleJws<T = JwsTransactionPayload | JwsNotificationPayload>(
|
|
142
|
+
jws: string,
|
|
143
|
+
opts: VerifyAppleJwsOptions = {}
|
|
144
|
+
): Promise<T> {
|
|
145
|
+
const trustedRoots = opts.trustedRoots ?? APPLE_ROOT_CERTS;
|
|
146
|
+
|
|
147
|
+
// 1. Split + parse header.
|
|
148
|
+
const parts = jws.split('.');
|
|
149
|
+
if (parts.length !== 3) {
|
|
150
|
+
throw new AppleJwsVerifyError('FORMAT', 'expected 3 JWS segments');
|
|
151
|
+
}
|
|
152
|
+
const [headerSeg, payloadSeg, signatureSeg] = parts as [string, string, string];
|
|
153
|
+
const header = base64UrlToJson(headerSeg, 'HEADER') as unknown as JwsHeader;
|
|
154
|
+
|
|
155
|
+
// alg whitelist — never derive the algorithm from the token (design §8).
|
|
156
|
+
if (header.alg !== 'ES256') {
|
|
157
|
+
throw new AppleJwsVerifyError('ALG', 'only ES256 is accepted');
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(header.x5c) || header.x5c.length !== 3) {
|
|
160
|
+
throw new AppleJwsVerifyError('INVALID_CHAIN_LENGTH', 'x5c must contain exactly 3 certs');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 2. Build certs (leaf, intermediate, rootFromJws).
|
|
164
|
+
const leaf = buildCert(header.x5c[0]!, 'leaf');
|
|
165
|
+
const intermediate = buildCert(header.x5c[1]!, 'intermediate');
|
|
166
|
+
const rootFromJws = buildCert(header.x5c[2]!, 'root');
|
|
167
|
+
|
|
168
|
+
// 3. Anchor the presented root to a vendored trusted Apple root (byte-equal DER).
|
|
169
|
+
const rootDer = Buffer.from(rootFromJws.raw);
|
|
170
|
+
const anchored = trustedRoots.some((trusted) => trusted.equals(rootDer));
|
|
171
|
+
if (!anchored) {
|
|
172
|
+
throw new AppleJwsVerifyError('ANCHOR', 'presented root is not a trusted Apple root');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 4. Walk the chain: signature + issuer/subject equality at each hop.
|
|
176
|
+
if (!intermediate.verify(rootFromJws.publicKey) || intermediate.issuer !== rootFromJws.subject) {
|
|
177
|
+
throw new AppleJwsVerifyError('CHAIN', 'intermediate does not chain to root');
|
|
178
|
+
}
|
|
179
|
+
if (!leaf.verify(intermediate.publicKey) || leaf.issuer !== intermediate.subject) {
|
|
180
|
+
throw new AppleJwsVerifyError('CHAIN', 'leaf does not chain to intermediate');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 5. CA flag + Apple extension OIDs (defense in depth).
|
|
184
|
+
if (intermediate.ca !== true || !certHasOid(intermediate, APPLE_INTERMEDIATE_OID)) {
|
|
185
|
+
throw new AppleJwsVerifyError('OID', 'intermediate missing CA flag or Apple OID');
|
|
186
|
+
}
|
|
187
|
+
if (!certHasOid(leaf, APPLE_LEAF_OID)) {
|
|
188
|
+
throw new AppleJwsVerifyError('OID', 'leaf missing Apple OID');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Decode the payload now — its signedDate is the effectiveDate for validity.
|
|
192
|
+
const payload = base64UrlToJson(payloadSeg, 'PAYLOAD');
|
|
193
|
+
const effectiveDate = opts.signedDate ?? (typeof payload.signedDate === 'number' ? payload.signedDate : undefined);
|
|
194
|
+
if (typeof effectiveDate !== 'number') {
|
|
195
|
+
throw new AppleJwsVerifyError('NO_SIGNED_DATE', 'payload has no numeric signedDate to validate against');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 6. Validity dates for all three certs (NO OCSP — design §5.7).
|
|
199
|
+
assertCertValid(leaf, effectiveDate, 'leaf');
|
|
200
|
+
assertCertValid(intermediate, effectiveDate, 'intermediate');
|
|
201
|
+
assertCertValid(rootFromJws, effectiveDate, 'root');
|
|
202
|
+
|
|
203
|
+
// 8. Verify the ES256 signature over `header.payload` with the leaf key
|
|
204
|
+
// (Approach S: SPKI import + subtle verify of the raw P1363 sig).
|
|
205
|
+
const spki = leaf.publicKey.export({ type: 'spki', format: 'der' });
|
|
206
|
+
const key = await crypto.subtle.importKey(
|
|
207
|
+
'spki',
|
|
208
|
+
spki as unknown as ArrayBuffer,
|
|
209
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
210
|
+
false,
|
|
211
|
+
['verify']
|
|
212
|
+
);
|
|
213
|
+
const signature = Buffer.from(signatureSeg, 'base64url');
|
|
214
|
+
const signingInput = Buffer.from(`${headerSeg}.${payloadSeg}`, 'ascii');
|
|
215
|
+
const valid = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signature, signingInput);
|
|
216
|
+
if (!valid) {
|
|
217
|
+
throw new AppleJwsVerifyError('SIGNATURE', 'ES256 signature does not verify against the leaf key');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 9. Return the decoded payload.
|
|
221
|
+
return payload as unknown as T;
|
|
222
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Native StoreKit 1 (legacy) receipt verification (Path C) — replaces
|
|
2
|
+
// `node-apple-receipt-verify` with native `fetch`. The package does ZERO crypto:
|
|
3
|
+
// it just POSTs the receipt to Apple's verifyReceipt endpoint and shape-maps the
|
|
4
|
+
// JSON (design §3C). Evicting it also drops `async` + the proxy-agents (its only
|
|
5
|
+
// importers). Feature-PRESERVING — output shape matches what client.ts consumes.
|
|
6
|
+
//
|
|
7
|
+
// NOTE the host is buy.itunes.apple.com (legacy verifyReceipt) — NOT the App
|
|
8
|
+
// Store Server API host (native-api.ts). Do not conflate the two.
|
|
9
|
+
|
|
10
|
+
const PRODUCTION_HOST = 'https://buy.itunes.apple.com/verifyReceipt';
|
|
11
|
+
const SANDBOX_HOST = 'https://sandbox.itunes.apple.com/verifyReceipt';
|
|
12
|
+
|
|
13
|
+
// Apple sends 21007 when a sandbox receipt hits production, 21002 for malformed —
|
|
14
|
+
// node-apple-receipt-verify retries sandbox on BOTH (lib/apple.js:246), so we do
|
|
15
|
+
// too: a sandbox receipt must not be surfaced as a hard failure.
|
|
16
|
+
const RETRY_SANDBOX_STATUSES = new Set([21007, 21002]);
|
|
17
|
+
|
|
18
|
+
/** Normalized receipt item — the shape client.ts:271-308 consumes (was produced by the old lib). */
|
|
19
|
+
export interface RawReceiptItem {
|
|
20
|
+
productId: string;
|
|
21
|
+
transactionId: string;
|
|
22
|
+
originalTransactionId?: string;
|
|
23
|
+
/** unix ms (number, never the seconds form or a string) */
|
|
24
|
+
purchaseDate?: number;
|
|
25
|
+
/** unix ms (number) */
|
|
26
|
+
expirationDate?: number;
|
|
27
|
+
bundleId?: string;
|
|
28
|
+
webOrderLineItemId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface AppleReceiptResponse {
|
|
32
|
+
status: number;
|
|
33
|
+
receipt?: { bundle_id?: string; in_app?: AppleRawItem[] };
|
|
34
|
+
latest_receipt_info?: AppleRawItem[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface AppleRawItem {
|
|
38
|
+
product_id?: string;
|
|
39
|
+
transaction_id?: string;
|
|
40
|
+
original_transaction_id?: string;
|
|
41
|
+
purchase_date_ms?: string | number;
|
|
42
|
+
expires_date_ms?: string | number;
|
|
43
|
+
web_order_line_item_id?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toMs(v: string | number | undefined): number | undefined {
|
|
47
|
+
if (v === undefined || v === null || v === '') return undefined;
|
|
48
|
+
const n = Number(v);
|
|
49
|
+
return Number.isNaN(n) ? undefined : n;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function postReceipt(host: string, body: object): Promise<AppleReceiptResponse> {
|
|
53
|
+
const response = await fetch(host, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify(body),
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new Error(`AppStore verifyReceipt: HTTP ${response.status}`);
|
|
60
|
+
}
|
|
61
|
+
return (await response.json()) as AppleReceiptResponse;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Verify a StoreKit 1 base64 receipt against Apple's verifyReceipt endpoint and
|
|
66
|
+
* return the normalized items. Tries production first; on a sandbox/malformed
|
|
67
|
+
* status (21007/21002) retries the sandbox host (parity). Throws on any other
|
|
68
|
+
* non-zero status with the code in the message — but never the shared secret.
|
|
69
|
+
*/
|
|
70
|
+
export async function verifyAppleReceiptNative({
|
|
71
|
+
receipt,
|
|
72
|
+
sharedSecret,
|
|
73
|
+
}: {
|
|
74
|
+
receipt: string;
|
|
75
|
+
sharedSecret: string;
|
|
76
|
+
}): Promise<RawReceiptItem[]> {
|
|
77
|
+
const body = {
|
|
78
|
+
'receipt-data': receipt,
|
|
79
|
+
password: sharedSecret,
|
|
80
|
+
// Deliberate, result-equivalent: client.ts reduces to the latest-expiry item
|
|
81
|
+
// anyway, so trimming old transactions here changes nothing downstream.
|
|
82
|
+
'exclude-old-transactions': true,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let data = await postReceipt(PRODUCTION_HOST, body);
|
|
86
|
+
if (RETRY_SANDBOX_STATUSES.has(data.status)) {
|
|
87
|
+
data = await postReceipt(SANDBOX_HOST, body);
|
|
88
|
+
}
|
|
89
|
+
if (data.status !== 0) {
|
|
90
|
+
// Status only — never echo the shared secret or the receipt blob.
|
|
91
|
+
throw new Error(`AppStore verifyReceipt: Apple returned status ${data.status}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const rawItems = data.latest_receipt_info ?? data.receipt?.in_app ?? [];
|
|
95
|
+
const bundleId = data.receipt?.bundle_id;
|
|
96
|
+
return rawItems.map((item) => ({
|
|
97
|
+
productId: item.product_id ?? '',
|
|
98
|
+
transactionId: item.transaction_id ?? '',
|
|
99
|
+
originalTransactionId: item.original_transaction_id,
|
|
100
|
+
purchaseDate: toMs(item.purchase_date_ms),
|
|
101
|
+
expirationDate: toMs(item.expires_date_ms),
|
|
102
|
+
bundleId,
|
|
103
|
+
webOrderLineItemId: item.web_order_line_item_id,
|
|
104
|
+
}));
|
|
105
|
+
}
|