1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324 | /**
* The artifact layer's hostile cases, against the committed dist/.
*
* test/vectors.test.mjs pins the WIRE — what the four artifacts look like and
* which tampered ones verify to null. This file pins the things a green vector
* suite still let through: checks that exist in the source but that no test
* could tell were gone, and the totality the `.d.ts` promises.
*
* Every test below was written against a specific surviving mutation. Where the
* shape of a test looks odd, the comment says which mutation it kills and why a
* more obvious test does not.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import {
CTX_MAX,
MAX_ARTIFACT_BYTES,
MAX_SETTLEMENT_BYTES,
MEMO_MAX,
expired,
issuanceShape,
orderShape,
payRequestShape,
payRequestUrl,
sanitizeCtx,
sanitizeMemo,
settlementShape,
verifyIssuanceReceipt,
verifyOrder,
verifyPayRequest,
verifySettlement,
} from "../dist/index.js";
import {
SEED_BANK,
SEED_PAYEE,
SEED_PAYER,
preimage,
signArtifact,
signOver,
verifyOver,
} from "./vectors/independent.mjs";
const fixture = async (name) =>
JSON.parse(await readFile(new URL(`./vectors/${name}`, import.meta.url), "utf8"));
const artifacts = await fixture("artifacts.json");
const paylink = await fixture("paylink.json");
const A = artifacts.artifacts;
const { bankPub } = artifacts.actors;
// ---- M4: the embedded order's signature is really checked -------------------
//
// verifySettlement checks TWO signatures: the bank's over the whole settlement,
// and the payer's over the order inside it. Removing the inner check used to
// change nothing any test could see, and the vector that claimed to cover it is
// vacuous: mutating `po.sig` also changes the bank's preimage (po sits inside
// it), so the OUTER check fails first and the inner one is never reached.
//
// The case that reaches it is a bank that co-signs an order whose payer
// signature is a well-formed, canonical, verifiable-looking base64url string
// and simply belongs to someone else. The bank's own signature is then
// perfectly valid over exactly those bytes — a real bank could produce this by
// accident with a broken verifier, or on purpose to launder a forgery — and the
// only thing standing between it and a folded balance is the inner check.
const unsignedOf = ({ bsig, ...rest }) => (void bsig, rest);
const unsignedWpo = ({ sig, ...rest }) => (void sig, rest);
const forgedInnerSignature = (sigOverWrongThing) => {
const po = { ...A.wpo.wire, sig: sigOverWrongThing };
// the bank signs THIS order, honestly, so the outer signature is valid
return signArtifact(
"wrc",
{ v: 1, t: "wrc", po, seq: "L-1", ts: A.wrc.wire.ts, bank: bankPub },
SEED_BANK,
);
};
test("a bank co-signing an order with a valid-shaped but WRONG payer signature is refused", async () => {
// the payee's signature over the payer's order: right length, right alphabet,
// right padding bits, wrong key
const wrongKey = signOver(SEED_PAYEE, preimage("wpo", unsignedWpo(A.wpo.wire)));
const laundered = forgedInnerSignature(wrongKey);
assert.notEqual(wrongKey, A.wpo.wire.sig, "the two signatures really differ");
assert.equal(settlementShape(laundered) !== null, true, "it is structurally a settlement");
// the OUTER signature is genuinely valid — so a rejection here can only come
// from the inner check, which is exactly what the vacuous vector could not say
assert.ok(
verifyOver(bankPub, laundered.bsig, preimage("wrc", unsignedOf(laundered))),
"the bank's own signature over this settlement is valid",
);
assert.equal(await verifySettlement(laundered, bankPub), null, "the embedded order does not verify");
assert.equal(await verifyOrder(laundered.po), null, "and standalone it does not either");
});
test("the same order re-signed by its real payer settles, so the test above is about the signature", async () => {
const honest = forgedInnerSignature(A.wpo.wire.sig);
assert.ok(await verifySettlement(honest, bankPub), "one changed field is the whole difference");
});
test("a payer signature over a DIFFERENT order of their own is refused too", async () => {
const other = { ...unsignedWpo(A.wpo.wire), amt: A.wpo.wire.amt + 1 };
const wrongMessage = signOver(SEED_PAYER, preimage("wpo", other));
assert.equal(await verifySettlement(forgedInnerSignature(wrongMessage), bankPub), null);
});
// ---- M18: exp must be strictly after ts -------------------------------------
//
// An order whose expiry is its own timestamp is dead the millisecond it is
// signed, and one whose expiry precedes it was never alive. Both used to be
// accepted by orderShape with the `>` relaxed to `>=`, and no test noticed.
const reSignedOrder = (patch) => {
const { sig, ...unsigned } = { ...A.wpo.wire, ...patch };
void sig;
return signArtifact("wpo", unsigned, SEED_PAYER);
};
for (const [name, exp] of [
["exp === ts", A.wpo.wire.ts],
["exp === ts - 1", A.wpo.wire.ts - 1],
["exp far before ts", A.wpo.wire.ts - 86400000],
]) {
test(`an order with ${name} is refused, however well signed`, async () => {
const po = reSignedOrder({ exp });
assert.equal(orderShape(po), null, name);
assert.equal(await verifyOrder(po), null, name);
// and it cannot be smuggled in inside a settlement either
const rc = signArtifact("wrc", { v: 1, t: "wrc", po, seq: "L-1", ts: A.wrc.wire.ts, bank: bankPub }, SEED_BANK);
assert.equal(await verifySettlement(rc, bankPub), null, name);
});
}
test("exp === ts + 1 is the first accepted expiry", async () => {
assert.ok(await verifyOrder(reSignedOrder({ exp: A.wpo.wire.ts + 1 })), "one live millisecond is alive");
});
// ---- M17: the serialized size ceiling ---------------------------------------
//
// The ceilings are checked over `JSON.stringify(raw)` BEFORE any field is
// looked at, and they do two jobs. The first is bounding the work done on
// hostile bytes: a 40 MB `memo` is rejected before `Array.from` is asked to
// expand it into 40 million code points, and only the ceiling makes that true —
// `text?` would reject it eventually, after doing the work.
//
// The second is the deterministically observable one: `byteLength` reports a
// number larger than any ceiling when the value WILL NOT SERIALIZE AT ALL. An
// artifact that cannot be turned into JSON cannot be transmitted, cannot be
// canon()'d by a peer and cannot be what anybody signed, so it is rejected on
// sight — even when all thirteen of its own fields would pass. Removing the
// ceiling accepts it, which is what makes this the test that kills the mutation.
test("an artifact that will not serialize is refused even when every field is valid", () => {
const hostile = Object.create({
toJSON() {
throw new Error("no bytes for you");
},
});
Object.assign(hostile, A.wpo.wire);
assert.equal(Object.keys(hostile).length, 13, "the own keys are exactly the wire's");
assert.throws(() => JSON.stringify(hostile), "and it genuinely cannot be serialized");
assert.equal(orderShape(hostile), null, "so it is not an order, whatever its fields say");
const settlement = Object.create({ toJSON() { throw new Error("no"); } });
Object.assign(settlement, A.wrc.wire);
assert.equal(settlementShape(settlement), null);
const issuance = Object.create({ toJSON() { throw new Error("no"); } });
Object.assign(issuance, A.wri.wire);
assert.equal(issuanceShape(issuance), null);
});
test("the ceiling rejects an oversized field before expanding it", () => {
// 40 MB of memo. With the ceiling this returns immediately; without it,
// `Array.from(memo)` builds a 40-million-element array first. The bound is
// deliberately loose — it is here to catch the difference between "instant"
// and "hundreds of milliseconds of allocation", not to measure anything.
const huge = { ...A.wpo.wire, memo: "x".repeat(40 * 1024 * 1024) };
const started = process.hrtime.bigint();
assert.equal(orderShape(huge), null);
const ms = Number(process.hrtime.bigint() - started) / 1e6;
assert.ok(ms < 250, `the size check must precede the field checks (took ${ms.toFixed(0)} ms)`);
});
test("the ceilings are the documented ones and a settlement gets its own headroom", () => {
assert.equal(MAX_ARTIFACT_BYTES, 4096);
assert.equal(MAX_SETTLEMENT_BYTES, 8192);
assert.ok(MAX_SETTLEMENT_BYTES > MAX_ARTIFACT_BYTES, "a settlement embeds a whole order");
});
// ---- totality ---------------------------------------------------------------
//
// The three shape checks and `expired` are the only inspection LedgerStore does
// of an artifact off the network or out of localStorage, and their declared
// types are `X | null` and `boolean`. A hostile object turned every one of them
// into a THROW — which one rung up is `await store.applyReceipt(fromNetwork)`
// becoming an unhandled rejection instead of `false`.
const hostiles = () => {
const revoked = Proxy.revocable({}, {});
revoked.revoke();
const rows = [
["a Proxy whose get trap throws", new Proxy({}, { get() { throw new Error("boom"); } })],
["a Proxy whose ownKeys trap throws", new Proxy({}, { ownKeys() { throw new Error("boom"); } })],
["a revoked Proxy", revoked.proxy],
["a throwing getter on v", { get v() { throw new Error("boom"); } }],
["a throwing getter on t", { v: 1, get t() { throw new Error("boom"); } }],
["a throwing getter on a field", { ...A.wpo.wire, get memo() { throw new Error("boom"); } }],
["a throwing getter on po", { ...A.wrc.wire, get po() { throw new Error("boom"); } }],
["a throwing getter on h", { ...A.wri.wire, get h() { throw new Error("boom"); } }],
["a BigInt in a field", { ...A.wpo.wire, amt: 1n }],
["a BigInt in a wri field", { ...A.wri.wire, amt: 1n }],
];
return rows;
};
for (const [name, hostile] of hostiles()) {
test(`the shape checks are total — ${name}`, () => {
assert.equal(payRequestShape(hostile), null, name);
assert.equal(orderShape(hostile), null, name);
assert.equal(settlementShape(hostile), null, name);
assert.equal(issuanceShape(hostile), null, name);
// `expired` answers, it never throws — false when it cannot read an expiry
// at all, and the honest verdict when the object happens to expose one
assert.equal(typeof expired(hostile), "boolean", name);
assert.equal(typeof expired(hostile, 0), "boolean", name);
});
test(`verify* is total — ${name}`, async () => {
assert.equal(await verifyPayRequest(hostile), null, name);
assert.equal(await verifyOrder(hostile), null, name);
assert.equal(await verifySettlement(hostile, bankPub), null, name);
assert.equal(await verifyIssuanceReceipt(hostile, bankPub), null, name);
});
}
test("expired answers false on a po chain no stack could recurse through", () => {
// free to build out of JSON.parse, and a `boolean` that is a RangeError is not
// a boolean
let head = {};
let tail = head;
for (let i = 0; i < 200000; i++) {
tail.po = {};
tail = tail.po;
}
assert.equal(expired(head), false);
assert.equal(expired(head, 0), false);
// the bound is a bound, not just a backstop: a settlement wraps exactly one
// order, so descent stops long before a chain like this could matter, and it
// stops by DECIDING rather than by having its RangeError swallowed
const nest = (depth, exp) => {
let node = { exp };
for (let i = 0; i < depth; i++) node = { po: node };
return node;
};
assert.equal(expired(nest(2, 0), 1), true, "a settlement's order is two levels down");
assert.equal(expired(nest(64, 0), 1), false, "sixty-four is not an artifact, it is a chain");
// and the real depth still works: a settlement's expiry is its order's
assert.equal(expired(A.wrc.wire), true);
assert.equal(expired(A.wrc.wire, A.wpo.wire.exp - 1), false);
});
// ---- line terminators are control characters --------------------------------
//
// U+2028 and U+2029 are not control characters by Unicode category, so a
// C0/C1-only class let them through — into `ctx`, the field a third party is
// expected to match on, and `memo`, which gets displayed. Both are line
// terminators in ECMAScript and in most log renderers, which is precisely what
// the class exists to keep out.
// built from code points, never typed as literals: this repo refuses to carry
// a line terminator through git, an editor and a diff (source-hygiene.test.mjs
// makes the same rule for C0 bytes), and a reader cannot see one anyway.
const SEP = [String.fromCodePoint(0x2028), String.fromCodePoint(0x2029)];
for (const ch of SEP) {
const u = `U+${ch.codePointAt(0).toString(16).toUpperCase()}`;
test(`${u} is refused in ctx and in memo, however well signed`, async () => {
for (const field of ["ctx", "memo"]) {
const po = reSignedOrder({ [field]: `a${ch}b` });
assert.equal(orderShape(po), null, `${u} in ${field}`);
assert.equal(await verifyOrder(po), null, `${u} in ${field}`);
}
});
test(`${u} is stripped on the build side, so the two halves agree`, () => {
assert.equal(sanitizeCtx(`a${ch}b`), "ab");
assert.equal(sanitizeMemo(`a${ch}b`), "ab");
// and what sanitize produces is what the wire accepts
assert.equal(orderShape(reSignedOrder({ ctx: sanitizeCtx(`a${ch}b`) })) !== null, true);
});
}
test("ordinary text still passes, bounds included", () => {
assert.equal(sanitizeCtx("stake:demo"), "stake:demo");
assert.equal(sanitizeMemo("cafea ☕"), "cafea ☕");
assert.equal(sanitizeCtx("x".repeat(CTX_MAX + 10)).length, CTX_MAX);
assert.equal([...sanitizeMemo("☕".repeat(MEMO_MAX + 10))].length, MEMO_MAX);
});
// ---- payRequestUrl validates -------------------------------------------------
test("payRequestUrl refuses what is not a payment request", () => {
for (const junk of [null, undefined, {}, [], 0, "wpr", { ...paylink.request, amt: -1 },
{ ...paylink.request, sig: "short" }]) {
assert.throws(() => payRequestUrl(junk), /wallet-kit/, JSON.stringify(junk));
}
});
test("payRequestUrl encodes the REBUILT request, so a link carries wire key order", () => {
const scrambled = {};
for (const k of Object.keys(paylink.request).reverse()) scrambled[k] = paylink.request[k];
assert.notEqual(JSON.stringify(scrambled), JSON.stringify(paylink.request), "really reversed");
assert.equal(payRequestUrl(scrambled), paylink.expect_url, "and the link is the canonical one");
});
|