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 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) a real answer for `policyKey`
238
- custody (today a single Schnorr key per custodian — losing or leaking it is custodian-wide,
239
- not just holder-wide, and nothing here enforces the `singlePolicyKeyPerHolder` convention
240
- callers are trusted to uphold); (e) the funded testnet broadcast itself.
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: 379 real checks, no mocks
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.10.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 # 379 verificaciones, sin mocks
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. **Doce rondas de asedio adversarial** contra nuestro propio código encontraron diez bugs reales, explotables — aproximadamente 1.150 exploits ejecutados a mano entre esos diez — incluido uno donde un cliente legítimo de un custodio podría haber obtenido una firma sobre la moneda de otro cliente. 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.
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 diez veces ya, la más reciente en los días antes de este correo; eso no es una política hipotética.
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.11.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 # 379 checks, no mocks
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. **Twelve adversarial siege rounds** against our own code found ten real, exploitable bugs — roughly 1,150 hand-run exploits across those ten — including one where a legitimate customer of a custodian could have obtained a signature over a different customer's coin. Every one of those bugs is now a permanent regression test: `claims --mutate` reintroduces the exact bug and confirms it still gets caught.
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 ten times already, the most recent one in the days before this email; that's not a hypothetical policy.
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.11.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
- /** Recommended fee rate (sat/vB). Falls back to 1 on parse issues. */
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
- return Math.max(1, Math.ceil(j[tier] ?? j.hourFee ?? 1));
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. */
@@ -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 { taprootScriptPathSighash } from "./tx.mjs";
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
- { where: "migration-envelope.mjs", guard: "verifySpend's own domain separation (SPEND vs COMMIT)",
843
- why: "tested in test/vectors.mjs but not registered as a published sentence" },
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
- { where: "policy.mjs (nothing downstream of it exists yet)", guard: "finalize + broadcast for a script-path (M2) spend",
893
- why: "NOT IMPLEMENTED. authorizeAndSign returns a raw Schnorr signature over a digest it " +
894
- "computed; nothing in this package assembles that signature, the policy leaf, and the " +
895
- "control block into a witness, serializes the transaction, or hands it to " +
896
- "broadcast.mjs. scriptPathWitness() (tapscript.mjs) is called exactly once in the " +
897
- "whole repo, in one test, only to check its shape — its output never reaches a " +
898
- "serializer. A custodian cannot attempt a real M2 spend, even in dry-run, until this " +
899
- "exists; it is the first thing anyone integrating M2 needs." },
900
- { where: "policy.mjs", guard: "policyKey custody, and singlePolicyKeyPerHolder is not enforced",
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 it freezes or drains everyone that custodian " +
904
- "serves, not just one holder; the letter's Tranche 2 discusses only holder-side key " +
905
- "loss. Separately, every call site passes singlePolicyKeyPerHolder: true, but " +
906
- "authorizeAndSign never reads that field — verified live, calling it with or without " +
907
- "the field produces byte-identical behaviour. The 'concentration, not elimination' " +
908
- "security story this whole package tells depends on one policy key mapping to " +
909
- "exactly one holder, and today that is pure operator convention outside the library, " +
910
- "with no detection if it's violated. The receipt used to echo a fake acknowledgement " +
911
- "of the caller's assertion (`singlePolicyKeyPerHolderAsserted: true`) on a code path " +
912
- "that turned out to be dead (spendContext has been mandatory since round 8, so the " +
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
  ]);