payment-kit 1.29.13 → 1.29.14
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/libs/constants.ts +12 -0
- package/api/src/queues/notification.ts +1 -1
- package/api/src/routes/connect/shared.ts +20 -2
- package/api/src/routes/hono/checkout-sessions.ts +2 -2
- package/api/src/store/models/customer.ts +2 -2
- package/api/tests/integrations/app-store/fixtures/make-chain.spec.ts +21 -9
- package/api/tests/integrations/app-store/fixtures/make-chain.ts +167 -199
- package/api/tests/integrations/app-store/native-jws.spec.ts +24 -24
- package/api/tests/routes/connect/collect-batch-limit.spec.ts +82 -0
- package/blocklet.yml +1 -1
- package/package.json +8 -6
|
@@ -3,3 +3,15 @@ export const EVM_CHAIN_TYPES = ['ethereum', 'base'];
|
|
|
3
3
|
export const CHARGE_SUPPORTED_CHAIN_TYPES = ['arcblock', 'ethereum', 'base'];
|
|
4
4
|
|
|
5
5
|
export const VAULT_BUFFER_THRESHOLD = 10;
|
|
6
|
+
|
|
7
|
+
// Conservative cap on the number of past-due invoice ids embedded in a single
|
|
8
|
+
// arcblock batch-collect transaction's on-chain metadata (see getTxMetadata()
|
|
9
|
+
// in libs/util.ts, consumed by routes/connect/collect-batch.ts). Each invoice
|
|
10
|
+
// id is up to 30 chars (Invoice.GENESIS_ATTRIBUTES.id is STRING(30)), so at
|
|
11
|
+
// this cap the `invoices` JSON array alone stays in the low single-digit KB.
|
|
12
|
+
// We could not find a documented/exact byte-size limit for arcblock chain tx
|
|
13
|
+
// data in this repo or in @ocap/client, so this is a safety margin chosen to
|
|
14
|
+
// stay well clear of the "tx size exceeds chain limit" failure reported in
|
|
15
|
+
// https://github.com/blocklet/payment-kit/issues/1128, not a value verified
|
|
16
|
+
// against the protocol's exact threshold.
|
|
17
|
+
export const MAX_BATCH_COLLECT_INVOICES = 200;
|
|
@@ -45,7 +45,7 @@ import { PriceQuote } from '../../store/models/price-quote';
|
|
|
45
45
|
import { SetupIntent } from '../../store/models/setup-intent';
|
|
46
46
|
import { Subscription } from '../../store/models/subscription';
|
|
47
47
|
import { ensureInvoiceAndItems } from '../../libs/invoice';
|
|
48
|
-
import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../../libs/constants';
|
|
48
|
+
import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES, MAX_BATCH_COLLECT_INVOICES } from '../../libs/constants';
|
|
49
49
|
import { returnStakeQueue } from '../../queues/subscription';
|
|
50
50
|
|
|
51
51
|
type Result = {
|
|
@@ -1511,12 +1511,30 @@ export async function ensureSubscriptionForCollectBatch(
|
|
|
1511
1511
|
throw new Error(`Payment method not found for currency ${currencyId}`);
|
|
1512
1512
|
}
|
|
1513
1513
|
|
|
1514
|
+
const invoices = detail[currencyId] || [];
|
|
1515
|
+
|
|
1516
|
+
// arcblock batch-collect embeds the full `invoices` id list into the on-chain tx
|
|
1517
|
+
// metadata (see getTxMetadata() usage in routes/connect/collect-batch.ts). That
|
|
1518
|
+
// JSON blob has no length limit of its own, so an unbounded invoice count can
|
|
1519
|
+
// grow the tx data past the chain's tx size limit (issue #1128). Fail fast with a
|
|
1520
|
+
// clear error instead of silently truncating the list — the invoices field is
|
|
1521
|
+
// display-only on chain (payment reconciliation re-queries this function from the
|
|
1522
|
+
// DB, it never decodes the invoice list back out of the tx), so truncating it
|
|
1523
|
+
// would be safe for reconciliation, but it would also make the on-chain memo
|
|
1524
|
+
// permanently misleading/incomplete. Rejecting the request keeps the ledger
|
|
1525
|
+
// trustworthy and asks the customer/admin to collect in smaller batches instead.
|
|
1526
|
+
if (paymentMethod.type === 'arcblock' && invoices.length > MAX_BATCH_COLLECT_INVOICES) {
|
|
1527
|
+
throw new Error(
|
|
1528
|
+
`Too many past due invoices to batch collect in a single on-chain transaction (${invoices.length} invoices, limit is ${MAX_BATCH_COLLECT_INVOICES}). Please pay a smaller subset of invoices, or contact support to collect the remainder separately.`
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1514
1532
|
return {
|
|
1515
1533
|
subscription,
|
|
1516
1534
|
paymentCurrency: paymentCurrency as PaymentCurrency,
|
|
1517
1535
|
paymentMethod: paymentMethod as PaymentMethod,
|
|
1518
1536
|
amount: summary[currencyId],
|
|
1519
|
-
invoices
|
|
1537
|
+
invoices,
|
|
1520
1538
|
};
|
|
1521
1539
|
}
|
|
1522
1540
|
|
|
@@ -2922,7 +2922,7 @@ app.put('/:id/submit', user, ensureCheckoutSessionOpen, async (c) => {
|
|
|
2922
2922
|
}
|
|
2923
2923
|
|
|
2924
2924
|
// check if customer can make new purchase
|
|
2925
|
-
const canMakeNewPurchase = await customer.canMakeNewPurchase(checkoutSession.invoice_id);
|
|
2925
|
+
const canMakeNewPurchase = await customer.canMakeNewPurchase(checkoutSession.invoice_id, checkoutSession.livemode);
|
|
2926
2926
|
if (!canMakeNewPurchase) {
|
|
2927
2927
|
return c.json(
|
|
2928
2928
|
{
|
|
@@ -3621,7 +3621,7 @@ app.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async (c
|
|
|
3621
3621
|
}
|
|
3622
3622
|
}
|
|
3623
3623
|
|
|
3624
|
-
const canMakeNewPurchase = await customer.canMakeNewPurchase(checkoutSession.invoice_id);
|
|
3624
|
+
const canMakeNewPurchase = await customer.canMakeNewPurchase(checkoutSession.invoice_id, checkoutSession.livemode);
|
|
3625
3625
|
if (!canMakeNewPurchase) {
|
|
3626
3626
|
return c.json(
|
|
3627
3627
|
{
|
|
@@ -212,10 +212,10 @@ export class Customer extends TenantModel<InferAttributes<Customer>, InferCreati
|
|
|
212
212
|
return { paid, due, refunded };
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
public async canMakeNewPurchase(excludedInvoiceId: string = '') {
|
|
215
|
+
public async canMakeNewPurchase(excludedInvoiceId: string = '', livemode?: boolean) {
|
|
216
216
|
const { Invoice } = this.sequelize.models;
|
|
217
217
|
// @ts-ignore
|
|
218
|
-
const [summary] = await Invoice!.getUncollectibleAmount({ customerId: this.id, excludedInvoiceId });
|
|
218
|
+
const [summary] = await Invoice!.getUncollectibleAmount({ customerId: this.id, excludedInvoiceId, livemode });
|
|
219
219
|
return Object.entries(summary).every(([, amount]) => new BN(amount as string).lte(new BN(0)));
|
|
220
220
|
}
|
|
221
221
|
|
|
@@ -6,7 +6,7 @@ import { X509Certificate } from 'node:crypto';
|
|
|
6
6
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
|
|
9
|
-
import { APPLE_INT_OID, APPLE_LEAF_OID, buildJws, makeChain } from './make-chain';
|
|
9
|
+
import { APPLE_INT_OID, APPLE_LEAF_OID, buildJws, Chain, makeChain } from './make-chain';
|
|
10
10
|
|
|
11
11
|
// Full DER OID tag (mirrors native-asn1.ts, see 0.3) — the strict form.
|
|
12
12
|
function oidToDerTag(oid: string): Buffer {
|
|
@@ -28,7 +28,10 @@ function oidToDerTag(oid: string): Buffer {
|
|
|
28
28
|
const certOf = (b64: string): X509Certificate => new X509Certificate(Buffer.from(b64, 'base64'));
|
|
29
29
|
|
|
30
30
|
describe('make-chain — happy path', () => {
|
|
31
|
-
|
|
31
|
+
let chain: Chain;
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
chain = await makeChain({ signedDate: 1_781_521_334_379 });
|
|
34
|
+
});
|
|
32
35
|
|
|
33
36
|
it('leaf verifies against intermediate, intermediate against root', () => {
|
|
34
37
|
expect(chain.certs.leaf.verify(chain.certs.intermediate.publicKey)).toBe(true);
|
|
@@ -59,7 +62,10 @@ describe('make-chain — happy path', () => {
|
|
|
59
62
|
});
|
|
60
63
|
|
|
61
64
|
describe('make-chain — security negative controls', () => {
|
|
62
|
-
|
|
65
|
+
let chain: Chain;
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
chain = await makeChain();
|
|
68
|
+
});
|
|
63
69
|
|
|
64
70
|
it('cross-signed leaf fails leaf.verify(intermediate.publicKey) but keeps issuer/subject equality', () => {
|
|
65
71
|
const cross = certOf(chain.crossSignedLeaf);
|
|
@@ -72,14 +78,17 @@ describe('make-chain — security negative controls', () => {
|
|
|
72
78
|
expect(Buffer.from(chain.certs.leaf.raw).includes(oidToDerTag(APPLE_INT_OID))).toBe(false);
|
|
73
79
|
});
|
|
74
80
|
|
|
75
|
-
it('omits the leaf OID on demand (leafOid: false)', () => {
|
|
76
|
-
const noOid = makeChain({ leafOid: false });
|
|
81
|
+
it('omits the leaf OID on demand (leafOid: false)', async () => {
|
|
82
|
+
const noOid = await makeChain({ leafOid: false });
|
|
77
83
|
expect(Buffer.from(noOid.certs.leaf.raw).includes(oidToDerTag(APPLE_LEAF_OID))).toBe(false);
|
|
78
84
|
});
|
|
79
85
|
});
|
|
80
86
|
|
|
81
87
|
describe('make-chain — data damage / round-trip', () => {
|
|
82
|
-
|
|
88
|
+
let chain: Chain;
|
|
89
|
+
beforeAll(async () => {
|
|
90
|
+
chain = await makeChain();
|
|
91
|
+
});
|
|
83
92
|
|
|
84
93
|
it('decode(x5c[i]) byte-equals the DER we encoded', () => {
|
|
85
94
|
expect(Buffer.from(chain.x5c[0]!, 'base64').equals(Buffer.from(chain.certs.leaf.raw))).toBe(true);
|
|
@@ -96,9 +105,9 @@ describe('make-chain — data damage / round-trip', () => {
|
|
|
96
105
|
});
|
|
97
106
|
|
|
98
107
|
describe('make-chain — validity window pinning', () => {
|
|
99
|
-
it('pins notBefore/notAfter relative to signedDate', () => {
|
|
108
|
+
it('pins notBefore/notAfter relative to signedDate', async () => {
|
|
100
109
|
const signedDate = 1_700_000_000_000;
|
|
101
|
-
const chain = makeChain({
|
|
110
|
+
const chain = await makeChain({
|
|
102
111
|
signedDate,
|
|
103
112
|
certValidFrom: new Date(signedDate - 86_400_000),
|
|
104
113
|
certValidTo: new Date(signedDate + 86_400_000),
|
|
@@ -136,7 +145,10 @@ describe('make-chain — data leak (committed test keys never reach src/)', () =
|
|
|
136
145
|
});
|
|
137
146
|
|
|
138
147
|
describe('make-chain — buildJws crafts variants', () => {
|
|
139
|
-
|
|
148
|
+
let chain: Chain;
|
|
149
|
+
beforeAll(async () => {
|
|
150
|
+
chain = await makeChain();
|
|
151
|
+
});
|
|
140
152
|
|
|
141
153
|
it('re-signs a custom header/payload with the leaf key', () => {
|
|
142
154
|
const { jws, p1363Sig } = buildJws({ alg: 'ES256', x5c: chain.x5c }, { foo: 'bar' }, chain.keys.leafKeyPem);
|
|
@@ -24,19 +24,27 @@
|
|
|
24
24
|
* Apple OID, ES256 P1363 sig over `header.payload`) is identical.
|
|
25
25
|
*
|
|
26
26
|
* Determinism: the EC P-256 signing keys are COMMITTED under `fixtures/keys/`
|
|
27
|
-
* (synthetic, test-only) and the certs are
|
|
27
|
+
* (synthetic, test-only) and the certs are minted from them at runtime with
|
|
28
28
|
* pinned validity windows — so golden-vector VALUE assertions are stable and
|
|
29
29
|
* the certs never expire. Pass `freshKeys: true` to generate unique keys for a
|
|
30
30
|
* single call (e.g. to make two chains demonstrably distinct). The committed
|
|
31
31
|
* keys are test-only and MUST never be imported by `src/` — a data-leak spec
|
|
32
32
|
* (`make-chain.spec.ts`) guards that invariant. The committed REAL Apple
|
|
33
33
|
* fixture (`real-sandbox.jws`) is the golden vector for real-chain field values.
|
|
34
|
+
*
|
|
35
|
+
* Certs are minted in-process with `@peculiar/x509` (no `openssl` shell-out),
|
|
36
|
+
* so the fixtures do not depend on the host toolchain's OpenSSL version.
|
|
34
37
|
*/
|
|
35
|
-
|
|
36
|
-
import
|
|
37
|
-
import {
|
|
38
|
-
import
|
|
38
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
39
|
+
import 'reflect-metadata';
|
|
40
|
+
import { X509Certificate, createPrivateKey, createPublicKey, sign as nodeSign, webcrypto } from 'node:crypto';
|
|
41
|
+
import { readFileSync } from 'node:fs';
|
|
39
42
|
import path from 'node:path';
|
|
43
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
44
|
+
import * as x509 from '@peculiar/x509';
|
|
45
|
+
|
|
46
|
+
x509.cryptoProvider.set(webcrypto as unknown as Crypto);
|
|
47
|
+
const CERT_ALG = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
|
|
40
48
|
|
|
41
49
|
export const APPLE_LEAF_OID = '1.2.840.113635.100.6.11.1';
|
|
42
50
|
export const APPLE_INT_OID = '1.2.840.113635.100.6.2.1';
|
|
@@ -92,23 +100,14 @@ export interface Chain {
|
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
const SUBJ = {
|
|
95
|
-
root: '
|
|
96
|
-
int: '
|
|
97
|
-
leaf: '
|
|
103
|
+
root: 'CN=Test Apple Root CA, O=did-pay-test',
|
|
104
|
+
int: 'CN=Test Apple WWDR Intermediate, O=did-pay-test',
|
|
105
|
+
leaf: 'CN=Test Apple Leaf, O=did-pay-test',
|
|
98
106
|
};
|
|
99
107
|
|
|
100
108
|
/** Committed synthetic signing keys (test-only, never imported by src/). */
|
|
101
109
|
const KEYS_DIR = path.join(__dirname, 'keys');
|
|
102
110
|
|
|
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
111
|
/** Sign `signingInput` (ASCII) with an EC P-256 key, returning a raw 64-byte P1363 sig. */
|
|
113
112
|
export function signES256(signingInput: string, privateKeyPem: string): Buffer {
|
|
114
113
|
return nodeSign('sha256', Buffer.from(signingInput, 'ascii'), { key: privateKeyPem, dsaEncoding: 'ieee-p1363' });
|
|
@@ -127,200 +126,169 @@ export function buildJws(
|
|
|
127
126
|
return { jws: `${signingInput}.${p1363Sig.toString('base64url')}`, signingInput, p1363Sig };
|
|
128
127
|
}
|
|
129
128
|
|
|
130
|
-
|
|
129
|
+
/** A committed (or fresh) EC P-256 key as a WebCrypto pair + its PEM (for JWS signing / return). */
|
|
130
|
+
async function loadKeyPair(committedStem: string, fresh: boolean): Promise<{ pair: CryptoKeyPair; pem: string }> {
|
|
131
|
+
if (fresh) {
|
|
132
|
+
const pair = (await webcrypto.subtle.generateKey(CERT_ALG, true, ['sign', 'verify'])) as CryptoKeyPair;
|
|
133
|
+
const der = Buffer.from(await webcrypto.subtle.exportKey('pkcs8', pair.privateKey));
|
|
134
|
+
const pem = createPrivateKey({ key: der, format: 'der', type: 'pkcs8' }).export({
|
|
135
|
+
type: 'sec1',
|
|
136
|
+
format: 'pem',
|
|
137
|
+
}) as string;
|
|
138
|
+
return { pair, pem };
|
|
139
|
+
}
|
|
140
|
+
const pem = readFileSync(path.join(KEYS_DIR, `${committedStem}.key.pem`), 'utf8');
|
|
141
|
+
const nodeKey = createPrivateKey(pem);
|
|
142
|
+
const privateKey = await webcrypto.subtle.importKey(
|
|
143
|
+
'pkcs8',
|
|
144
|
+
nodeKey.export({ type: 'pkcs8', format: 'der' }),
|
|
145
|
+
CERT_ALG,
|
|
146
|
+
true,
|
|
147
|
+
['sign']
|
|
148
|
+
);
|
|
149
|
+
const publicKey = await webcrypto.subtle.importKey(
|
|
150
|
+
'spki',
|
|
151
|
+
createPublicKey(nodeKey).export({ type: 'spki', format: 'der' }),
|
|
152
|
+
CERT_ALG,
|
|
153
|
+
true,
|
|
154
|
+
['verify']
|
|
155
|
+
);
|
|
156
|
+
return { pair: { privateKey, publicKey }, pem };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Apple marker OID carried as a critical=false extension whose value is ASN.1 NULL (`05 00`). */
|
|
160
|
+
const appleOidExt = (oid: string): x509.Extension => new x509.Extension(oid, false, new Uint8Array([0x05, 0x00]));
|
|
161
|
+
|
|
162
|
+
export async function makeChain(opts: MakeChainOptions = {}): Promise<Chain> {
|
|
131
163
|
const signedDate = opts.signedDate ?? Date.now();
|
|
132
164
|
const validFrom = opts.certValidFrom ?? new Date(signedDate - ONE_DAY_MS);
|
|
133
165
|
const validTo = opts.certValidTo ?? new Date(signedDate + TEN_YEARS_MS);
|
|
134
|
-
|
|
135
|
-
const windowFor = (cert: 'leaf' | 'intermediate' | 'root'): { nb: string; na: string } => {
|
|
166
|
+
const windowFor = (cert: 'leaf' | 'intermediate' | 'root'): { nb: Date; na: Date } => {
|
|
136
167
|
const o = opts.certWindows?.[cert];
|
|
137
|
-
return { nb:
|
|
168
|
+
return { nb: o?.from ?? validFrom, na: o?.to ?? validTo };
|
|
138
169
|
};
|
|
170
|
+
const rootW = windowFor('root');
|
|
171
|
+
const intW = windowFor('intermediate');
|
|
172
|
+
const leafW = windowFor('leaf');
|
|
139
173
|
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
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
|
-
};
|
|
174
|
+
const fresh = !!opts.freshKeys;
|
|
175
|
+
const root = await loadKeyPair('root', fresh);
|
|
176
|
+
const int = await loadKeyPair('int', fresh);
|
|
177
|
+
const leaf = await loadKeyPair('leaf', fresh);
|
|
178
|
+
const wrongInt = await loadKeyPair('wrongint', fresh);
|
|
155
179
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const leafW = windowFor('leaf');
|
|
180
|
+
const caUsage = (): x509.Extension =>
|
|
181
|
+
// eslint-disable-next-line no-bitwise
|
|
182
|
+
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true);
|
|
160
183
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
184
|
+
const mk = (o: {
|
|
185
|
+
serial: string;
|
|
186
|
+
subject: string;
|
|
187
|
+
issuer: string;
|
|
188
|
+
nb: Date;
|
|
189
|
+
na: Date;
|
|
190
|
+
pub: CryptoKey;
|
|
191
|
+
sign: CryptoKey;
|
|
192
|
+
exts: x509.Extension[];
|
|
193
|
+
}): Promise<x509.X509Certificate> =>
|
|
194
|
+
x509.X509CertificateGenerator.create({
|
|
195
|
+
serialNumber: o.serial,
|
|
196
|
+
subject: o.subject,
|
|
197
|
+
issuer: o.issuer,
|
|
198
|
+
notBefore: o.nb,
|
|
199
|
+
notAfter: o.na,
|
|
200
|
+
signingAlgorithm: CERT_ALG,
|
|
201
|
+
publicKey: o.pub,
|
|
202
|
+
signingKey: o.sign,
|
|
203
|
+
extensions: o.exts,
|
|
204
|
+
});
|
|
182
205
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
]);
|
|
206
|
+
// root — self-signed CA
|
|
207
|
+
const rootX = await mk({
|
|
208
|
+
serial: '01',
|
|
209
|
+
subject: SUBJ.root,
|
|
210
|
+
issuer: SUBJ.root,
|
|
211
|
+
nb: rootW.nb,
|
|
212
|
+
na: rootW.na,
|
|
213
|
+
pub: root.pair.publicKey,
|
|
214
|
+
sign: root.pair.privateKey,
|
|
215
|
+
exts: [new x509.BasicConstraintsExtension(true, undefined, true), caUsage()],
|
|
216
|
+
});
|
|
210
217
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
]);
|
|
218
|
+
// intermediate — CA + Apple intermediate OID, signed by root
|
|
219
|
+
const intExts: x509.Extension[] = [new x509.BasicConstraintsExtension(true, undefined, true), caUsage()];
|
|
220
|
+
if (opts.intOid !== false) intExts.push(appleOidExt(APPLE_INT_OID));
|
|
221
|
+
const intX = await mk({
|
|
222
|
+
serial: '02',
|
|
223
|
+
subject: SUBJ.int,
|
|
224
|
+
issuer: SUBJ.root,
|
|
225
|
+
nb: intW.nb,
|
|
226
|
+
na: intW.na,
|
|
227
|
+
pub: int.pair.publicKey,
|
|
228
|
+
sign: root.pair.privateKey,
|
|
229
|
+
exts: intExts,
|
|
230
|
+
});
|
|
238
231
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
]);
|
|
232
|
+
// leaf — Apple leaf OID, signed by intermediate
|
|
233
|
+
const leafExts: x509.Extension[] = [new x509.BasicConstraintsExtension(false, undefined, true)];
|
|
234
|
+
if (opts.leafOid !== false) leafExts.push(appleOidExt(APPLE_LEAF_OID));
|
|
235
|
+
const leafX = await mk({
|
|
236
|
+
serial: '03',
|
|
237
|
+
subject: SUBJ.leaf,
|
|
238
|
+
issuer: SUBJ.int,
|
|
239
|
+
nb: leafW.nb,
|
|
240
|
+
na: leafW.na,
|
|
241
|
+
pub: leaf.pair.publicKey,
|
|
242
|
+
sign: int.pair.privateKey,
|
|
243
|
+
exts: leafExts,
|
|
244
|
+
});
|
|
283
245
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
246
|
+
// cross-signed leaf — the SAME leaf key + subject, but issued (signed) by a
|
|
247
|
+
// different key under the intermediate's DN (`wrongint`). It keeps
|
|
248
|
+
// issuer/subject equality up the chain yet fails leaf.verify(int.publicKey).
|
|
249
|
+
const crossLeafX = await mk({
|
|
250
|
+
serial: '05',
|
|
251
|
+
subject: SUBJ.leaf,
|
|
252
|
+
issuer: SUBJ.int,
|
|
253
|
+
nb: leafW.nb,
|
|
254
|
+
na: leafW.na,
|
|
255
|
+
pub: leaf.pair.publicKey,
|
|
256
|
+
sign: wrongInt.pair.privateKey,
|
|
257
|
+
exts: leafExts,
|
|
258
|
+
});
|
|
288
259
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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);
|
|
260
|
+
const toNode = (c: x509.X509Certificate): X509Certificate => new X509Certificate(Buffer.from(c.rawData));
|
|
261
|
+
const rootCert = toNode(rootX);
|
|
262
|
+
const intCert = toNode(intX);
|
|
263
|
+
const leafCert = toNode(leafX);
|
|
264
|
+
const crossLeafCert = toNode(crossLeafX);
|
|
306
265
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
266
|
+
const x5c = [
|
|
267
|
+
Buffer.from(leafX.rawData).toString('base64'),
|
|
268
|
+
Buffer.from(intX.rawData).toString('base64'),
|
|
269
|
+
Buffer.from(rootX.rawData).toString('base64'),
|
|
270
|
+
];
|
|
271
|
+
const header = { alg: opts.alg ?? 'ES256', x5c };
|
|
272
|
+
const payload: Record<string, unknown> = {
|
|
273
|
+
transactionId: 'txn_synth_1',
|
|
274
|
+
originalTransactionId: 'orig_synth_1',
|
|
275
|
+
productId: 'sub_synthetic',
|
|
276
|
+
bundleId: 'com.example.app',
|
|
277
|
+
environment: 'Sandbox',
|
|
278
|
+
signedDate,
|
|
279
|
+
...opts.payload,
|
|
280
|
+
};
|
|
281
|
+
const { jws, signingInput, p1363Sig } = buildJws(header, payload, leaf.pem);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
x5c,
|
|
285
|
+
jws,
|
|
286
|
+
signingInput,
|
|
287
|
+
p1363Sig,
|
|
288
|
+
crossSignedLeaf: Buffer.from(crossLeafCert.raw).toString('base64'),
|
|
289
|
+
keys: { rootKeyPem: root.pem, intKeyPem: int.pem, leafKeyPem: leaf.pem, wrongIntKeyPem: wrongInt.pem },
|
|
290
|
+
certs: { leaf: leafCert, intermediate: intCert, root: rootCert },
|
|
291
|
+
payload,
|
|
292
|
+
header,
|
|
293
|
+
};
|
|
326
294
|
}
|
|
@@ -15,8 +15,8 @@ import type { JwsTransactionPayload } from '../../../src/integrations/app-store/
|
|
|
15
15
|
const FIXED_SIGNED_DATE = 1_700_000_000_000;
|
|
16
16
|
|
|
17
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 });
|
|
18
|
+
async function freshChainCtx(opts: Parameters<typeof makeChain>[0] = {}) {
|
|
19
|
+
const chain = await makeChain({ signedDate: FIXED_SIGNED_DATE, ...opts });
|
|
20
20
|
const trustedRoots = [Buffer.from(chain.x5c[2]!, 'base64')];
|
|
21
21
|
return { chain, trustedRoots };
|
|
22
22
|
}
|
|
@@ -27,7 +27,7 @@ async function expectCode(promise: Promise<unknown>, code: string): Promise<void
|
|
|
27
27
|
|
|
28
28
|
describe('verifyAppleJws — happy path', () => {
|
|
29
29
|
it('decodes a synthetic chain when its root is trusted; fields match the signed payload', async () => {
|
|
30
|
-
const { chain, trustedRoots } = freshChainCtx({
|
|
30
|
+
const { chain, trustedRoots } = await freshChainCtx({
|
|
31
31
|
payload: { productId: 'sub_happy', expiresDate: 1_900_000_000_000, appAccountToken: 'uuid-happy' },
|
|
32
32
|
});
|
|
33
33
|
const decoded = await verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots });
|
|
@@ -56,7 +56,7 @@ describe('verifyAppleJws — bad input', () => {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
it('rejects x5c.length !== 3 with INVALID_CHAIN_LENGTH', async () => {
|
|
59
|
-
const { chain, trustedRoots } = freshChainCtx();
|
|
59
|
+
const { chain, trustedRoots } = await freshChainCtx();
|
|
60
60
|
const { jws } = buildJws({ alg: 'ES256', x5c: chain.x5c.slice(0, 2) }, chain.payload, chain.keys.leafKeyPem);
|
|
61
61
|
await expectCode(verifyAppleJws(jws, { trustedRoots }), 'INVALID_CHAIN_LENGTH');
|
|
62
62
|
});
|
|
@@ -66,7 +66,7 @@ describe('verifyAppleJws — bad input', () => {
|
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
it('rejects a payload that is not valid JSON (after a valid chain)', async () => {
|
|
69
|
-
const { chain, trustedRoots } = freshChainCtx();
|
|
69
|
+
const { chain, trustedRoots } = await freshChainCtx();
|
|
70
70
|
const headerSeg = chain.jws.split('.')[0]!;
|
|
71
71
|
const badPayload = Buffer.from('not json at all').toString('base64url');
|
|
72
72
|
const jws = `${headerSeg}.${badPayload}.${chain.jws.split('.')[2]}`;
|
|
@@ -76,14 +76,14 @@ describe('verifyAppleJws — bad input', () => {
|
|
|
76
76
|
|
|
77
77
|
describe('verifyAppleJws — security', () => {
|
|
78
78
|
it('rejects a tampered payload (signature no longer matches)', async () => {
|
|
79
|
-
const { chain, trustedRoots } = freshChainCtx();
|
|
79
|
+
const { chain, trustedRoots } = await freshChainCtx();
|
|
80
80
|
const [h, , s] = chain.jws.split('.');
|
|
81
81
|
const tampered = Buffer.from(JSON.stringify({ ...chain.payload, productId: 'EVIL' })).toString('base64url');
|
|
82
82
|
await expectCode(verifyAppleJws(`${h}.${tampered}.${s}`, { trustedRoots }), 'SIGNATURE');
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
it('rejects a tampered signature', async () => {
|
|
86
|
-
const { chain, trustedRoots } = freshChainCtx();
|
|
86
|
+
const { chain, trustedRoots } = await freshChainCtx();
|
|
87
87
|
const [h, p] = chain.jws.split('.');
|
|
88
88
|
const sigBytes = Buffer.from(chain.jws.split('.')[2]!, 'base64url');
|
|
89
89
|
sigBytes[0] = sigBytes[0]! ^ 0xff;
|
|
@@ -91,37 +91,37 @@ describe('verifyAppleJws — security', () => {
|
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
it('rejects alg: "none" and alg: "RS256" (alg whitelist)', async () => {
|
|
94
|
-
const none = freshChainCtx({ alg: 'none' });
|
|
94
|
+
const none = await freshChainCtx({ alg: 'none' });
|
|
95
95
|
await expectCode(verifyAppleJws(none.chain.jws, { trustedRoots: none.trustedRoots }), 'ALG');
|
|
96
|
-
const rs = freshChainCtx({ alg: 'RS256' });
|
|
96
|
+
const rs = await freshChainCtx({ alg: 'RS256' });
|
|
97
97
|
await expectCode(verifyAppleJws(rs.chain.jws, { trustedRoots: rs.trustedRoots }), 'ALG');
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
it('rejects when the presented root is NOT a trusted anchor', async () => {
|
|
101
|
-
const { chain } = freshChainCtx();
|
|
101
|
+
const { chain } = await freshChainCtx();
|
|
102
102
|
// default APPLE_ROOT_CERTS does NOT contain the synthetic root
|
|
103
103
|
await expectCode(verifyAppleJws(chain.jws), 'ANCHOR');
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
it('rejects a cross-signed leaf (chain break) before signature', async () => {
|
|
107
|
-
const { chain, trustedRoots } = freshChainCtx();
|
|
107
|
+
const { chain, trustedRoots } = await freshChainCtx();
|
|
108
108
|
const header = { alg: 'ES256', x5c: [chain.crossSignedLeaf, chain.x5c[1], chain.x5c[2]] };
|
|
109
109
|
const { jws } = buildJws(header, chain.payload, chain.keys.leafKeyPem);
|
|
110
110
|
await expectCode(verifyAppleJws(jws, { trustedRoots }), 'CHAIN');
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
it('rejects when the leaf is missing the Apple OID', async () => {
|
|
114
|
-
const { chain, trustedRoots } = freshChainCtx({ leafOid: false });
|
|
114
|
+
const { chain, trustedRoots } = await freshChainCtx({ leafOid: false });
|
|
115
115
|
await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'OID');
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
it('rejects when the intermediate is missing the Apple OID', async () => {
|
|
119
|
-
const { chain, trustedRoots } = freshChainCtx({ intOid: false });
|
|
119
|
+
const { chain, trustedRoots } = await freshChainCtx({ intOid: false });
|
|
120
120
|
await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'OID');
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
it('does not pollute Object.prototype from a hostile __proto__ payload', async () => {
|
|
124
|
-
const { chain, trustedRoots } = freshChainCtx();
|
|
124
|
+
const { chain, trustedRoots } = await freshChainCtx();
|
|
125
125
|
const headerSeg = chain.jws.split('.')[0]!;
|
|
126
126
|
// own __proto__ key via JSON.parse, then sign it so the chain is valid
|
|
127
127
|
const hostile = JSON.parse(
|
|
@@ -142,29 +142,29 @@ describe('verifyAppleJws — validity & clock skew (±60s, all 3 certs)', () =>
|
|
|
142
142
|
// boundary: reject if validTo < effectiveDate − 60s; pass at exactly −60s.
|
|
143
143
|
it('passes at the expiry boundary (effectiveDate = validTo + 60s) and rejects 1ms past it', async () => {
|
|
144
144
|
const validTo = new Date(FIXED_SIGNED_DATE);
|
|
145
|
-
const ctxPass = freshChainCtx({ certValidTo: validTo, signedDate: FIXED_SIGNED_DATE + 60_000 });
|
|
145
|
+
const ctxPass = await freshChainCtx({ certValidTo: validTo, signedDate: FIXED_SIGNED_DATE + 60_000 });
|
|
146
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 });
|
|
147
|
+
const ctxFail = await freshChainCtx({ certValidTo: validTo, signedDate: FIXED_SIGNED_DATE + 60_000 + 1 });
|
|
148
148
|
await expectCode(verifyAppleJws(ctxFail.chain.jws, { trustedRoots: ctxFail.trustedRoots }), 'CERT_EXPIRED');
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
it('passes at the not-yet-valid boundary (effectiveDate = validFrom − 60s) and rejects 1ms before it', async () => {
|
|
152
152
|
const validFrom = new Date(FIXED_SIGNED_DATE);
|
|
153
|
-
const ctxPass = freshChainCtx({ certValidFrom: validFrom, signedDate: FIXED_SIGNED_DATE - 60_000 });
|
|
153
|
+
const ctxPass = await freshChainCtx({ certValidFrom: validFrom, signedDate: FIXED_SIGNED_DATE - 60_000 });
|
|
154
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 });
|
|
155
|
+
const ctxFail = await freshChainCtx({ certValidFrom: validFrom, signedDate: FIXED_SIGNED_DATE - 60_000 - 1 });
|
|
156
156
|
await expectCode(verifyAppleJws(ctxFail.chain.jws, { trustedRoots: ctxFail.trustedRoots }), 'CERT_NOT_YET_VALID');
|
|
157
157
|
});
|
|
158
158
|
|
|
159
159
|
it('rejects when ONLY the intermediate is expired (proves int is checked, not just leaf)', async () => {
|
|
160
|
-
const { chain, trustedRoots } = freshChainCtx({
|
|
160
|
+
const { chain, trustedRoots } = await freshChainCtx({
|
|
161
161
|
certWindows: { intermediate: { to: new Date(FIXED_SIGNED_DATE - 3_600_000) } },
|
|
162
162
|
});
|
|
163
163
|
await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'CERT_EXPIRED');
|
|
164
164
|
});
|
|
165
165
|
|
|
166
166
|
it('rejects when ONLY the root is expired (proves root is checked)', async () => {
|
|
167
|
-
const { chain, trustedRoots } = freshChainCtx({
|
|
167
|
+
const { chain, trustedRoots } = await freshChainCtx({
|
|
168
168
|
certWindows: { root: { to: new Date(FIXED_SIGNED_DATE - 3_600_000) } },
|
|
169
169
|
});
|
|
170
170
|
await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'CERT_EXPIRED');
|
|
@@ -173,7 +173,7 @@ describe('verifyAppleJws — validity & clock skew (±60s, all 3 certs)', () =>
|
|
|
173
173
|
|
|
174
174
|
describe('verifyAppleJws — data loss (concurrency / statelessness)', () => {
|
|
175
175
|
it('resolves all concurrent verifications correctly (no shared mutable state)', async () => {
|
|
176
|
-
const { chain, trustedRoots } = freshChainCtx();
|
|
176
|
+
const { chain, trustedRoots } = await freshChainCtx();
|
|
177
177
|
const results = await Promise.all(
|
|
178
178
|
Array.from({ length: 12 }, () => verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots }))
|
|
179
179
|
);
|
|
@@ -183,7 +183,7 @@ describe('verifyAppleJws — data loss (concurrency / statelessness)', () => {
|
|
|
183
183
|
|
|
184
184
|
describe('verifyAppleJws — data damage (type/encoding fidelity)', () => {
|
|
185
185
|
it('keeps numbers as numbers and round-trips unicode byte-faithfully', async () => {
|
|
186
|
-
const { chain, trustedRoots } = freshChainCtx({
|
|
186
|
+
const { chain, trustedRoots } = await freshChainCtx({
|
|
187
187
|
payload: { productId: '产品_🎉', expiresDate: 1_888_888_888_888, appAccountToken: 'uuid-😀' },
|
|
188
188
|
});
|
|
189
189
|
const decoded = await verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots });
|
|
@@ -194,7 +194,7 @@ describe('verifyAppleJws — data damage (type/encoding fidelity)', () => {
|
|
|
194
194
|
});
|
|
195
195
|
|
|
196
196
|
it('leaves a missing expiresDate as undefined (never coerced to 0/null)', async () => {
|
|
197
|
-
const { chain, trustedRoots } = freshChainCtx(); // default payload has no expiresDate
|
|
197
|
+
const { chain, trustedRoots } = await freshChainCtx(); // default payload has no expiresDate
|
|
198
198
|
const decoded = await verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots });
|
|
199
199
|
expect('expiresDate' in decoded).toBe(false);
|
|
200
200
|
expect(decoded.expiresDate).toBeUndefined();
|
|
@@ -203,7 +203,7 @@ describe('verifyAppleJws — data damage (type/encoding fidelity)', () => {
|
|
|
203
203
|
|
|
204
204
|
describe('verifyAppleJws — data leak (errors name the failure, not secrets)', () => {
|
|
205
205
|
it('throws AppleJwsVerifyError with a code and never echoes the raw cert/JWS material', async () => {
|
|
206
|
-
const { chain } = freshChainCtx();
|
|
206
|
+
const { chain } = await freshChainCtx();
|
|
207
207
|
try {
|
|
208
208
|
await verifyAppleJws(chain.jws); // ANCHOR (synthetic root not trusted)
|
|
209
209
|
throw new Error('should have rejected');
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// #1128 — arcblock batch-collect embeds the FULL past-due invoice id list into
|
|
2
|
+
// the on-chain tx metadata (getTxMetadata() in libs/util.ts, consumed by
|
|
3
|
+
// routes/connect/collect-batch.ts). That JSON blob has no length limit of its
|
|
4
|
+
// own, so a customer/subscription with many uncollectible invoices could build
|
|
5
|
+
// a tx whose `data` field exceeds the chain's tx size limit and fails on-chain.
|
|
6
|
+
//
|
|
7
|
+
// ensureSubscriptionForCollectBatch() (routes/connect/shared.ts) is the single
|
|
8
|
+
// choke point used by collect-batch's claims.authPrincipal / onConnect / onAuth,
|
|
9
|
+
// so the size guard lives there: reject the request up front with a clear error
|
|
10
|
+
// instead of silently truncating the invoice list (which would make the
|
|
11
|
+
// on-chain memo permanently misleading) or letting an oversized tx reach the
|
|
12
|
+
// chain and fail there.
|
|
13
|
+
import { ensureSubscriptionForCollectBatch } from '../../../src/routes/connect/shared';
|
|
14
|
+
import { Invoice, PaymentCurrency, PaymentMethod, Subscription } from '../../../src/store/models';
|
|
15
|
+
import { MAX_BATCH_COLLECT_INVOICES } from '../../../src/libs/constants';
|
|
16
|
+
|
|
17
|
+
const SUBSCRIPTION_ID = 'sub_test_1128';
|
|
18
|
+
const CURRENCY_ID = 'cur_test_1128';
|
|
19
|
+
|
|
20
|
+
const buildInvoiceIds = (count: number) => Array.from({ length: count }, (_, i) => `in_test_${i}`);
|
|
21
|
+
|
|
22
|
+
function mockUncollectible({ invoiceCount, paymentMethodType }: { invoiceCount: number; paymentMethodType: string }) {
|
|
23
|
+
const invoiceIds = buildInvoiceIds(invoiceCount);
|
|
24
|
+
|
|
25
|
+
jest.spyOn(Subscription, 'findByPk').mockResolvedValue({
|
|
26
|
+
id: SUBSCRIPTION_ID,
|
|
27
|
+
customer_id: 'cus_test_1128',
|
|
28
|
+
} as any);
|
|
29
|
+
|
|
30
|
+
jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({
|
|
31
|
+
id: CURRENCY_ID,
|
|
32
|
+
payment_method_id: 'pm_test_1128',
|
|
33
|
+
} as any);
|
|
34
|
+
|
|
35
|
+
jest.spyOn(PaymentMethod, 'findByPk').mockResolvedValue({
|
|
36
|
+
id: 'pm_test_1128',
|
|
37
|
+
type: paymentMethodType,
|
|
38
|
+
} as any);
|
|
39
|
+
|
|
40
|
+
jest
|
|
41
|
+
.spyOn(Invoice, 'getUncollectibleAmount')
|
|
42
|
+
.mockResolvedValue([{ [CURRENCY_ID]: String(invoiceCount * 100) }, { [CURRENCY_ID]: invoiceIds }, []] as any);
|
|
43
|
+
|
|
44
|
+
return invoiceIds;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('ensureSubscriptionForCollectBatch — batch size guard (#1128)', () => {
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
jest.restoreAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('resolves normally when the arcblock invoice count is within the limit', async () => {
|
|
53
|
+
const invoiceIds = mockUncollectible({ invoiceCount: MAX_BATCH_COLLECT_INVOICES, paymentMethodType: 'arcblock' });
|
|
54
|
+
|
|
55
|
+
const result = await ensureSubscriptionForCollectBatch(SUBSCRIPTION_ID, CURRENCY_ID, undefined);
|
|
56
|
+
|
|
57
|
+
expect(result.invoices).toEqual(invoiceIds);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects with a clear error instead of building an oversized tx when the arcblock invoice count exceeds the limit', async () => {
|
|
61
|
+
mockUncollectible({ invoiceCount: MAX_BATCH_COLLECT_INVOICES + 1, paymentMethodType: 'arcblock' });
|
|
62
|
+
|
|
63
|
+
await expect(ensureSubscriptionForCollectBatch(SUBSCRIPTION_ID, CURRENCY_ID, undefined)).rejects.toThrow(
|
|
64
|
+
/Too many past due invoices.*on-chain transaction/
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not enforce the on-chain tx-size guard for non-arcblock (EVM) payment methods', async () => {
|
|
69
|
+
// EVM batch-collect (see collect-batch.ts onConnect) never embeds the
|
|
70
|
+
// invoices array into the signed payload — only `network` + the encoded
|
|
71
|
+
// transfer itx — so the on-chain size risk this guard protects against
|
|
72
|
+
// does not apply there.
|
|
73
|
+
const invoiceIds = mockUncollectible({
|
|
74
|
+
invoiceCount: MAX_BATCH_COLLECT_INVOICES + 1,
|
|
75
|
+
paymentMethodType: 'ethereum',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const result = await ensureSubscriptionForCollectBatch(SUBSCRIPTION_ID, CURRENCY_ID, undefined);
|
|
79
|
+
|
|
80
|
+
expect(result.invoices).toEqual(invoiceIds);
|
|
81
|
+
});
|
|
82
|
+
});
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.14",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"prelint": "npm run types",
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
"@blocklet/error": "^0.3.5",
|
|
61
61
|
"@blocklet/js-sdk": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
62
62
|
"@blocklet/logger": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
63
|
-
"@blocklet/payment-broker-client": "1.29.
|
|
64
|
-
"@blocklet/payment-react": "1.29.
|
|
65
|
-
"@blocklet/payment-vendor": "1.29.
|
|
63
|
+
"@blocklet/payment-broker-client": "1.29.14",
|
|
64
|
+
"@blocklet/payment-react": "1.29.14",
|
|
65
|
+
"@blocklet/payment-vendor": "1.29.14",
|
|
66
66
|
"@blocklet/sdk": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
67
67
|
"@blocklet/ui-react": "^3.5.4",
|
|
68
68
|
"@blocklet/uploader": "^0.15.4",
|
|
@@ -133,7 +133,8 @@
|
|
|
133
133
|
"devDependencies": {
|
|
134
134
|
"@abtnode/types": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
135
135
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
136
|
-
"@blocklet/payment-types": "1.29.
|
|
136
|
+
"@blocklet/payment-types": "1.29.14",
|
|
137
|
+
"@peculiar/x509": "^2.0.0",
|
|
137
138
|
"@types/connect": "^3.4.38",
|
|
138
139
|
"@types/debug": "^4.1.12",
|
|
139
140
|
"@types/dotenv-flow": "^3.3.3",
|
|
@@ -154,6 +155,7 @@
|
|
|
154
155
|
"npm-run-all": "^4.1.5",
|
|
155
156
|
"prettier": "^3.6.0",
|
|
156
157
|
"prettier-plugin-import-sort": "^0.0.7",
|
|
158
|
+
"reflect-metadata": "^0.2.2",
|
|
157
159
|
"rollup-plugin-visualizer": "^6.0.3",
|
|
158
160
|
"ts-jest": "^29.4.0",
|
|
159
161
|
"ts-node": "^10.9.2",
|
|
@@ -180,5 +182,5 @@
|
|
|
180
182
|
"parser": "typescript"
|
|
181
183
|
}
|
|
182
184
|
},
|
|
183
|
-
"gitHead": "
|
|
185
|
+
"gitHead": "ac4de50daf46f5ef2b4d58d1060610564929a10c"
|
|
184
186
|
}
|