fractal-pqc 0.8.0 → 0.9.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
@@ -134,15 +134,35 @@ so the property is never silently over-claimed.
134
134
 
135
135
  ## What this is NOT
136
136
 
137
- > **ONE POLICY KEY PER HOLDER — OR BIND THE OUTPUT.** `authorizeAndSign` reads a 32-byte
138
- > `spendDigest` chosen by the requester. Round 7 of our own siege used that to have one
139
- > *legitimate* client of a custodian obtain a policy-key signature over **another client's
140
- > sighash** — which is exactly the "concentration" deployment this package describes. The
141
- > engine now **refuses any request that does not say which coin it is for**: supply
142
- > `spendContext.prevoutScriptPubKey` (enforced against the holder's own anchored commitment)
143
- > or explicitly assert `singlePolicyKeyPerHolder: true`. Silence is refused. Residual, stated
144
- > rather than hidden: the engine checks the scriptPubKey the commitment governs, it does not
145
- > recompute the BIP-341 sighash from the transaction.
137
+ > **NEVER SIGN A DIGEST YOU DID NOT COMPUTE YOURSELF.** Until 0.9.0 `authorizeAndSign` read a
138
+ > 32-byte `spendDigest` chosen by the requester, and round 7 of our own siege used that to have
139
+ > one *legitimate* client of a custodian obtain a policy-key signature over **another client's
140
+ > sighash**. The round-7 fix compared the presented scriptPubKey against one recomputed from the
141
+ > requester's fact hash — and round 8 walked through it, because **both sides of that comparison
142
+ > were the requester's**.
143
+ >
144
+ > An engine that signs a digest it was handed is not a policy engine, it is a signing **oracle**,
145
+ > and no guard around it helps: every check then compares values the caller controls. So the
146
+ > caller no longer supplies a digest. It supplies the **transaction**:
147
+ >
148
+ > ```js
149
+ > authorizeAndSign({ cert, pqSignatureHex, anchorEvidence, expectedLogId, policyKey,
150
+ > cutoffBlockHeight, blockMerkleRoots,
151
+ > spendContext: { tx, inputIndex, prevoutValues, prevoutScriptPubKeys } })
152
+ > ```
153
+ >
154
+ > `spendContext` is **mandatory** and a request carrying `spendDigest` is refused at the door.
155
+ > The engine recomputes the Taproot output key from the holder's **anchored** first-seen fact
156
+ > hash, requires the prevout at `inputIndex` to be that coin, and then derives the BIP-341
157
+ > sighash **itself**. Use `buildSpendContext()` — the same helper a custodian uses — so the
158
+ > holder signs exactly the bytes the policy key will sign.
159
+ >
160
+ > **THE TWO RESIDUALS, stated rather than hidden.** (1) The engine imposes **nothing on the
161
+ > outputs**: it proves *whose* coin is spent and *who* authorised it, never *where* the value
162
+ > goes. (2) `buildSpendContext` builds a **single-input** transaction while the engine accepts
163
+ > multi-input ones, so for those the holder cannot re-derive the digest with the shipped helper
164
+ > and would be signing a digest the custodian computed — the very mistake this section is about.
165
+ > **The holder must inspect the transaction, not merely sign the digest they were handed.**
146
166
  (honest scope — do not overstate)
147
167
 
148
168
  - **Not yet confirmed in a live mempool.** The sighash is proven consensus-correct against
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", "bip341-scriptpath.mjs", "claims.mjs", "letter-claims.mjs"]) {
240
+ for (const f of ["transparency.mjs", "primacy.mjs", "anchoring.mjs", "conformance.mjs", "m2-policy.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);
@@ -247,6 +247,14 @@ switch (cmd) {
247
247
 
248
248
  // Run every checkable assertion in the letter against this package. The letter cannot be
249
249
  // sent if this is not green — which is the entire point of building it.
250
+ // The whole story in one run: identity -> commitment -> log -> Bitcoin anchor ->
251
+ // offline verification -> the custodian decision, and the six refusals. The enforcement
252
+ // point had no CLI verb at all, so a reviewer could read about it and never run it.
253
+ case "demo": {
254
+ const { runDemo } = await import("../src/demo.mjs");
255
+ process.exit(await runDemo());
256
+ }
257
+
250
258
  case "verify-letter": {
251
259
  const { verifyLetter } = await import("../src/verify-letter.mjs");
252
260
  const { SHIPPED_LETTER } = await import("../src/letter-claims.mjs");
@@ -451,8 +459,16 @@ Usage:
451
459
  shipped here and proves each claim DIES when the
452
460
  code it names is broken. A claim no mutation can
453
461
  kill is vacuous, and is reported as such.
462
+ fractal-pqc demo THE WHOLE STORY, offline, in one run: identity,
463
+ dual-signed commitment, transparency log, Bitcoin
464
+ anchor, offline verification, and the custodian
465
+ policy engine authorising the rightful holder and
466
+ REFUSING six attacks — including a post-quantum
467
+ adversary holding the victim's classical key.
468
+ fractal-pqc verify-letter [file] Check every factual assertion in our letter
469
+ against this package. Exits non-zero if any fails.
454
470
  fractal-pqc verify-vector Check the official BIP-340 test vector
455
- fractal-pqc selftest Run everything: 315 real checks, no mocks
471
+ fractal-pqc selftest Run everything: 331 real checks, no mocks
456
472
 
457
473
  Docs: integrations/pqc-migration-kit/README.md`);
458
474
  process.exit(cmd ? 1 : 0);
@@ -14,11 +14,11 @@ Three layers. Two exist and are published. The third does not exist, and I won't
14
14
 
15
15
  **Layer 3 — Consensus (does NOT exist).** Bitcoin does not reject a spend for lacking a PQ signature, and nothing we ship changes that. I'll be blunter than the pitch requires: the only thing consensus ever checks is a classical Schnorr signature on the policy key. A quantum adversary who reaches that key spends anyway. What this construction actually buys is **concentration** — N exposed classical keys become one hash-hidden, rotatable, custodian-controlled key — plus a post-quantum-signed record of who was authorised to make that key sign, giving the custodian a cryptographic reason to refuse everyone else. That is a real reduction in attack surface and a governance mechanism. **It is not immunity, and we removed the sentence that said it was.**
16
16
 
17
- One correction I owe you inside this same paragraph, because it is the finding of our seventh siege and it lands squarely on the sentence above. That sentence used to read "a record of who *should have been allowed to spend*", and until days before this email that was an overclaim: the engine received the spend digest as 32 opaque bytes and never learned **which output** it was signing for. Our own siege stood up a second, entirely legitimate client of the same custodian and had it obtain a policy-key signature over the **first client's** sighash. Every check passed. The concentration this paragraph describes — one policy key across many leaves — is precisely the deployment in which that is theft. It is fixed in `0.8.0`: the leaf commits to the holder's own anchored fact hash, the engine recomputes the output key from it, and **a request that does not say which coin it is for is refused** rather than defaulted. A footgun you step on by omission is a fail-open default with a paragraph next to it.
17
+ One correction I owe you inside this same paragraph, because it is the finding of our seventh siege and it lands squarely on the sentence above. That sentence used to read "a record of who *should have been allowed to spend*", and until days before this email that was an overclaim: the engine received the spend digest as 32 opaque bytes and never learned **which output** it was signing for. Our own siege stood up a second, entirely legitimate client of the same custodian and had it obtain a policy-key signature over the **first client's** sighash. Every check passed. The concentration this paragraph describes — one policy key across many leaves — is precisely the deployment in which that is theft. It is fixed in `0.9.0`: the leaf commits to the holder's own anchored fact hash, the engine recomputes the output key from it, and **a request that does not say which coin it is for is refused** rather than defaulted. A footgun you step on by omission is a fail-open default with a paragraph next to it.
18
18
 
19
19
  **2) You can run all of it, right now, without believing any of this**
20
20
 
21
- Including this email. It makes fourteen factual assertions — every count, every block
21
+ Including this email. It makes eighteen factual assertions — every count, every block
22
22
  height, every version, and five sentences about what the engine does. Each one is registered
23
23
  with an executable check that reads the asserted value **out of this letter** and compares it
24
24
  to a value measured from the package. Change a number in the letter and the check reads the
@@ -31,22 +31,38 @@ npx fractal-pqc verify-letter # this email, checked against the code, on your
31
31
 
32
32
  The letter you are reading ships inside the package, so you can also confirm nothing was
33
33
  altered between my sending it and your reading it. **If any assertion fails, this email is
34
- wrong — and you will know before I do.** It also prints, in the same output, the four things
35
- in here that no check covers: two judgements, one hand-counted aggregate, and every
36
- forward-looking commitment. Those are opinions and promises, and we refuse to report them as
37
- verified.
34
+ wrong — and you will know before I do.** It also prints, in the same output, the five things in here that no check covers: two judgements, one hand-counted aggregate, one historical figure that can no longer be re-measured, and every forward-looking commitment. Those are opinions, memories and promises, and we refuse to report them as verified.
38
35
 
39
36
  I built that tool because of the disease in section 5, and I built it for this letter
40
37
  specifically. It found three false numbers in my own draft before you saw it.
41
38
 
42
39
  ```
43
- npm i fractal-pqc@0.8.0
40
+ npm i fractal-pqc@0.9.0
41
+ npx fractal-pqc demo # the whole path, offline, in 20 seconds — see below
44
42
  npx fractal-pqc claims # every security claim we make, each with an attack
45
43
  npx fractal-pqc claims --gaps # and what the green does NOT cover
46
44
  npx fractal-pqc claims --mutate # break the code, watch the sentences die
47
- npx fractal-pqc selftest # 315 checks, no mocks
45
+ npx fractal-pqc selftest # 331 checks, no mocks
48
46
  ```
49
47
 
48
+ **Start with `demo`.** It is the answer to your question, executed rather than described.
49
+ In one offline run it generates a holder identity, dual-signs a migration commitment, appends
50
+ it to an RFC 6962 log, signs the head with ML-DSA-65, anchors it, verifies the anchor by
51
+ recomputing everything — and then runs the custodian policy engine. That last part is the
52
+ point, and it is why most of what you will watch are **refusals**: the engine authorises the
53
+ rightful holder once, and then refuses six times. Including this one, which is the attack the
54
+ whole design exists for:
55
+
56
+ > *a post-quantum adversary who already HOLDS the victim's classical key, produces a genuinely
57
+ > valid dual-signed rebinding, and an honest append-only log accepts it — and the engine still
58
+ > refuses, because the attacker loses on PRIMACY, not on the signature.*
59
+
60
+ The demo names the synthetic parts (there is no network call, so the Bitcoin attestation is
61
+ constructed locally and it says so on the line where it matters), and it ends by naming the
62
+ ceiling: consensus only ever checks a classical Schnorr signature, so this is not immunity.
63
+ It is also a test — `test/demo.mjs` fails the build if any of those six refusals ever starts
64
+ authorising, which is the only reason I am willing to put it in this email.
65
+
50
66
  Published today. `verify-anchor` verifies a bundle offline: the tree head's ML-DSA-65 signature, the inclusion proof with the Merkle root **recomputed** rather than compared to one we hand you, an RFC 6962 consistency proof that no history was rewritten, and a Bitcoin anchor derived from the OpenTimestamps proof alone — height and Merkle root, which you then check against a header from **your own node**. We are not in that path.
51
67
 
52
68
  A bundle ships in the package so you can run that path end to end;
@@ -76,7 +92,7 @@ What remains is, deliberately, **precisely what we cannot do alone**:
76
92
  | # | What remains | Why money is required for it | Amount |
77
93
  |---|---|---|---|
78
94
  | ~~M1~~ | ~~Bitcoin-anchored first-seen registry~~ | **DELIVERED — `npx fractal-pqc verify-anchor`** | **$0** |
79
- | **M2** | **Exposure-relative primacy, in a real custodian's signing flow** — the design is below, and the primitive is already shipped and enforced in `fractal-pqc@0.8.0`. What remains is a *custodian* running it against funded testnet spends | The code, the BIP-341 conformance and the gate exist. What is missing is a custodian, and custodian engineering time is what money buys | $120,000 |
95
+ | **M2** | **Exposure-relative primacy, in a real custodian's signing flow** — the design is below, and the primitive is already shipped and enforced in `fractal-pqc@0.9.0`. What remains is a *custodian* running it against funded testnet spends | The code, the BIP-341 conformance and the gate exist. What is missing is a custodian, and custodian engineering time is what money buys | $120,000 |
80
96
 
81
97
  **Tranche 2 — $80,000** — **independent external review**, published in full including negative findings, plus multisig PSBT and key rotation (today a lost ML-DSA secret is a permanent lockout, and the package says so). We cannot review ourselves; this is the one deliverable whose entire value comes from it not being us.
82
98
  **Tranche 3 — $120,000** — public specification, wallet interoperability, long-term maintenance of the vectors. Success measured by independent adopters, not by our own claims.
@@ -95,6 +111,8 @@ Where that inequality holds, the proof depends on **no Q-day date**: at the mome
95
111
 
96
112
  It is wired as a **gate**, not a report: `authorizeAndSign({ …, exposureHeight })` refuses and releases no signature when the commitment was anchored after exposure. Supplied means **enforced** — a parameter that can be passed and silently dropped is worse than one that does not exist.
97
113
 
114
+ And one structural change our eighth siege forced, which I would rather you heard from me: **the engine computes the digest it signs and refuses any request that carries one.** Until last week it accepted a 32-byte digest from the caller, which makes it a signing *oracle* — every check it performs then compares two values the caller controls. Our own siege used that to have one legitimate client of a custodian obtain a policy-key signature over another client's coin. The caller now supplies the transaction; the engine derives the BIP-341 sighash itself, over the prevout it verified belongs to that holder.
115
+
98
116
  And the precise shape of it, which our seventh siege made us state properly: **this gate is a conjunction, never a substitution.** The cutoff you choose stays mandatory and is still applied afterwards, so a commitment anchored at 800,000 with exposure at 850,000 — the strongest position this scheme can give anyone — is still refused under a cutoff of 790,000. Supplying an exposure height *narrows* what authorises; it never rescues what the cutoff rejects. What is true, and is the whole point, is that the second condition depends on no Q-day estimate. The receipt now says exactly that, in those words, instead of the flatter sentence it used to print.
99
117
 
100
118
  The claim is bounded, and we say so in the code itself:
@@ -116,7 +134,7 @@ We are not the standard, and anyone claiming to be one at this stage is selling.
116
134
 
117
135
  **Ours is the only package I know of that will tell you, on your machine, which of its own security claims it cannot back.** A claim is admissible only with three things: an executable proof, an executable *attack* that must fail, and a **mutation** of the code it names under which the claim must fail. A sentence no mutation can kill is reported as vacuous and the build breaks.
118
136
 
119
- We built that because we needed it. Seven adversarial siege rounds against our own code — roughly 900 executed exploits — found the same disease every time and never once in the mathematics: an English sentence and a code path written separately, with a fully green test suite in between hiding the gap. Round 5 deleted a single line binding an anchor to the head it timestamps; the ledger printed all-green and 263 assertions passed, because no claim named that guard. Round 7 — run against the very paragraph in section 3b, in the days before this email — found the cross-client signature described in section 1, and found a frozen scope string still telling auditors that the M2 gate did not exist while the gate was refusing signatures. Every mutation in the matrix reintroduces a bug that really shipped in this package, tagged with the round that caught it. **The matrix is simultaneously our test harness and the public record of our own failures.**
137
+ We built that because we needed it. Ten adversarial siege rounds against our own code — roughly 1,150 executed exploits — found the same disease every time and never once in the mathematics: an English sentence and a code path written separately, with a fully green test suite in between hiding the gap. Round 5 deleted a single line binding an anchor to the head it timestamps; the ledger printed all-green and 263 assertions passed, because no claim named that guard. Round 7 — run against the very paragraph in section 3b, in the days before this email — found the cross-client signature described in section 1, and found a frozen scope string still telling auditors that the M2 gate did not exist while the gate was refusing signatures. Round 10, run against this exact letter before it was sent: `cutoffBlockHeight`, `blockMerkleRoots` and `knownHeads` were each read straight off the request at every point they were needed, instead of being snapshotted once the way `anchorEvidence` already was — and for `knownHeads` that was not a theoretical gap. An accessor that shows a genuinely conflicting log head to the length check and an empty array to the loop that actually runs equivocation detection produced a real, verifiable ML-DSA-gated Schnorr signature in exactly the case section 4's limit #4 promises a refusal for. All three fields now get the same snapshot-once discipline. The `knownHeads` and `cutoffBlockHeight` exploits are each closed and covered by a mutation that turns the ledger red if either regresses. `blockMerkleRoots` got the identical fix on the identical reasoning, but we could not construct an attack that flips authorisation through it alone — every consumer looks the anchor height up by key, so a divergent read fails closed rather than open — and we are not shipping a mutation we cannot honestly make fail; that field's hardening is disclosed as defense-in-depth, not as the closure of a demonstrated bypass. Every mutation in the matrix reintroduces a bug that really shipped in this package, tagged with the round that caught it. **The matrix is simultaneously our test harness and the public record of our own failures.**
120
138
 
121
139
  I am telling you about round 7 in the letter that asks you for money, before you could possibly have found it yourself, because the alternative is a commitment on the next page that would be worth nothing.
122
140
 
@@ -125,7 +143,7 @@ That is also why the retraction in section 1 is a *test*, not a footnote: the ol
125
143
  **Four commitments that go with the money**
126
144
 
127
145
  1. **Every deliverable arrives with its own post-quantum receipt, Bitcoin-anchored.** You verify our work using the mechanism you're funding.
128
- 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 seven times, the most recent one in the days before this email; that's not a hypothetical policy.
146
+ 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, the most recent one in the days before this email; that's not a hypothetical policy.
129
147
  3. **Vectors stay CC0.** 33 of them are already published. Whoever runs them owns their own verification.
130
148
  4. **Non-dilutive, open source, no exclusivity.**
131
149
 
package/package.json CHANGED
@@ -1,15 +1,13 @@
1
1
  {
2
2
  "name": "fractal-pqc",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
7
7
  "author": "FractalAI",
8
8
  "homepage": "https://fractalai.net.co",
9
- "repository": {
10
- "type": "git",
11
- "url": "https://github.com/fractalai/fractal-ai",
12
- "directory": "integrations/pqc-migration-kit"
9
+ "bugs": {
10
+ "email": "helloinvestor@fractalai.net.co"
13
11
  },
14
12
  "engines": {
15
13
  "node": ">=18"
@@ -44,14 +42,15 @@
44
42
  "vectors"
45
43
  ],
46
44
  "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/bip341-scriptpath.mjs && node test/claims.mjs && node test/letter-claims.mjs",
45
+ "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/bip341-scriptpath.mjs && node test/claims.mjs && node test/demo.mjs && node test/letter-claims.mjs",
48
46
  "selftest": "node bin/cli.mjs selftest",
49
47
  "conformance": "node test/conformance.mjs",
50
48
  "claims": "node bin/cli.mjs claims",
51
49
  "mutate": "node bin/cli.mjs claims --mutate",
52
50
  "attribution": "node bin/cli.mjs claims --attribution",
53
51
  "gaps": "node bin/cli.mjs claims --gaps",
54
- "verify-letter": "node bin/cli.mjs verify-letter"
52
+ "verify-letter": "node bin/cli.mjs verify-letter",
53
+ "demo": "node bin/cli.mjs demo"
55
54
  },
56
55
  "dependencies": {
57
56
  "@noble/curves": "^2.2.0",
@@ -10,9 +10,11 @@ import { sha256 } from "@noble/hashes/sha2.js";
10
10
  import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
11
11
  import * as T from "./transparency.mjs";
12
12
  import * as P from "./primacy.mjs";
13
+ import { certIsFirstSeen } from "./primacy.mjs";
14
+ const toHexLocal = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
13
15
  import * as TS from "./tapscript.mjs";
14
16
  import * as OTS from "./ots.mjs";
15
- import { POLICY_SCOPE, authorizeAndSign, REFUSED, policyOutputScriptPubKey } from "./policy.mjs";
17
+ import { POLICY_SCOPE, authorizeAndSign, REFUSED, policyOutputScriptPubKey, buildSpendContext } from "./policy.mjs";
16
18
  import { schnorr } from "@noble/curves/secp256k1.js";
17
19
  import { generateMigrationIdentity, createMigrationCommitment, authorizeSpend } from "./migration-envelope.mjs";
18
20
 
@@ -60,19 +62,29 @@ const REAL_ROOT = (() => {
60
62
  const HEADERS = { [ANCHOR_HEIGHT]: REAL_ROOT };
61
63
  const CUTOFF = 900_000;
62
64
  const POLICY_KEY = new Uint8Array(32).fill(0x2b);
63
- const DIGEST = sha256(te.encode("a real spend"));
64
- const SPEND_SIG = authorizeSpend(ident, DIGEST);
65
+ // The anchored first-seen fact hash is what the holder's output key is derived from, so it
66
+ // has to be resolved before a spend context can exist. Round 8 removed the option of making
67
+ // one up: there is no digest to hand in any more.
68
+ const ANCHORED_FH = certIsFirstSeen({
69
+ cert, entries: ENTRIES, sth: STH, expectedLogId: STH.logId, cutoffBlockHeight: CUTOFF,
70
+ otsHex: OTS_HEX, blockMerkleRoots: HEADERS, requireTemporalFrontier: true,
71
+ }).anchoredFactHash;
72
+ const SC = buildSpendContext({
73
+ anchoredFactHash: ANCHORED_FH, policyPublicKey: schnorr.getPublicKey(POLICY_KEY),
74
+ txid: "11".repeat(32), valueSats: 100_000n,
75
+ outputs: [{ valueSats: 90_000n, scriptPubKey: new Uint8Array(34) }],
76
+ });
77
+ const SPEND_SIG = authorizeSpend(ident, SC.digest);
65
78
 
66
79
  /** A request that ACTUALLY AUTHORISES. Attacks perturb exactly one field of it. */
67
80
  const goodReq = () => ({
68
- cert, spendDigest: DIGEST, pqSignatureHex: SPEND_SIG,
81
+ cert, pqSignatureHex: SPEND_SIG,
69
82
  anchorEvidence: { sth: STH, entries: ENTRIES, otsHex: OTS_HEX },
70
83
  expectedLogId: STH.logId, policyKey: POLICY_KEY,
71
84
  cutoffBlockHeight: CUTOFF, blockMerkleRoots: HEADERS,
72
- // Round 7 made this explicit rather than defaulted: the engine refuses a request that
73
- // does not say WHICH coin it is for. These fixtures are single-holder scenarios, so they
74
- // assert the safe deployment instead of silently relying on it.
75
- singlePolicyKeyPerHolder: true,
85
+ // Round 8: the engine COMPUTES the digest from this. There is no digest field any more,
86
+ // because a digest the caller chooses is a signing oracle no matter what guards surround it.
87
+ spendContext: SC.spendContext,
76
88
  });
77
89
 
78
90
  export const CLAIMS = [
@@ -232,43 +244,181 @@ export const CLAIMS = [
232
244
  {
233
245
  id: "R7-output-binding-or-refusal",
234
246
  module: "policy.mjs",
235
- statement: "The engine never signs without knowing WHICH coin it signs for. Either the " +
236
- "caller binds the spend to an output (enforced against the holder's own " +
237
- "anchored commitment) or the caller explicitly asserts one policy key per " +
238
- "holder. Silence is refused, and one holder's certificate can never authorise " +
239
- "a spend of another holder's output.",
247
+ statement: "The engine COMPUTES the digest it signs; it never accepts one. It derives " +
248
+ "the BIP-341 sighash from the transaction it was shown, over the prevout it " +
249
+ "verified is governed by this holder's own anchored commitment. A request " +
250
+ "carrying `spendDigest` is refused outright, and one holder's certificate " +
251
+ "cannot obtain a signature over another holder's coin.",
240
252
  proof: () => {
241
- // Bound to the output this holder's commitment actually governs: authorises, and the
242
- // receipt SAYS it is bound.
243
- const anchoredFactHash = authorizeAndSign(goodReq()).evidence.anchoredFactHash;
244
- const spk = policyOutputScriptPubKey(anchoredFactHash, schnorr.getPublicKey(POLICY_KEY));
245
- const r = authorizeAndSign({ ...goodReq(), spendContext: { prevoutScriptPubKey: spk } });
246
- return r.authorized === true && r.evidence.outputBound === true;
253
+ // The engine computes the digest. The holder signs THAT SAME digest, because the
254
+ // custodian's helper told them what it would be — neither party chose it.
255
+ const r = authorizeAndSign(goodReq());
256
+ return r.authorized === true && r.evidence.outputBound === true &&
257
+ r.evidence.digestSigned === toHexLocal(SC.digest);
247
258
  },
248
259
  attack: () => {
249
- // 1. Silence must NOT be the permissive default. This is the whole finding: round 7
250
- // had one legitimate client of a custodian buy a signature over another client's
251
- // sighash precisely because omitting the binding authorised anyway.
252
- const { singlePolicyKeyPerHolder: _drop, ...silent } = goodReq();
260
+ // 1. A supplied digest must be refused OUTRIGHT. This is the whole of round 8: an
261
+ // engine that accepts a digest is an oracle, and every check it then performs
262
+ // compares two values the caller controls.
263
+ const withDigest = authorizeAndSign({ ...goodReq(), spendDigest: new Uint8Array(32) });
264
+ if (withDigest.authorized !== false || withDigest.reason !== REFUSED.MALFORMED_REQUEST) return false;
265
+
266
+ // 2. No spendContext at all: refused. Silence is not a safe default.
267
+ const { spendContext: _drop, ...silent } = goodReq();
253
268
  const q = authorizeAndSign(silent);
254
269
  if (q.authorized !== false || q.policySignatureHex !== null) return false;
255
- // 2. A DIFFERENT holder's output must be refused, not signed.
256
- const other = "11".repeat(32);
257
- const foreign = policyOutputScriptPubKey(other, schnorr.getPublicKey(POLICY_KEY));
258
- const r = authorizeAndSign({ ...goodReq(), spendContext: { prevoutScriptPubKey: foreign } });
259
- if (r.authorized !== false || r.policySignatureHex !== null) return false;
260
- // 3. A malformed binding must refuse rather than fall back to the unbound path.
261
- const bad = authorizeAndSign({ ...goodReq(), spendContext: { prevoutScriptPubKey: "00" } });
262
- if (bad.authorized !== false) return false;
263
- // 4. The asserted single-key mode still works, and the receipt does not claim binding.
264
- const a = authorizeAndSign(goodReq());
265
- return a.authorized === true && a.evidence.outputBound === false;
270
+
271
+ // 3. ANOTHER HOLDER'S COIN. The prevout at inputIndex is a real P2TR output governed
272
+ // by a DIFFERENT fact hash. Round 8 obtained a signature over exactly this; it must
273
+ // now be impossible, because the output key no longer derives from this certificate.
274
+ const foreign = buildSpendContext({
275
+ anchoredFactHash: "22".repeat(32), policyPublicKey: schnorr.getPublicKey(POLICY_KEY),
276
+ txid: "33".repeat(32), valueSats: 100_000n,
277
+ outputs: [{ valueSats: 90_000n, scriptPubKey: new Uint8Array(34) }],
278
+ });
279
+ const cross = authorizeAndSign({ ...goodReq(), spendContext: foreign.spendContext });
280
+ if (cross.authorized !== false || cross.reason !== REFUSED.OUTPUT_NOT_BOUND) return false;
281
+
282
+ // 4. And the signature must be over the digest WE computed — never over one that
283
+ // happens to be lying around. Swapping the transaction changes the digest, so the
284
+ // holder's ML-DSA authorisation no longer matches and the spend is refused.
285
+ const other = buildSpendContext({
286
+ anchoredFactHash: ANCHORED_FH, policyPublicKey: schnorr.getPublicKey(POLICY_KEY),
287
+ txid: "44".repeat(32), valueSats: 100_000n,
288
+ outputs: [{ valueSats: 80_000n, scriptPubKey: new Uint8Array(34) }],
289
+ });
290
+ if (toHexLocal(other.digest) === toHexLocal(SC.digest)) return false; // must actually differ
291
+ const mismatched = authorizeAndSign({ ...goodReq(), spendContext: other.spendContext });
292
+ return mismatched.authorized === false;
293
+ },
294
+ note: "Round 8's critical, and the second attempt at it. Round 7 compared a presented " +
295
+ "scriptPubKey against one recomputed from the requester's fact hash — and round 8 " +
296
+ "walked through it, because BOTH SIDES OF THAT COMPARISON WERE THE REQUESTER'S: a " +
297
+ "second legitimate client presented its OWN output alongside the VICTIM's sighash " +
298
+ "and the policy key signed a complete witness for someone else's coin. The law we " +
299
+ "had not applied: NEVER SIGN A DIGEST YOU DID NOT COMPUTE YOURSELF. The caller now " +
300
+ "supplies the transaction; the engine derives the BIP-341 sighash over the prevout " +
301
+ "it verified belongs to this holder. `spendDigest` is refused outright, and with it " +
302
+ "the entire TOCTOU class that `snapshot32` existed to guard — removing an input is " +
303
+ "stronger than watching it. The round-7 residual this note used to declare — that " +
304
+ "the engine did not recompute the BIP-341 sighash — is CLOSED: it recomputes it now, " +
305
+ "and that is the whole fix.",
306
+ },
307
+ {
308
+ id: "R9-holder-can-reproduce-any-digest",
309
+ module: "policy.mjs",
310
+ statement: "For every transaction this engine will authorise, the holder can derive the " +
311
+ "exact digest themselves with the helper we ship — including multi-input " +
312
+ "spends. A holder who cannot reproduce the digest is signing one the custodian " +
313
+ "computed, which is the mistake this engine stopped making in 0.9.0.",
314
+ proof: () => {
315
+ // Multi-input: the engine accepts these, so the helper must build them.
316
+ const foreignSpk = policyOutputScriptPubKey("22".repeat(32), schnorr.getPublicKey(POLICY_KEY));
317
+ const multi = buildSpendContext({
318
+ anchoredFactHash: ANCHORED_FH, policyPublicKey: schnorr.getPublicKey(POLICY_KEY),
319
+ txid: "11".repeat(32), valueSats: 100_000n,
320
+ outputs: [{ valueSats: 90_000n, scriptPubKey: new Uint8Array(34) }],
321
+ otherInputs: [{ txid: "99".repeat(32), vout: 1, valueSats: 50_000n, scriptPubKey: foreignSpk }],
322
+ });
323
+ if (multi.spendContext.tx.inputs.length !== 2) return false;
324
+ // The holder SEES the other coin and the destinations before signing.
325
+ if (multi.inspect.alsoSpends.length !== 1) return false;
326
+ if (!Array.isArray(multi.inspect.paysOut) || multi.inspect.paysOut.length !== 1) return false;
327
+ const r = authorizeAndSign({ ...goodReq(), spendContext: multi.spendContext,
328
+ pqSignatureHex: authorizeSpend(ident, multi.digest) });
329
+ // The digest the engine signed is EXACTLY the one the holder derived.
330
+ return r.authorized === true && r.evidence.digestSigned === toHexLocal(multi.digest);
331
+ },
332
+ attack: () => {
333
+ // A single-input helper cannot reproduce a two-input digest: if it ever silently
334
+ // returned one anyway, the holder would be signing blind and this must fail.
335
+ const foreignSpk = policyOutputScriptPubKey("22".repeat(32), schnorr.getPublicKey(POLICY_KEY));
336
+ const one = buildSpendContext({ anchoredFactHash: ANCHORED_FH,
337
+ policyPublicKey: schnorr.getPublicKey(POLICY_KEY), txid: "11".repeat(32), valueSats: 100_000n,
338
+ outputs: [{ valueSats: 90_000n, scriptPubKey: new Uint8Array(34) }] });
339
+ const two = buildSpendContext({ anchoredFactHash: ANCHORED_FH,
340
+ policyPublicKey: schnorr.getPublicKey(POLICY_KEY), txid: "11".repeat(32), valueSats: 100_000n,
341
+ outputs: [{ valueSats: 90_000n, scriptPubKey: new Uint8Array(34) }],
342
+ otherInputs: [{ txid: "99".repeat(32), vout: 1, valueSats: 50_000n, scriptPubKey: foreignSpk }] });
343
+ // Different transactions MUST produce different digests, or the sighash is broken.
344
+ if (toHexLocal(one.digest) === toHexLocal(two.digest)) return false;
345
+ // And the ML-DSA authorisation for one must not authorise the other.
346
+ const wrong = authorizeAndSign({ ...goodReq(), spendContext: two.spendContext,
347
+ pqSignatureHex: authorizeSpend(ident, one.digest) });
348
+ return wrong.authorized === false;
349
+ },
350
+ note: "Round 9's blind spot, and nobody had looked at the holder's side in nine rounds. " +
351
+ "The engine stopped accepting digests; the helper still built only single-input " +
352
+ "transactions, so for a multi-input spend the holder had to sign whatever the " +
353
+ "custodian handed them. `inspect` also exists now because a digest you cannot read " +
354
+ "is a digest you cannot refuse: it names the other coins and every destination, " +
355
+ "with the warning that this engine constrains WHOSE coin is spent and never WHERE " +
356
+ "the value goes.",
357
+ },
358
+ {
359
+ id: "R10-every-caller-field-read-once",
360
+ module: "policy.mjs",
361
+ statement: "cutoffBlockHeight, blockMerkleRoots and knownHeads are each read from the " +
362
+ "request exactly once: an accessor that shows one value to a validation guard " +
363
+ "and a different one to the call that actually gates authorisation is refused " +
364
+ "outright, never read twice.",
365
+ proof: () => {
366
+ // Baseline still authorises with the fields supplied plainly — the guard must not
367
+ // reject an honest, non-accessor request.
368
+ return authorizeAndSign(goodReq()).authorized === true;
369
+ },
370
+ attack: () => {
371
+ // 1. knownHeads: a getter shows a genuinely conflicting head to the Array.isArray/.length
372
+ // guard, then an EMPTY array to the for...of that runs detectEquivocation. Before this
373
+ // round, the loop skipped split-view detection entirely and the engine signed.
374
+ const evilSth = T.signTreeHead({ secretKey: kp.secretKey, publicKey: kp.publicKey,
375
+ treeSize: STH.treeSize, rootHash: T.merkleRoot(leaves(9)), timestampMs: STH.timestampMs });
376
+ const req1 = goodReq();
377
+ let reads1 = 0;
378
+ Object.defineProperty(req1, "knownHeads",
379
+ { get() { reads1++; return reads1 <= 2 ? [evilSth] : []; } });
380
+ const r1 = authorizeAndSign(req1);
381
+ if (r1.authorized !== false) return false;
382
+
383
+ // 2. cutoffBlockHeight: a getter shows a strict height (700,000) to the shape/absence
384
+ // guards and a laxer one (950,000) to certIsFirstSeen, which is the read that
385
+ // actually decides whether the anchor is "before" the cutoff.
386
+ const req2 = goodReq();
387
+ let reads2 = 0;
388
+ Object.defineProperty(req2, "cutoffBlockHeight",
389
+ { get() { reads2++; return reads2 <= 2 ? 700_000 : 950_000; } });
390
+ const r2 = authorizeAndSign(req2);
391
+ if (r2.authorized !== false) return false;
392
+
393
+ // 3. blockMerkleRoots: a getter swaps the entire roots object between the shape guard
394
+ // and the calls inside proveExposureRelativePrimacy/certIsFirstSeen that actually
395
+ // index into it by height.
396
+ const req3 = goodReq();
397
+ let reads3 = 0;
398
+ const honestRoots = HEADERS;
399
+ const poisonedRoots = { [CUTOFF - 1]: REAL_ROOT };
400
+ Object.defineProperty(req3, "blockMerkleRoots",
401
+ { get() { reads3++; return reads3 <= 1 ? honestRoots : poisonedRoots; } });
402
+ const r3 = authorizeAndSign(req3);
403
+ if (r3.authorized !== false) return false;
404
+
405
+ // And the accessor guard must never fire on a plain, non-accessor value — refusing an
406
+ // honest request would be a false positive as serious as missing the real one.
407
+ return authorizeAndSign(goodReq()).authorized === true;
266
408
  },
267
- note: "Round 7's critical. `spendDigest` is 32 opaque bytes chosen by the requester; " +
268
- "before this, the receipt named the HOLDER while the coin could be anyone's. " +
269
- "RESIDUAL, stated rather than hidden: the engine checks that the scriptPubKey is " +
270
- "the one this commitment governs, but does not recompute the BIP-341 sighash from " +
271
- "the transaction — the caller must derive the digest from the prevout it presents.",
409
+ note: "Round 10 (this audit): the discipline `anchorEvidence` already had — refuse an " +
410
+ "accessor-backed carrier, snapshot ONCE, read nothing but the snapshot — had not " +
411
+ "been extended to cutoffBlockHeight, blockMerkleRoots or knownHeads. For knownHeads " +
412
+ "and cutoffBlockHeight the exploit was demonstrated to completion and is mutation-" +
413
+ "killable: a real ML-DSA-gated Schnorr signature came out in exactly the case " +
414
+ "`limitFour_historyNotPinned` promises a refusal for. blockMerkleRoots got the same " +
415
+ "fix on the same reasoning, but HONESTLY: no mutation for it is registered in " +
416
+ "mutations.mjs, because every consumer looks the anchor height up BY KEY, so a " +
417
+ "divergent read fails CLOSED (a missing/mismatched height refuses) rather than open " +
418
+ "— we could not construct an attack that flips authorisation via this field alone. " +
419
+ "It is defense-in-depth applied by analogy, not the closure of a demonstrated bypass, " +
420
+ "and mutations.mjs says so rather than shipping a mutation that always reads " +
421
+ "'detected: false'.",
272
422
  },
273
423
  {
274
424
  id: "R7-no-claim-without-the-proof-that-earns-it",
@@ -578,13 +728,27 @@ export const UNCLAIMED_GUARDS = Object.freeze([
578
728
  "blocks) — again stronger than a claim, again not in the ledger" },
579
729
  { where: "policy.mjs", guard: "the ORDER of the refusal guards",
580
730
  why: "each guard is claimed individually; nothing claims that reordering them is safe" },
581
- { where: "policy.mjs", guard: "WHICH OUTPUT is being spent — unless spendContext is supplied",
582
- why: "the engine reads a 32-byte spendDigest chosen by the requester. WITHOUT " +
583
- "`spendContext.prevoutScriptPubKey` the receipt identifies the HOLDER, never the " +
584
- "COIN: it records who was authorised to make the policy key sign SOMETHING. Round 7 " +
585
- "used exactly this to have one legitimate client of a custodian buy a signature over " +
586
- "another client's sighash. Supply spendContext (enforced when present) or use ONE " +
587
- "POLICY KEY PER HOLDER, without exception" },
731
+ { where: "policy.mjs", guard: "buildSpendContext is single-input; the engine is not",
732
+ why: "ROUND 9. The helper we ship — the only one the package documents so that 'the holder " +
733
+ "signs what the custodian will sign' — hardcodes one input and inputIndex 0, while " +
734
+ "authorizeAndSign accepts and authorises multi-input transactions. For those the " +
735
+ "holder cannot reproduce the digest with anything we gave them, so they must sign one " +
736
+ "the custodian computed. Measured: engine authorises the 2-input tx, the other input " +
737
+ "is not named in the receipt, and the holder's helper-derived digest differs from the " +
738
+ "one signed. The oracle moved rather than vanished, and until the helper covers the " +
739
+ "multi-input case this is the honest boundary" },
740
+ { where: "policy.mjs", guard: "WHERE THE MONEY GOES — the engine imposes nothing on the outputs",
741
+ why: "the engine verifies the coin being SPENT belongs to this holder, and computes the " +
742
+ "sighash itself, so no one can be made to sign for someone else's coin. It says " +
743
+ "NOTHING about the destinations. A custodian that constructs the transaction can " +
744
+ "route the whole balance to itself, and the engine authorises: verified by our own " +
745
+ "test on 2026-08-31 (control pays the holder -> authorized; the SAME coin paying a " +
746
+ "thief -> authorized). The holder's ML-DSA signature is the only thing standing " +
747
+ "between them and that, so THE HOLDER MUST INSPECT THE TRANSACTION, not just sign " +
748
+ "the digest the custodian hands them. The receipt does not name the outputs either. " +
749
+ "This is the honest boundary of what a policy engine can do: it can prove WHOSE coin " +
750
+ "and WHO authorised, never WHERE it should go — that is the holder's job, and we " +
751
+ "must not let the digest-signing flow imply otherwise" },
588
752
  { where: "policy.mjs / primacy.mjs", guard: "prose-to-code agreement",
589
753
  why: "the ledger governs CODE -> CLAIM (kill the code and the claim dies). NOTHING " +
590
754
  "governs PROSE -> CODE. Every one of round 7's findings landed in that hole, " +