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 +29 -9
- package/bin/cli.mjs +18 -2
- package/letter/galaxy-2026-08-31.md +29 -11
- package/package.json +6 -7
- package/src/claims-registry.mjs +210 -46
- package/src/demo.mjs +200 -0
- package/src/letter-claims.mjs +183 -69
- package/src/mutations.mjs +77 -6
- package/src/policy.mjs +231 -85
- package/src/tx.mjs +38 -3
- package/src/verify-letter.mjs +69 -14
- package/test/demo.mjs +33 -0
- package/test/letter-claims.mjs +38 -5
- package/test/m2-policy.mjs +16 -5
- package/test/primacy.mjs +27 -9
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
|
-
> **
|
|
138
|
-
> `spendDigest` chosen by the requester
|
|
139
|
-
> *legitimate* client of a custodian obtain a policy-key signature over **another client's
|
|
140
|
-
> sighash
|
|
141
|
-
>
|
|
142
|
-
>
|
|
143
|
-
>
|
|
144
|
-
>
|
|
145
|
-
>
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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 #
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
10
|
-
"
|
|
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",
|
package/src/claims-registry.mjs
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
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,
|
|
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
|
|
73
|
-
//
|
|
74
|
-
|
|
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
|
|
236
|
-
"
|
|
237
|
-
"
|
|
238
|
-
"
|
|
239
|
-
"a
|
|
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
|
-
//
|
|
242
|
-
//
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
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.
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
const
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
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
|
|
268
|
-
"
|
|
269
|
-
"
|
|
270
|
-
"
|
|
271
|
-
"
|
|
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: "
|
|
582
|
-
why: "the
|
|
583
|
-
"
|
|
584
|
-
"
|
|
585
|
-
"
|
|
586
|
-
"
|
|
587
|
-
"
|
|
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, " +
|