fractal-pqc 0.11.0 → 0.12.0
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/README.md +19 -4
- package/bin/cli.mjs +2 -2
- package/letter/galaxy-2026-08-31-es.docx +0 -0
- package/letter/galaxy-2026-08-31-es.md +4 -4
- package/letter/galaxy-2026-08-31.docx +0 -0
- package/letter/galaxy-2026-08-31.md +4 -4
- package/package.json +3 -2
- package/src/broadcast.mjs +17 -3
- package/src/claims-registry.mjs +363 -25
- package/src/custodian-log.mjs +13 -2
- package/src/fees.mjs +37 -0
- package/src/letter-claims.mjs +3 -3
- package/src/mutations.mjs +97 -0
- package/src/policy-key-registry.mjs +96 -0
- package/src/policy.mjs +12 -7
- package/src/tapscript.mjs +11 -1
- package/src/tx.mjs +12 -0
- package/src/verify-letter.mjs +14 -2
- package/test/composed-custody.mjs +117 -0
- package/test/letter-claims.mjs +2 -2
- package/test/policy-key-registry.mjs +135 -0
package/README.md
CHANGED
|
@@ -234,10 +234,25 @@ did. Each milestone is independently verifiable, open-source, and shippable on i
|
|
|
234
234
|
successes) and idempotent request handling (a retried request under the same key never
|
|
235
235
|
re-signs, proven by `R12-custodian-log-is-idempotent-and-tamper-evident`), with an explicit
|
|
236
236
|
design note on why the raw `policyKey` is never persisted. **Not done:** an actual HTTP/RPC
|
|
237
|
-
layer — this is a library a custodian's service calls, not a service itself; (d)
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
237
|
+
layer — this is a library a custodian's service calls, not a service itself; (d) ✅
|
|
238
|
+
*partially done* (round 13) — `policy-key-registry.mjs` enforces the
|
|
239
|
+
`singlePolicyKeyPerHolder` convention instead of trusting callers to uphold it: a policy
|
|
240
|
+
key stays bound to whichever classical key first authorised under it, and a different
|
|
241
|
+
holder is refused before a signature is ever computed, proven by
|
|
242
|
+
`R13-policy-key-registry-refuses-cross-holder-reuse` against a genuine two-holder attack.
|
|
243
|
+
**Not done:** the wrapper is opt-in (a caller can still call `authorizeAndSign` directly
|
|
244
|
+
and skip it), and HSM/multisig custody of the raw Schnorr secret itself — losing or
|
|
245
|
+
leaking that secret is still custodian-wide, not just holder-wide; (e) the funded testnet
|
|
246
|
+
broadcast itself.
|
|
247
|
+
|
|
248
|
+
**A custodian using (c) and (d) together** should compose them with the audit log
|
|
249
|
+
OUTERMOST and the registry INNERMOST —
|
|
250
|
+
`custodianAuthorize(log, idempotencyKey, req, ts, (r) => registry.authorize(r))` — round
|
|
251
|
+
15 found that the other order (registry outermost) silently drops the audit trail for a
|
|
252
|
+
refused cross-holder attempt, which is exactly the attempt most worth recording. Proven
|
|
253
|
+
by `R15-log-and-registry-compose-without-losing-either-guarantee` and
|
|
254
|
+
`test/composed-custody.mjs`, the only place either wrapper is exercised alongside the
|
|
255
|
+
other rather than in isolation.
|
|
241
256
|
|
|
242
257
|
4. **Tranche 2, $80,000 — independent external review, multisig PSBT, key rotation.**
|
|
243
258
|
External review: scope not yet defined here, tracked separately from the two engineering
|
package/bin/cli.mjs
CHANGED
|
@@ -237,7 +237,7 @@ switch (cmd) {
|
|
|
237
237
|
case "selftest": {
|
|
238
238
|
const r = spawnSync(process.execPath, [join(__dirname, "..", "test", "vectors.mjs")], { stdio: "inherit" });
|
|
239
239
|
if ((r.status ?? 1) !== 0) process.exit(r.status ?? 1);
|
|
240
|
-
for (const f of ["transparency.mjs", "primacy.mjs", "anchoring.mjs", "conformance.mjs", "m2-policy.mjs", "m2-broadcast.mjs", "custodian-log.mjs", "bip341-scriptpath.mjs", "claims.mjs", "demo.mjs", "letter-claims.mjs"]) {
|
|
240
|
+
for (const f of ["transparency.mjs", "primacy.mjs", "anchoring.mjs", "conformance.mjs", "m2-policy.mjs", "m2-broadcast.mjs", "custodian-log.mjs", "policy-key-registry.mjs", "composed-custody.mjs", "bip341-scriptpath.mjs", "claims.mjs", "demo.mjs", "letter-claims.mjs"]) {
|
|
241
241
|
const t = spawnSync(process.execPath, [join(__dirname, "..", "test", f)],
|
|
242
242
|
{ stdio: "inherit", env: { ...process.env, FRACTAL_SELFTEST_DEPTH: "1" } });
|
|
243
243
|
if ((t.status ?? 1) !== 0) process.exit(t.status ?? 1);
|
|
@@ -468,7 +468,7 @@ Usage:
|
|
|
468
468
|
fractal-pqc verify-letter [file] Check every factual assertion in our letter
|
|
469
469
|
against this package. Exits non-zero if any fails.
|
|
470
470
|
fractal-pqc verify-vector Check the official BIP-340 test vector
|
|
471
|
-
fractal-pqc selftest Run everything:
|
|
471
|
+
fractal-pqc selftest Run everything: 405 real checks, no mocks
|
|
472
472
|
|
|
473
473
|
Docs: integrations/pqc-migration-kit/README.md`);
|
|
474
474
|
process.exit(cmd ? 1 : 0);
|
|
Binary file
|
|
@@ -17,16 +17,16 @@ Tres capas. Dos están construidas y en producción. Una no existe, y lo decimos
|
|
|
17
17
|
**2) No tienes que creernos nada de esto**
|
|
18
18
|
|
|
19
19
|
```
|
|
20
|
-
npm i fractal-pqc@0.
|
|
20
|
+
npm i fractal-pqc@0.12.0
|
|
21
21
|
npx fractal-pqc verify-letter # este correo exacto, verificado contra el código, en tu máquina
|
|
22
22
|
npx fractal-pqc demo # el flujo completo, offline, 20 segundos
|
|
23
23
|
npx fractal-pqc claims --mutate # cada afirmación de seguridad, rota a propósito, para probar que se sostiene
|
|
24
|
-
npx fractal-pqc selftest #
|
|
24
|
+
npx fractal-pqc selftest # 405 verificaciones, sin mocks
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
Hace dieciocho afirmaciones factuales, y cada una de ellas — cada conteo, cada versión, cada comportamiento que describimos — se verifica automáticamente contra el código en vivo, incluida esta misma oración. Cambia un número aquí y la verificación falla; cambia el código y la verificación falla. Señala, en la misma salida, las cuatro cosas aquí que ninguna verificación cubre: dos juicios, un agregado contado a mano, y cada compromiso a futuro. Eso son opiniones y promesas, y nos negamos a reportarlas como verificadas.
|
|
28
28
|
|
|
29
|
-
También probamos nuestro propio código de forma adversarial antes de publicar cualquier cosa: cada afirmación de seguridad va acompañada de un ataque que debe fallar, y una mutación del código que debe romper la afirmación si la garantía que nombra deja de sostenerse. **
|
|
29
|
+
También probamos nuestro propio código de forma adversarial antes de publicar cualquier cosa: cada afirmación de seguridad va acompañada de un ataque que debe fallar, y una mutación del código que debe romper la afirmación si la garantía que nombra deja de sostenerse. **Dieciocho rondas de asedio adversarial** contra nuestro propio código encontraron catorce bugs reales, explotables — aproximadamente 1.150 exploits ejecutados a mano entre esos catorce — incluido uno donde un cliente legítimo de un custodio podría haber obtenido una firma sobre la moneda de otro cliente, uno donde componer nuestras dos herramientas de auditoría en el orden más obvio silenciaba el registro de un ataque rechazado, y uno donde una entrada malformada podía hacer que dos transacciones de Bitcoin distintas produjeran el mismo digest firmado. Cada uno de esos bugs es ahora una prueba de regresión permanente: `claims --mutate` reintroduce el bug exacto y confirma que sigue siendo detectado.
|
|
30
30
|
|
|
31
31
|
Nuestro codec OTS se verifica contra el ejemplo de referencia del propio proyecto OpenTimestamps, confirmado en el bloque de Bitcoin **358391**. El gasto por script-path se verifica contra los vectores de prueba oficiales de wallet BIP-341: 7/7 casos de scriptPubKey y 12/12 bloques de control, byte por byte. **Los vectores siguen siendo CC0.** 33 de ellos ya están publicados — quien los corra es dueño de su propia verificación.
|
|
32
32
|
|
|
@@ -60,7 +60,7 @@ Te comprometes al siguiente tranche solo después de que verifiques el anterior
|
|
|
60
60
|
**Cuatro compromisos que van con el dinero**
|
|
61
61
|
|
|
62
62
|
1. **Cada entregable llega con su propio recibo post-cuántico, anclado en Bitcoin.** Verificas nuestro trabajo usando el mismo mecanismo que estás financiando.
|
|
63
|
-
2. **Cláusula pública de fallo.** Si alguien — incluidos nosotros — rompe el esquema de vinculación durante el grant, lo publicamos en su totalidad dentro de 72 horas y dejamos de facturar hasta que se corrija. Nuestro propio asedio lo ha roto
|
|
63
|
+
2. **Cláusula pública de fallo.** Si alguien — incluidos nosotros — rompe el esquema de vinculación durante el grant, lo publicamos en su totalidad dentro de 72 horas y dejamos de facturar hasta que se corrija. Nuestro propio asedio lo ha roto catorce veces ya, la más reciente en los días antes de este correo; eso no es una política hipotética.
|
|
64
64
|
3. **Los vectores siguen siendo CC0.**
|
|
65
65
|
4. **No dilutivo, código abierto, sin exclusividad.**
|
|
66
66
|
|
|
Binary file
|
|
@@ -17,16 +17,16 @@ Three layers. Two are built and live. One does not exist, and we say so plainly.
|
|
|
17
17
|
**2) You don't have to take our word for any of this**
|
|
18
18
|
|
|
19
19
|
```
|
|
20
|
-
npm i fractal-pqc@0.
|
|
20
|
+
npm i fractal-pqc@0.12.0
|
|
21
21
|
npx fractal-pqc verify-letter # this exact email, checked against the code, on your machine
|
|
22
22
|
npx fractal-pqc demo # the full flow, offline, 20 seconds
|
|
23
23
|
npx fractal-pqc claims --mutate # every security claim, broken on purpose, to prove it holds
|
|
24
|
-
npx fractal-pqc selftest #
|
|
24
|
+
npx fractal-pqc selftest # 405 checks, no mocks
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
It makes eighteen factual assertions, and every one of them — every count, every version, every behaviour we describe — is checked automatically against the live code, including this sentence. Change a number here and the check fails; change the code and the check fails. It flags, in the same output, the four things in here that no check covers: two judgements, one hand-counted aggregate, and every forward-looking commitment. Those are opinions and promises, and we refuse to report them as verified.
|
|
28
28
|
|
|
29
|
-
We also test our own code adversarially before anything ships: every security claim is paired with an attack that must fail, and a mutation of the code that must break the claim if the guarantee it names stops holding. **
|
|
29
|
+
We also test our own code adversarially before anything ships: every security claim is paired with an attack that must fail, and a mutation of the code that must break the claim if the guarantee it names stops holding. **Eighteen adversarial siege rounds** against our own code found fourteen real, exploitable bugs — roughly 1,150 hand-run exploits across those fourteen — including one where a legitimate customer of a custodian could have obtained a signature over a different customer's coin, one where composing our own two audit tools in the more obvious order silently dropped the record of a refused attack, and one where a malformed input could make two different Bitcoin transactions produce the same signed digest. Every one of those bugs is now a permanent regression test: `claims --mutate` reintroduces the exact bug and confirms it still gets caught.
|
|
30
30
|
|
|
31
31
|
Our OTS codec is checked against OpenTimestamps' own reference example, confirmed in Bitcoin block **358391**. Script-path spending is checked against the official BIP-341 wallet test vectors: 7/7 scriptPubKey cases and 12/12 control blocks, byte for byte. **Vectors stay CC0.** 33 of them are already published — whoever runs them owns their own verification.
|
|
32
32
|
|
|
@@ -60,7 +60,7 @@ You fund the next tranche only after the previous one is verified by you. If a m
|
|
|
60
60
|
**Four commitments that go with the money**
|
|
61
61
|
|
|
62
62
|
1. **Every deliverable arrives with its own post-quantum receipt, Bitcoin-anchored.** You verify our work using the mechanism you're funding.
|
|
63
|
-
2. **Public failure clause.** If anyone — including us — breaks the binding scheme during the grant, we publish it in full within 72 hours and stop invoicing until it's fixed. Our own siege has broken it
|
|
63
|
+
2. **Public failure clause.** If anyone — including us — breaks the binding scheme during the grant, we publish it in full within 72 hours and stop invoicing until it's fixed. Our own siege has broken it fourteen times already, the most recent one in the days before this email; that's not a hypothetical policy.
|
|
64
64
|
3. **Vectors stay CC0.**
|
|
65
65
|
4. **Non-dilutive, open source, no exclusivity.**
|
|
66
66
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fractal-pqc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Runnable reference for quantum-safe migration of a Bitcoin-style key: bind secp256k1/Taproot to ML-DSA-65 (FIPS-204), derive P2TR addresses, build+sign BIP-341 key-path spends (official-vector-verified), and broadcast on testnet. Real primitives, honest scope.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"./policy": "./src/policy.mjs",
|
|
23
23
|
"./m2-broadcast": "./src/m2-broadcast.mjs",
|
|
24
24
|
"./custodian-log": "./src/custodian-log.mjs",
|
|
25
|
+
"./policy-key-registry": "./src/policy-key-registry.mjs",
|
|
25
26
|
"./tapscript": "./src/tapscript.mjs",
|
|
26
27
|
"./ots": "./src/ots.mjs",
|
|
27
28
|
"./address": "./src/address.mjs",
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
"vectors"
|
|
45
46
|
],
|
|
46
47
|
"scripts": {
|
|
47
|
-
"test": "node test/vectors.mjs && node test/transparency.mjs && node test/primacy.mjs && node test/anchoring.mjs && node test/conformance.mjs && node test/m2-policy.mjs && node test/m2-broadcast.mjs && node test/custodian-log.mjs && node test/bip341-scriptpath.mjs && node test/claims.mjs && node test/demo.mjs && node test/letter-claims.mjs",
|
|
48
|
+
"test": "node test/vectors.mjs && node test/transparency.mjs && node test/primacy.mjs && node test/anchoring.mjs && node test/conformance.mjs && node test/m2-policy.mjs && node test/m2-broadcast.mjs && node test/custodian-log.mjs && node test/policy-key-registry.mjs && node test/composed-custody.mjs && node test/bip341-scriptpath.mjs && node test/claims.mjs && node test/demo.mjs && node test/letter-claims.mjs",
|
|
48
49
|
"selftest": "node bin/cli.mjs selftest",
|
|
49
50
|
"conformance": "node test/conformance.mjs",
|
|
50
51
|
"claims": "node bin/cli.mjs claims",
|
package/src/broadcast.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { generateTaprootKey } from "./bitcoin.mjs";
|
|
12
12
|
import { p2trAddress, addressToScriptPubKey, taprootTweakOutputKey } from "./address.mjs";
|
|
13
13
|
import { p2trScriptPubKey } from "./tx.mjs";
|
|
14
|
-
import { selectCoins } from "./fees.mjs";
|
|
14
|
+
import { selectCoins, MAX_FEE_RATE_SAT_PER_VB } from "./fees.mjs";
|
|
15
15
|
import { createPsbt, signPsbtTaprootKeyPath, finalizePsbt } from "./psbt.mjs";
|
|
16
16
|
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
17
17
|
|
|
@@ -43,11 +43,25 @@ export async function fetchUtxos(address, { network = "tb", apiBase } = {}) {
|
|
|
43
43
|
return list.map((u) => ({ txid: u.txid, vout: u.vout, valueSats: u.value, status: u.status }));
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Recommended fee rate (sat/vB). Falls back to 1 on parse issues.
|
|
48
|
+
*
|
|
49
|
+
* Floored at 1 sat/vB (always did that); as of the 2026-09-04 audit sweep also CEILED at
|
|
50
|
+
* MAX_FEE_RATE_SAT_PER_VB (fees.mjs) — an anomalous response from the fee endpoint (bad
|
|
51
|
+
* config, MITM, upstream bug) used to flow straight through with no upper bound at all.
|
|
52
|
+
* selectCoins() enforces the same ceiling independently, so this is defence in depth for
|
|
53
|
+
* callers who use this function directly rather than through selectCoins.
|
|
54
|
+
*/
|
|
47
55
|
export async function fetchFeeRate({ network = "tb", apiBase, tier = "halfHourFee" } = {}) {
|
|
48
56
|
const base = apiBase || net(network).api;
|
|
49
57
|
const j = await getJson(`${base}/v1/fees/recommended`);
|
|
50
|
-
|
|
58
|
+
const rate = Math.max(1, Math.ceil(j[tier] ?? j.hourFee ?? 1));
|
|
59
|
+
if (rate > MAX_FEE_RATE_SAT_PER_VB) {
|
|
60
|
+
throw new Error(`fee endpoint returned an anomalous rate (${rate} sat/vB, ceiling is ` +
|
|
61
|
+
`${MAX_FEE_RATE_SAT_PER_VB}) — refusing rather than using it. Pass --fee-rate ` +
|
|
62
|
+
`explicitly if this is genuinely correct.`);
|
|
63
|
+
}
|
|
64
|
+
return rate;
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
/** Broadcast a raw tx hex (Esplora `POST /tx`). Returns the txid. */
|
package/src/claims-registry.mjs
CHANGED
|
@@ -12,14 +12,17 @@ import * as T from "./transparency.mjs";
|
|
|
12
12
|
import * as P from "./primacy.mjs";
|
|
13
13
|
import { certIsFirstSeen } from "./primacy.mjs";
|
|
14
14
|
const toHexLocal = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
15
|
+
const fromHexLocal = (h) => Buffer.from(h, "hex");
|
|
15
16
|
import * as TS from "./tapscript.mjs";
|
|
16
17
|
import * as OTS from "./ots.mjs";
|
|
17
18
|
import { POLICY_SCOPE, authorizeAndSign, REFUSED, policyOutputScriptPubKey, buildSpendContext } from "./policy.mjs";
|
|
18
19
|
import { rebuildM2Witness } from "./m2-broadcast.mjs";
|
|
19
20
|
import * as C from "./custodian-log.mjs";
|
|
20
|
-
import
|
|
21
|
+
import * as PKR from "./policy-key-registry.mjs";
|
|
22
|
+
import { taprootScriptPathSighash, taprootKeyPathSighash, p2trScriptPubKey } from "./tx.mjs";
|
|
23
|
+
import { selectCoins, MAX_FEE_RATE_SAT_PER_VB } from "./fees.mjs";
|
|
21
24
|
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
22
|
-
import { generateMigrationIdentity, createMigrationCommitment, authorizeSpend } from "./migration-envelope.mjs";
|
|
25
|
+
import { generateMigrationIdentity, createMigrationCommitment, authorizeSpend, verifySpend, _internal as ME_INTERNAL } from "./migration-envelope.mjs";
|
|
23
26
|
|
|
24
27
|
const te = new TextEncoder();
|
|
25
28
|
const kp = ml_dsa65.keygen(new Uint8Array(32).fill(0x9c));
|
|
@@ -414,6 +417,332 @@ export const CLAIMS = [
|
|
|
414
417
|
"spendContext's own BigInt fields, which is exactly the kind of bug this package's " +
|
|
415
418
|
"own culture exists to catch before it ships, not after.",
|
|
416
419
|
},
|
|
420
|
+
{
|
|
421
|
+
id: "R13-policy-key-registry-refuses-cross-holder-reuse",
|
|
422
|
+
module: "policy-key-registry.mjs",
|
|
423
|
+
statement: "Once a policy key has authorised for one holder, authorizeAndSign() itself " +
|
|
424
|
+
"still cannot stop the SAME key from also signing for a DIFFERENT holder — it " +
|
|
425
|
+
"is a stateless function and cannot see its own prior calls (POLICY_SCOPE." +
|
|
426
|
+
"policyKeyCustody says exactly this). The registry wrapper closes that: the " +
|
|
427
|
+
"second, different holder is refused, and refused BEFORE a signature is ever " +
|
|
428
|
+
"computed for the attempt, not merely withheld afterward.",
|
|
429
|
+
proof: () => {
|
|
430
|
+
const reg = PKR.createPolicyKeyRegistry();
|
|
431
|
+
const r1 = reg.authorize(goodReq());
|
|
432
|
+
if (r1.authorized !== true) return false;
|
|
433
|
+
const r2 = reg.authorize(goodReq());
|
|
434
|
+
// The SAME holder, same key, twice: must still authorise, and each signature is
|
|
435
|
+
// independently valid (fresh aux randomness per Schnorr sign, so equal reasons ==
|
|
436
|
+
// equal bytes only by proof, never assumed).
|
|
437
|
+
if (r2.authorized !== true) return false;
|
|
438
|
+
return schnorr.verify(fromHexLocal(r1.policySignatureHex), fromHexLocal(r1.evidence.digestSigned),
|
|
439
|
+
fromHexLocal(r1.policyPublicKeyHex)) === true;
|
|
440
|
+
},
|
|
441
|
+
attack: () => {
|
|
442
|
+
// A genuinely SECOND holder: fresh identity, fresh commitment, first-seen in its OWN
|
|
443
|
+
// log — not a variant of `cert`, or the attack would die on a guard that has nothing
|
|
444
|
+
// to do with the claim (round 5's lesson, the same one R12's attack re-learned).
|
|
445
|
+
const otherIdent = generateMigrationIdentity();
|
|
446
|
+
const otherCert = createMigrationCommitment(otherIdent);
|
|
447
|
+
const otherEntries = [{ classicalPub: otherCert.classicalPub, pqPublic: otherCert.pqPublic,
|
|
448
|
+
classicalSig: otherCert.classicalSig, pqSig: otherCert.pqSig }];
|
|
449
|
+
const otherLeaves = otherEntries.map(P.entryLeaf);
|
|
450
|
+
const kp2 = ml_dsa65.keygen(new Uint8Array(32).fill(0x7e));
|
|
451
|
+
const otherSth = T.signTreeHead({ secretKey: kp2.secretKey, publicKey: kp2.publicKey,
|
|
452
|
+
treeSize: otherEntries.length, rootHash: T.merkleRoot(otherLeaves), timestampMs: 1_756_000_000_002 });
|
|
453
|
+
const otherHeadBytes = T.sthSignedBytes({ version: otherSth.version, logId: otherSth.logId,
|
|
454
|
+
treeSize: otherSth.treeSize, rootHash: T.fromHex(otherSth.rootHash), timestampMs: otherSth.timestampMs });
|
|
455
|
+
const otherOtsDigest = OTS.otsSha256(otherHeadBytes);
|
|
456
|
+
const otherOtsHex = T.toHex(OTS.encodeOtsFile({ majorVersion: OTS.OTS_MAJOR_VERSION, fileHashOp: "sha256",
|
|
457
|
+
fileDigest: otherOtsDigest, timestamp: OTS.timestampWithAttestation(otherOtsDigest, { kind: "bitcoin", height: 800_000 }) }));
|
|
458
|
+
const otherRoot = (() => { const a = OTS.evaluateOts(OTS.decodeOtsFile(T.fromHex(otherOtsHex))).blockAttestations[0];
|
|
459
|
+
return a.merkleRootInternalHex.match(/../g).reverse().join(""); })();
|
|
460
|
+
const otherHeaders = { 800_000: otherRoot };
|
|
461
|
+
const otherFactHash = certIsFirstSeen({ cert: otherCert, entries: otherEntries, sth: otherSth,
|
|
462
|
+
expectedLogId: otherSth.logId, cutoffBlockHeight: 900_000, otsHex: otherOtsHex,
|
|
463
|
+
blockMerkleRoots: otherHeaders, requireTemporalFrontier: true }).anchoredFactHash;
|
|
464
|
+
if (!otherFactHash) return false; // setup must genuinely authorise, or this proves nothing
|
|
465
|
+
// THE SAME POLICY_KEY the module-level goodReq() already uses.
|
|
466
|
+
const otherSc = buildSpendContext({ anchoredFactHash: otherFactHash,
|
|
467
|
+
policyPublicKey: schnorr.getPublicKey(POLICY_KEY), txid: "33".repeat(32), valueSats: 70_000n,
|
|
468
|
+
outputs: [{ valueSats: 60_000n, scriptPubKey: new Uint8Array(34) }] });
|
|
469
|
+
const otherSig = authorizeSpend(otherIdent, otherSc.digest);
|
|
470
|
+
const otherReq = { cert: otherCert, pqSignatureHex: otherSig,
|
|
471
|
+
anchorEvidence: { sth: otherSth, entries: otherEntries, otsHex: otherOtsHex },
|
|
472
|
+
expectedLogId: otherSth.logId, policyKey: POLICY_KEY, cutoffBlockHeight: 900_000,
|
|
473
|
+
blockMerkleRoots: otherHeaders, spendContext: otherSc.spendContext };
|
|
474
|
+
// Confirm authorizeAndSign ALONE (no registry) really would authorise this — otherwise
|
|
475
|
+
// the registry's refusal proves nothing about cross-holder reuse specifically.
|
|
476
|
+
const bare = authorizeAndSign(otherReq);
|
|
477
|
+
if (bare.authorized !== true) return false;
|
|
478
|
+
|
|
479
|
+
const reg = PKR.createPolicyKeyRegistry();
|
|
480
|
+
const r1 = reg.authorize(goodReq());
|
|
481
|
+
if (r1.authorized !== true) return false;
|
|
482
|
+
const r2 = reg.authorize(otherReq);
|
|
483
|
+
return r2.authorized === false && r2.reason === "policy-key-bound-to-different-holder" &&
|
|
484
|
+
!Object.prototype.hasOwnProperty.call(r2, "policySignatureHex");
|
|
485
|
+
},
|
|
486
|
+
note: "Round 13 (this audit): policyKeyCustody in policy.mjs names the gap and explicitly " +
|
|
487
|
+
"declines to close it inside a stateless function — 'a stateless function call " +
|
|
488
|
+
"cannot verify a claim about OTHER calls it never sees'. This is that external, " +
|
|
489
|
+
"stateful enforcement, built the same way custodian-log.mjs was: a wrapper around " +
|
|
490
|
+
"authorizeAndSign, never a change to it. Keyed by the holder's classical public key " +
|
|
491
|
+
"rather than anchoredFactHash — forward-compatible with a PQ key rotation feature " +
|
|
492
|
+
"that does not exist yet (see UNCLAIMED_GUARDS), though nothing today can exercise " +
|
|
493
|
+
"that distinction, and test/policy-key-registry.mjs says so rather than fake it.",
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
id: "R14-commit-and-spend-domains-do-not-collide",
|
|
497
|
+
module: "migration-envelope.mjs",
|
|
498
|
+
statement: "A signature that authorises a COMMIT (binding a classical key to a PQ key) " +
|
|
499
|
+
"cannot be replayed as a SPEND authorisation, and a SPEND signature cannot be " +
|
|
500
|
+
"replayed as a COMMIT — canonical() prefixes every message with a domain tag " +
|
|
501
|
+
"specifically so these two signature types can never be confused, and this had " +
|
|
502
|
+
"test coverage for encoding DETERMINISM but never for the replay itself.",
|
|
503
|
+
proof: () => {
|
|
504
|
+
// Isolate the domain tag as the mechanism, not incidental field-length differences:
|
|
505
|
+
// the SAME two field values, under the two DIFFERENT domains, must not canonicalise
|
|
506
|
+
// to the same bytes.
|
|
507
|
+
const fields = [cert.classicalPub instanceof Uint8Array ? cert.classicalPub : ident.classicalPub, ident.pqPublic];
|
|
508
|
+
const asCommit = ME_INTERNAL.canonical(ME_INTERNAL.COMMIT_DOMAIN, fields);
|
|
509
|
+
const asSpend = ME_INTERNAL.canonical(ME_INTERNAL.SPEND_DOMAIN, fields);
|
|
510
|
+
return ME_INTERNAL.toHex(asCommit) !== ME_INTERNAL.toHex(asSpend);
|
|
511
|
+
},
|
|
512
|
+
attack: () => {
|
|
513
|
+
// The realistic exploit attempt: take a genuinely valid COMMIT signature (the cert's
|
|
514
|
+
// own pqSig, which authorised binding classicalPub<->pqPublic) and hand it to
|
|
515
|
+
// verifySpend as if it authorised spending some transaction. If domain separation is
|
|
516
|
+
// real, this is refused — not because the bytes happen to differ for some unrelated
|
|
517
|
+
// reason, but because the message the COMMIT signature covers is never the message
|
|
518
|
+
// verifySpend reconstructs and checks against.
|
|
519
|
+
const txHash = sha256(te.encode("R14-attack-txhash"));
|
|
520
|
+
const replayed = verifySpend(cert, txHash, cert.pqSig, { anchoredFactHash: ANCHORED_FH });
|
|
521
|
+
if (replayed.valid !== false) return false;
|
|
522
|
+
// And the reverse direction: a genuine SPEND signature must not verify as a COMMIT.
|
|
523
|
+
const spendSig = authorizeSpend(ident, txHash);
|
|
524
|
+
const asCommitMsg = ME_INTERNAL.canonical(ME_INTERNAL.COMMIT_DOMAIN, [fromHexLocal(cert.classicalPub), fromHexLocal(cert.pqPublic)]);
|
|
525
|
+
const spendVerifiesAsCommit = ml_dsa65.verify(fromHexLocal(spendSig), asCommitMsg, fromHexLocal(cert.pqPublic));
|
|
526
|
+
return spendVerifiesAsCommit === false;
|
|
527
|
+
},
|
|
528
|
+
note: "Round 14 (this audit, same sweep as R13): test/vectors.mjs already checked that " +
|
|
529
|
+
"canonical() is DETERMINISTIC (same input twice -> same bytes) but nothing had ever " +
|
|
530
|
+
"attempted the actual cross-domain replay this encoding exists to stop. The mutation " +
|
|
531
|
+
"that should kill this is collapsing SPEND_DOMAIN to equal COMMIT_DOMAIN — it flips " +
|
|
532
|
+
"the `proof` above directly, which is the honest way to test a tag-collision defence: " +
|
|
533
|
+
"isolate the tag, not the field content, or a mutation could pass by accident for a " +
|
|
534
|
+
"reason that has nothing to do with domain separation (round 5's lesson again).",
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
id: "R15-log-and-registry-compose-without-losing-either-guarantee",
|
|
538
|
+
module: "custodian-log.mjs + policy-key-registry.mjs",
|
|
539
|
+
statement: "custodian-log.mjs and policy-key-registry.mjs were each tested wrapping " +
|
|
540
|
+
"authorizeAndSign directly, in complete isolation from each other. Composed " +
|
|
541
|
+
"with the log OUTERMOST and the registry INNERMOST — custodianAuthorize(log, " +
|
|
542
|
+
"key, req, ts, (r) => registry.authorize(r)) — a custodian gets BOTH " +
|
|
543
|
+
"guarantees at once: idempotency still holds, AND a refused cross-holder " +
|
|
544
|
+
"attempt still produces a hash-chained audit entry, rather than vanishing " +
|
|
545
|
+
"silently the way a registry-outermost composition would leave it.",
|
|
546
|
+
proof: () => {
|
|
547
|
+
const log = C.createAuditLog();
|
|
548
|
+
const registry = PKR.createPolicyKeyRegistry();
|
|
549
|
+
const authorizeFn = (r) => registry.authorize(r);
|
|
550
|
+
const r1 = C.custodianAuthorize(log, "r15-proof", goodReq(), 1, authorizeFn);
|
|
551
|
+
const r2 = C.custodianAuthorize(log, "r15-proof", goodReq(), 2, authorizeFn);
|
|
552
|
+
if (r1.authorized !== true || r2.idempotentReplay !== true) return false;
|
|
553
|
+
if (r1.policySignatureHex !== r2.policySignatureHex) return false;
|
|
554
|
+
if (log.entries.length !== 1) return false;
|
|
555
|
+
return log.verify().valid === true;
|
|
556
|
+
},
|
|
557
|
+
attack: () => {
|
|
558
|
+
// A genuinely SECOND holder — built the same way R13's attack builds one: its own
|
|
559
|
+
// fresh identity, fresh log, first-seen there, so this dies on nothing but the
|
|
560
|
+
// property being tested.
|
|
561
|
+
const otherIdent = generateMigrationIdentity();
|
|
562
|
+
const otherCert = createMigrationCommitment(otherIdent);
|
|
563
|
+
const otherEntries = [{ classicalPub: otherCert.classicalPub, pqPublic: otherCert.pqPublic,
|
|
564
|
+
classicalSig: otherCert.classicalSig, pqSig: otherCert.pqSig }];
|
|
565
|
+
const otherLeaves = otherEntries.map(P.entryLeaf);
|
|
566
|
+
const kp2 = ml_dsa65.keygen(new Uint8Array(32).fill(0x5a));
|
|
567
|
+
const otherSth = T.signTreeHead({ secretKey: kp2.secretKey, publicKey: kp2.publicKey,
|
|
568
|
+
treeSize: otherEntries.length, rootHash: T.merkleRoot(otherLeaves), timestampMs: 1_756_000_000_003 });
|
|
569
|
+
const otherHeadBytes = T.sthSignedBytes({ version: otherSth.version, logId: otherSth.logId,
|
|
570
|
+
treeSize: otherSth.treeSize, rootHash: T.fromHex(otherSth.rootHash), timestampMs: otherSth.timestampMs });
|
|
571
|
+
const otherOtsDigest = OTS.otsSha256(otherHeadBytes);
|
|
572
|
+
const otherOtsHex = T.toHex(OTS.encodeOtsFile({ majorVersion: OTS.OTS_MAJOR_VERSION, fileHashOp: "sha256",
|
|
573
|
+
fileDigest: otherOtsDigest, timestamp: OTS.timestampWithAttestation(otherOtsDigest, { kind: "bitcoin", height: 800_000 }) }));
|
|
574
|
+
const otherRoot = (() => { const a = OTS.evaluateOts(OTS.decodeOtsFile(T.fromHex(otherOtsHex))).blockAttestations[0];
|
|
575
|
+
return a.merkleRootInternalHex.match(/../g).reverse().join(""); })();
|
|
576
|
+
const otherHeaders = { 800_000: otherRoot };
|
|
577
|
+
const otherFactHash = certIsFirstSeen({ cert: otherCert, entries: otherEntries, sth: otherSth,
|
|
578
|
+
expectedLogId: otherSth.logId, cutoffBlockHeight: 900_000, otsHex: otherOtsHex,
|
|
579
|
+
blockMerkleRoots: otherHeaders, requireTemporalFrontier: true }).anchoredFactHash;
|
|
580
|
+
if (!otherFactHash) return false;
|
|
581
|
+
const otherSc = buildSpendContext({ anchoredFactHash: otherFactHash,
|
|
582
|
+
policyPublicKey: schnorr.getPublicKey(POLICY_KEY), txid: "44".repeat(32), valueSats: 70_000n,
|
|
583
|
+
outputs: [{ valueSats: 60_000n, scriptPubKey: new Uint8Array(34) }] });
|
|
584
|
+
const otherSig = authorizeSpend(otherIdent, otherSc.digest);
|
|
585
|
+
const otherReq = { cert: otherCert, pqSignatureHex: otherSig,
|
|
586
|
+
anchorEvidence: { sth: otherSth, entries: otherEntries, otsHex: otherOtsHex },
|
|
587
|
+
expectedLogId: otherSth.logId, policyKey: POLICY_KEY, cutoffBlockHeight: 900_000,
|
|
588
|
+
blockMerkleRoots: otherHeaders, spendContext: otherSc.spendContext };
|
|
589
|
+
|
|
590
|
+
const log = C.createAuditLog();
|
|
591
|
+
const registry = PKR.createPolicyKeyRegistry();
|
|
592
|
+
const authorizeFn = (r) => registry.authorize(r);
|
|
593
|
+
const r1 = C.custodianAuthorize(log, "r15-attack-a", goodReq(), 1, authorizeFn);
|
|
594
|
+
if (r1.authorized !== true) return false;
|
|
595
|
+
const r2 = C.custodianAuthorize(log, "r15-attack-b", otherReq, 2, authorizeFn);
|
|
596
|
+
// THE PROPERTY: refused, AND logged. A registry-outermost composition would satisfy
|
|
597
|
+
// "refused" while failing "logged" — both must hold for this claim to mean anything.
|
|
598
|
+
const refused = r2.authorized === false && r2.reason === "policy-key-bound-to-different-holder";
|
|
599
|
+
const logged = log.entries.length === 2 && log.entries[1].result.authorized === false &&
|
|
600
|
+
log.entries[1].result.reason === "policy-key-bound-to-different-holder";
|
|
601
|
+
return refused && logged && log.verify().valid === true;
|
|
602
|
+
},
|
|
603
|
+
note: "Round 15 (this audit, same sweep as R13/R14): the two stateful wrappers this " +
|
|
604
|
+
"package ships were each proven correct alone, and nothing had ever proven they " +
|
|
605
|
+
"compose. Building the composed test first (test/composed-custody.mjs) is what " +
|
|
606
|
+
"found the ordering hazard — registry-outermost silently drops the audit trail for " +
|
|
607
|
+
"exactly the attempts most worth recording. Fixed by giving custodianAuthorize an " +
|
|
608
|
+
"injectable `authorizeFn` (defaults to authorizeAndSign, so every existing R12 " +
|
|
609
|
+
"call site is unaffected) and documenting log-outer/registry-inner as the " +
|
|
610
|
+
"recommended composition, in the code itself, not only here.",
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
id: "R16-control-block-size-bounded-to-consensus-max",
|
|
614
|
+
module: "tapscript.mjs",
|
|
615
|
+
statement: "verifyControlBlock's own doc comment calls it 'the check consensus performs, " +
|
|
616
|
+
"done locally' — but until this round it had no upper bound on control-block " +
|
|
617
|
+
"size at all, so a Merkle path deeper than Bitcoin consensus allows (128 " +
|
|
618
|
+
"levels, 4129 bytes total) was reported valid:true by this library while a " +
|
|
619
|
+
"real Bitcoin node would reject it outright " +
|
|
620
|
+
"(SCRIPT_ERR_TAPROOT_WRONG_CONTROL_SIZE) before ever checking the path.",
|
|
621
|
+
proof: () => {
|
|
622
|
+
// Boundary correctness, not just presence of A bound: exactly AT the consensus max
|
|
623
|
+
// must still verify — a fix that is merely conservative (rejects too much) would
|
|
624
|
+
// itself be a divergence from consensus in the other direction.
|
|
625
|
+
const script = TS.checksigScript(new Uint8Array(32).fill(7));
|
|
626
|
+
let h = TS.tapLeafHash(script);
|
|
627
|
+
const path = [];
|
|
628
|
+
for (let i = 0; i < 128; i++) {
|
|
629
|
+
const sib = new Uint8Array(32).fill((i % 250) + 1);
|
|
630
|
+
path.push(sib);
|
|
631
|
+
h = TS.tapBranchHash(h, sib);
|
|
632
|
+
}
|
|
633
|
+
const { outputKey, parity } = TS.taprootOutputKeyWithTree(TS.NUMS_INTERNAL_KEY, h);
|
|
634
|
+
const cb = TS.controlBlock({ internalXOnly: TS.NUMS_INTERNAL_KEY, parity, path });
|
|
635
|
+
if (cb.length !== TS.TAPROOT_CONTROL_MAX_SIZE) return false; // setup check
|
|
636
|
+
return TS.verifyControlBlock(cb, script, outputKey).valid === true;
|
|
637
|
+
},
|
|
638
|
+
attack: () => {
|
|
639
|
+
// ONE level past the consensus max (129, not a round number chosen to look dramatic) —
|
|
640
|
+
// real TapLeaf/TapBranch hashing throughout, the exact construction a script-path
|
|
641
|
+
// spend with an oversized tree would actually produce.
|
|
642
|
+
const script = TS.checksigScript(new Uint8Array(32).fill(7));
|
|
643
|
+
let h = TS.tapLeafHash(script);
|
|
644
|
+
const path = [];
|
|
645
|
+
for (let i = 0; i < 129; i++) {
|
|
646
|
+
const sib = new Uint8Array(32).fill((i % 250) + 1);
|
|
647
|
+
path.push(sib);
|
|
648
|
+
h = TS.tapBranchHash(h, sib);
|
|
649
|
+
}
|
|
650
|
+
const { outputKey, parity } = TS.taprootOutputKeyWithTree(TS.NUMS_INTERNAL_KEY, h);
|
|
651
|
+
const cb = TS.controlBlock({ internalXOnly: TS.NUMS_INTERNAL_KEY, parity, path });
|
|
652
|
+
if (cb.length <= TS.TAPROOT_CONTROL_MAX_SIZE) return false; // setup check: must actually exceed it
|
|
653
|
+
const r = TS.verifyControlBlock(cb, script, outputKey);
|
|
654
|
+
return r.valid === false && r.reason === "malformed-control-block";
|
|
655
|
+
},
|
|
656
|
+
note: "Round 16 (this audit, adversarial sweep across the less-recently-touched files, " +
|
|
657
|
+
"run by an independent subagent whose finding was reproduced by hand before " +
|
|
658
|
+
"acting on it — round 5's lesson applied to trusting my OWN tooling, not just my " +
|
|
659
|
+
"own attacks). ots.mjs already bounded proof depth this same way (MAX_TREE_DEPTH); " +
|
|
660
|
+
"tapscript.mjs had no equivalent, and its own doc comment claiming to do 'the check " +
|
|
661
|
+
"consensus performs' made the omission a real, checkable overclaim, not a vague gap.",
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
id: "R17-sighash-rejects-malformed-txid",
|
|
665
|
+
module: "tx.mjs",
|
|
666
|
+
statement: "commonSighashParts (the function this file's own comment calls 'THE FUNCTION " +
|
|
667
|
+
"THAT ENDS THE SIGNING ORACLE') rejects a malformed txid instead of silently " +
|
|
668
|
+
"decoding it with Node's lenient Buffer.from(str,'hex') — which stops at the " +
|
|
669
|
+
"first invalid character instead of throwing. Before this round, two " +
|
|
670
|
+
"DECLARED-DIFFERENT malformed txids that shared a valid hex prefix could " +
|
|
671
|
+
"decode to the SAME bytes and produce the SAME sighash: the exact binding " +
|
|
672
|
+
"'which coin is this for' this function exists to make arithmetic rather than " +
|
|
673
|
+
"trusted, silently broken by an input serializeInputs would have refused.",
|
|
674
|
+
proof: () => {
|
|
675
|
+
// A well-formed request still signs correctly — the fix must not be merely
|
|
676
|
+
// conservative (rejecting valid input would be its own regression).
|
|
677
|
+
const spk = p2trScriptPubKey(new Uint8Array(32).fill(1));
|
|
678
|
+
const tx = { version: 2, locktime: 0, inputs: [{ txid: "11".repeat(32), vout: 0 }],
|
|
679
|
+
outputs: [{ valueSats: 1000n, scriptPubKey: spk }] };
|
|
680
|
+
const sh = taprootKeyPathSighash(tx, 0, [10_000n], [spk]);
|
|
681
|
+
return sh instanceof Uint8Array && sh.length === 32;
|
|
682
|
+
},
|
|
683
|
+
attack: () => {
|
|
684
|
+
// THE EXACT COLLISION the audit reproduced: two txids that are DIFFERENT strings,
|
|
685
|
+
// sharing only a valid hex prefix, must no longer silently collapse to one sighash —
|
|
686
|
+
// they must both be refused outright before any hashing happens.
|
|
687
|
+
const spk = p2trScriptPubKey(new Uint8Array(32).fill(1));
|
|
688
|
+
const txA = { version: 2, locktime: 0, inputs: [{ txid: "ab", vout: 0 }],
|
|
689
|
+
outputs: [{ valueSats: 1000n, scriptPubKey: spk }] };
|
|
690
|
+
const txB = { version: 2, locktime: 0,
|
|
691
|
+
inputs: [{ txid: "abzzzzzzzzzzzzzz-not-a-real-txid-at-all", vout: 0 }],
|
|
692
|
+
outputs: [{ valueSats: 1000n, scriptPubKey: spk }] };
|
|
693
|
+
const aThrew = threw(() => taprootKeyPathSighash(txA, 0, [10_000n], [spk]));
|
|
694
|
+
const bThrew = threw(() => taprootKeyPathSighash(txB, 0, [10_000n], [spk]));
|
|
695
|
+
return aThrew && bThrew;
|
|
696
|
+
},
|
|
697
|
+
note: "Round 17 (this audit, independent subagent finding, reproduced by hand before " +
|
|
698
|
+
"acting on it): serializeInputs already validated txid shape; commonSighashParts " +
|
|
699
|
+
"— used by BOTH the key-path and script-path signing flows, and the one " +
|
|
700
|
+
"policy.mjs's buildSpendContext calls on caller-supplied txid values before a " +
|
|
701
|
+
"holder ever signs — did not. The two code paths disagreed about what counts as a " +
|
|
702
|
+
"valid txid; now they agree.",
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
id: "R18-fee-rate-and-fee-amount-are-bounded",
|
|
706
|
+
module: "fees.mjs + broadcast.mjs",
|
|
707
|
+
statement: "selectCoins() refuses a fee rate above a documented sanity ceiling " +
|
|
708
|
+
"(MAX_FEE_RATE_SAT_PER_VB), and independently refuses to build a transaction " +
|
|
709
|
+
"whose fee would exceed the amount actually being sent — before this round, " +
|
|
710
|
+
"neither was bounded anywhere in the call chain from a fetched fee rate to a " +
|
|
711
|
+
"signed, broadcastable transaction.",
|
|
712
|
+
proof: () => {
|
|
713
|
+
// A normal, sane request is unaffected by either new bound.
|
|
714
|
+
const utxos = [{ txid: "11".repeat(32), vout: 0, valueSats: 100_000_000 }];
|
|
715
|
+
const r = selectCoins(utxos, 10_000, 5);
|
|
716
|
+
return r.hasChange === true && r.fee > 0 && r.fee < 10_000;
|
|
717
|
+
},
|
|
718
|
+
attack: () => {
|
|
719
|
+
// TWO SEPARATELY ISOLATED scenarios — round 5's lesson, applied to my own claim while
|
|
720
|
+
// writing it: the first draft of this attack used a fee rate so extreme (500,000
|
|
721
|
+
// sat/vB) that the fee-vs-target guard ALSO fired, so removing the rate ceiling alone
|
|
722
|
+
// left the mutation matrix unable to kill this claim through that path — the attack
|
|
723
|
+
// "held" for the wrong reason. Each scenario below trips exactly one guard.
|
|
724
|
+
const utxos = [{ txid: "22".repeat(32), vout: 0, valueSats: 100_000_000 }];
|
|
725
|
+
// Scenario A: rate is above the ceiling (10,000), but the resulting fee (~154 vB *
|
|
726
|
+
// 15,000 ≈ 2.3M sats) stays comfortably BELOW this target (3,000,000) — only the rate
|
|
727
|
+
// ceiling can catch this one.
|
|
728
|
+
const rateCeilingHeld = threw(() => selectCoins(utxos, 3_000_000, MAX_FEE_RATE_SAT_PER_VB + 5_000));
|
|
729
|
+
// Scenario B: rate (50) is far below the ceiling, but the resulting fee (~154 vB * 50
|
|
730
|
+
// ≈ 7,700 sats) exceeds this tiny target (100) — only the fee-vs-target guard can
|
|
731
|
+
// catch this one. THE EXACT SHAPE THE AUDIT REPRODUCED, scaled down: a rate that is
|
|
732
|
+
// individually plausible still wrecking a small payment.
|
|
733
|
+
const feeVsTargetHeld = threw(() => selectCoins(utxos, 100, 50));
|
|
734
|
+
return rateCeilingHeld && feeVsTargetHeld;
|
|
735
|
+
},
|
|
736
|
+
note: "Round 18 (this audit, independent subagent finding — broadcast.mjs/fees.mjs had " +
|
|
737
|
+
"zero registered claims or UNCLAIMED_GUARDS entries before this round, so this is " +
|
|
738
|
+
"new coverage for a real, previously-invisible fund-loss risk, not a re-report). " +
|
|
739
|
+
"Two independent bounds, not one: the rate ceiling alone does not protect a small " +
|
|
740
|
+
"payment against a rate that is individually plausible but still disproportionate, " +
|
|
741
|
+
"so selectCoins also refuses outright when the computed fee would exceed the " +
|
|
742
|
+
"amount being sent — no legitimate payment intentionally costs more in fees than " +
|
|
743
|
+
"it sends. fetchFeeRate() in broadcast.mjs enforces the same rate ceiling " +
|
|
744
|
+
"independently, for callers who use it directly rather than through selectCoins.",
|
|
745
|
+
},
|
|
417
746
|
{
|
|
418
747
|
id: "R11-finalized-witness-independently-verifies",
|
|
419
748
|
module: "m2-broadcast.mjs",
|
|
@@ -839,8 +1168,12 @@ export const UNCLAIMED_GUARDS = Object.freeze([
|
|
|
839
1168
|
{ where: "ots.mjs", guard: "the full OpenTimestamps opcode/branch semantics",
|
|
840
1169
|
why: "covered by the reference-implementation vector (block 358391) and by MAX_* bounds, " +
|
|
841
1170
|
"but no claim names the per-opcode behaviour" },
|
|
842
|
-
|
|
843
|
-
|
|
1171
|
+
// REMOVED (post-round-14): "verifySpend's own domain separation (SPEND vs COMMIT)" used to
|
|
1172
|
+
// live here, described as "tested... but not registered as a published sentence" — which
|
|
1173
|
+
// undersold the gap: test/vectors.mjs checked encoding DETERMINISM only, never the actual
|
|
1174
|
+
// cross-domain replay. R14-commit-and-spend-domains-do-not-collide closes it with a real
|
|
1175
|
+
// attack (replaying a genuine COMMIT signature as a SPEND authorisation, and back) and a
|
|
1176
|
+
// mutation that collapses the two domain tags.
|
|
844
1177
|
{ where: "transparency.mjs", guard: "RFC 6962 inclusion/consistency arithmetic itself",
|
|
845
1178
|
why: "covered by 33 CC0 conformance vectors, which is a stronger artefact than a claim, " +
|
|
846
1179
|
"but the vectors are not wired into the ledger" },
|
|
@@ -889,27 +1222,32 @@ export const UNCLAIMED_GUARDS = Object.freeze([
|
|
|
889
1222
|
"which key is valid NOW, multisig asks how many keys must agree — and a design that " +
|
|
890
1223
|
"supports both together (replace one of N signers without invalidating the others) " +
|
|
891
1224
|
"does not exist yet in prose or code." },
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1225
|
+
// REMOVED (post-round-11): "finalize + broadcast for a script-path (M2) spend — NOT
|
|
1226
|
+
// IMPLEMENTED" used to live here. Round 11 closed it: m2-broadcast.mjs rebuilds the exact
|
|
1227
|
+
// witness a decision authorised (never a caller-supplied one), serializes the transaction,
|
|
1228
|
+
// and hands it to broadcast.mjs — proven end to end by
|
|
1229
|
+
// R11-finalized-witness-independently-verifies (a hand-rolled TapLeaf+TapTweak
|
|
1230
|
+
// recomputation sharing no code with the module under test). Leaving this entry claiming
|
|
1231
|
+
// "not implemented" after the gap closed would itself be exactly the prose-vs-code drift
|
|
1232
|
+
// this file exists to catch — found stale during the 2026-09-04 gap sweep.
|
|
1233
|
+
// NARROWED (post-round-13): "policyKey custody, and singlePolicyKeyPerHolder is not
|
|
1234
|
+
// enforced" used to describe TWO distinct gaps as one. Round 13 closed the second:
|
|
1235
|
+
// policy-key-registry.mjs is external, stateful enforcement that a policy key stays bound
|
|
1236
|
+
// to the classical key that first claimed it — proven by
|
|
1237
|
+
// R13-policy-key-registry-refuses-cross-holder-reuse, a genuine two-holder attack (not one
|
|
1238
|
+
// that dies on an unrelated guard). What remains open, listed honestly rather than folded
|
|
1239
|
+
// into a "done" entry:
|
|
1240
|
+
{ where: "policy.mjs / policy-key-registry.mjs", guard: "policyKey custody (the raw secret itself)",
|
|
901
1241
|
why: "The Taproot internal key is the unspendable NUMS point, so policyPub in the leaf " +
|
|
902
1242
|
"script is the ONLY spending path — and it is the SAME Schnorr key for every holder " +
|
|
903
|
-
"under one custodian. Losing or leaking
|
|
904
|
-
"serves, not just one holder
|
|
905
|
-
"
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
"
|
|
909
|
-
"
|
|
910
|
-
"
|
|
911
|
-
"
|
|
912
|
-
"
|
|
913
|
-
"branch that built that field could never run) — removed rather than left as prose " +
|
|
914
|
-
"describing a possibility the code cannot reach. See POLICY_SCOPE.policyKeyCustody." },
|
|
1243
|
+
"under one custodian. Losing or leaking the raw secret freezes or drains everyone " +
|
|
1244
|
+
"that custodian serves, not just one holder. HSM or multisig custody of that secret " +
|
|
1245
|
+
"is Tranche 2 paid engineering (the letter says so) and is NOT what Round 13 built." },
|
|
1246
|
+
{ where: "policy-key-registry.mjs", guard: "the wrapper is opt-in, not enforced by policy.mjs",
|
|
1247
|
+
why: "A caller can still call authorizeAndSign directly, skip the registry entirely, and " +
|
|
1248
|
+
"get the old, unenforced behaviour — nothing in policy.mjs requires going through " +
|
|
1249
|
+
"the wrapper. That is a deliberate architectural choice (the decision engine stays " +
|
|
1250
|
+
"stateless and network/storage-free; see the R12/R13 notes), but it means 'one " +
|
|
1251
|
+
"policy key per holder' is only as real as the custodian's integration choosing to " +
|
|
1252
|
+
"call createPolicyKeyRegistry().authorize() instead of authorizeAndSign() directly." },
|
|
915
1253
|
]);
|