fractal-pqc 0.9.0 → 0.10.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 +77 -27
- package/bin/cli.mjs +2 -2
- package/letter/galaxy-2026-08-31.md +3 -3
- package/package.json +3 -2
- package/src/claims-registry.mjs +104 -9
- package/src/letter-claims.mjs +2 -2
- package/src/m2-broadcast.mjs +111 -0
- package/src/mutations.mjs +10 -0
- package/src/policy.mjs +31 -20
- package/test/letter-claims.mjs +1 -1
- package/test/m2-broadcast.mjs +185 -0
package/README.md
CHANGED
|
@@ -124,13 +124,19 @@ The SDK equivalent is `sendP2trKeyPath({ internalPriv, to, amountSats, network,
|
|
|
124
124
|
|
|
125
125
|
The quantum protection does **not** come from a cert verifying in isolation (a cert only
|
|
126
126
|
proves whoever built it held both secret keys). It comes from **anchoring**: the holder
|
|
127
|
-
publishes their commitment first-seen and immutably
|
|
128
|
-
|
|
129
|
-
take `anchoredFactHash` and reject any
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
publishes their commitment first-seen and immutably in an append-only transparency log
|
|
128
|
+
(RFC 6962) anchored into Bitcoin (OpenTimestamps), and every verifier **pins that anchored
|
|
129
|
+
factHash**. `verifySpend`/`verifyMigrationCommitment` take `anchoredFactHash` and reject any
|
|
130
|
+
cert that doesn't match it. Skip the anchor and the kit provides no quantum protection —
|
|
131
|
+
`verifySpend` fails closed rather than pretend otherwise. The anchor registry itself
|
|
132
|
+
(first-seen, no-duplicate, immutable — `primacy.mjs` + `ots.mjs` + `verify-anchor`) is
|
|
133
|
+
**delivered and published** (see the Roadmap below); this reference makes the anchor a
|
|
134
|
+
**required verifier input** so the property is never silently over-claimed. One honest
|
|
135
|
+
residual: this package can *verify* a `.ots` you already obtained — it has no code to
|
|
136
|
+
*submit* a new digest to an OpenTimestamps calendar server or poll it to Bitcoin
|
|
137
|
+
confirmation. Creating a brand-new anchor today requires an external tool (the reference
|
|
138
|
+
`ots` CLI or the `python-opentimestamps` library); we deliberately don't vendor one, per
|
|
139
|
+
`ots.mjs`'s own "no network" scope.
|
|
134
140
|
|
|
135
141
|
## What this is NOT
|
|
136
142
|
|
|
@@ -166,12 +172,16 @@ so the property is never silently over-claimed.
|
|
|
166
172
|
(honest scope — do not overstate)
|
|
167
173
|
|
|
168
174
|
- **Not yet confirmed in a live mempool.** The sighash is proven consensus-correct against
|
|
169
|
-
the official BIP-341 vector, and the
|
|
170
|
-
funded **testnet** broadcast (which needs tBTC from
|
|
171
|
-
|
|
175
|
+
the official BIP-341 vector, and both the key-path and script-path (M2) broadcasters are
|
|
176
|
+
built + dry-run-tested — but an actual funded **testnet** broadcast (which needs tBTC from
|
|
177
|
+
a faucet) is the operator's final step for either path.
|
|
178
|
+
- **`p2trAddress`'s signer is key-path-only.** `taprootTweakPrivateKey`/`signTaprootKeyPath`
|
|
172
179
|
handle key-path-only outputs — exactly what this kit's `p2trAddress` derives. Pointing the
|
|
173
180
|
signer at a foreign Taproot output that commits to a script tree would produce an invalid
|
|
174
|
-
signature. Script-path (tapscript)
|
|
181
|
+
signature. Script-path (tapscript) *construction* — the policy leaf, control blocks, and the
|
|
182
|
+
BIP-341 script-path sighash the M2 policy engine signs — IS implemented and asserted against
|
|
183
|
+
the official BIP-341 wallet test vectors (7/7 cases, 12/12 control blocks, byte for byte);
|
|
184
|
+
what's missing is the step after the signature, per the point above.
|
|
175
185
|
- **Secrets are not zeroized.** Private-key `Uint8Array`s are not wiped after use (best-effort
|
|
176
186
|
only in JS); `keygen` prints secrets by design (testnet/experimental).
|
|
177
187
|
- **Not a BIP and not consensus.** The "recovery commitment" is an application-layer
|
|
@@ -182,24 +192,64 @@ so the property is never silently over-claimed.
|
|
|
182
192
|
|
|
183
193
|
## Roadmap — from reference to real Bitcoin custody tooling (the grant-funded work)
|
|
184
194
|
|
|
185
|
-
|
|
195
|
+
Milestones below match the tranche structure in our funding ask (`letter/galaxy-2026-08-31.md`)
|
|
196
|
+
exactly, so this table and that letter can never quietly drift apart the way this section once
|
|
197
|
+
did. Each milestone is independently verifiable, open-source, and shippable on its own.
|
|
186
198
|
|
|
187
199
|
1. **Bitcoin address + tx layer** — ✅ *done + vector-verified*: bech32/bech32m (BIP-173/350),
|
|
188
|
-
P2TR `bc1p…` derivation (BIP-341), segwit tx + txid (genesis-checked),
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
P2TR `bc1p…` derivation (BIP-341), segwit tx + txid (genesis-checked), key-path AND
|
|
201
|
+
script-path BIP-341 sighashes **matching the official vectors byte-for-byte** (7/7
|
|
202
|
+
scriptPubKey cases, 12/12 control blocks), **fees + coin selection**, **BIP-174 PSBT**
|
|
203
|
+
create→sign→finalize (key-path), and a **testnet broadcaster** (fetch UTXOs/fees →
|
|
204
|
+
build+sign → publish; dry-run tested offline, never yet run against a live network) — all
|
|
205
|
+
permanent tests.
|
|
206
|
+
|
|
207
|
+
2. **M1 — Bitcoin-anchored first-seen registry** — ✅ **DELIVERED, published, $0** (see the
|
|
208
|
+
letter). `primacy.mjs` + `ots.mjs` + `verify-anchor`: an append-only transparency log
|
|
209
|
+
(RFC 6962), ML-DSA-65-signed heads, first-seen resolution by complete enumeration, and a
|
|
210
|
+
temporal frontier confirmed against a real Bitcoin block header the verifier supplies —
|
|
211
|
+
run it yourself, offline, with `npx fractal-pqc verify-anchor`. **Honest residual:** this
|
|
212
|
+
delivers *verification* of an anchor; it does not deliver *creation* of one. Submitting a
|
|
213
|
+
new digest to an OpenTimestamps calendar and polling it to Bitcoin confirmation needs an
|
|
214
|
+
external tool today (the reference `ots` CLI or `python-opentimestamps`) — not vendored
|
|
215
|
+
here, and not yet documented anywhere outside this README.
|
|
216
|
+
|
|
217
|
+
3. **M2 — Exposure-relative primacy, in a real custodian's signing flow** (Tranche 1,
|
|
218
|
+
$120,000/90 days — see the letter for the full scope). The primitive is shipped and
|
|
219
|
+
enforced in `fractal-pqc@0.9.0`: `policy.mjs`'s `authorizeAndSign` computes the BIP-341
|
|
220
|
+
sighash itself, requires the prevout to be the coin the holder's anchor governs, and (opt-in
|
|
221
|
+
via `exposureHeight`) additionally requires the commitment predate the chain first revealing
|
|
222
|
+
the classical key. **What remains, concretely, is engineering a custodian around that
|
|
223
|
+
primitive — not the primitive itself:** (a) ✅ *done*, `m2-broadcast.mjs` — rebuilds the
|
|
224
|
+
witness an `authorizeAndSign` decision actually authorised (never a caller-supplied leaf,
|
|
225
|
+
which would let someone splice in a different script) and serializes/dry-run-broadcasts it;
|
|
226
|
+
the Schnorr signature is re-verified independently against a freshly recomputed BIP-342
|
|
227
|
+
sighash, not against this module's own bookkeeping; (b) ✅ *already done* (round 9,
|
|
228
|
+
before this roadmap section was last reconciled) — `buildSpendContext` takes `otherInputs`,
|
|
229
|
+
so a custodian consolidating more than one deposit per holder does not recreate the round-8
|
|
230
|
+
signing-oracle bug; the holder's independently-derived digest for a real 2-input spend
|
|
231
|
+
matches the one the engine signs, proven by `R9-holder-can-reproduce-any-digest`; (c) a thin
|
|
232
|
+
service wrapper (HTTP/RPC, structured audit logging of every authorize/refuse decision,
|
|
233
|
+
idempotent UTXO handling) around the pure library; (d) a real answer for `policyKey`
|
|
234
|
+
custody (today a single Schnorr key per custodian — losing or leaking it is custodian-wide,
|
|
235
|
+
not just holder-wide, and nothing here enforces the `singlePolicyKeyPerHolder` convention
|
|
236
|
+
callers are trusted to uphold); (e) the funded testnet broadcast itself.
|
|
237
|
+
|
|
238
|
+
4. **Tranche 2, $80,000 — independent external review, multisig PSBT, key rotation.**
|
|
239
|
+
External review: scope not yet defined here, tracked separately from the two engineering
|
|
240
|
+
items. **Multisig PSBT: 0% built.** `psbt.mjs` is single-signer P2TR key-path only; no
|
|
241
|
+
BIP-174 multisig fields, no k-of-N Tapscript threshold primitive, no multi-leaf
|
|
242
|
+
`policyLeafScript` — not even listed in this package's own `UNCLAIMED_GUARDS` honesty
|
|
243
|
+
ledger yet. **Key rotation: confirmed NOT IMPLEMENTED** (`npx fractal-pqc claims --gaps`
|
|
244
|
+
says so verbatim) — a holder who loses their ML-DSA-65 secret is locked out permanently,
|
|
245
|
+
by the same first-seen-primacy logic that makes the rest of the design sound. A real fix
|
|
246
|
+
needs a pre-registered guardian quorum, a distinct anchored entry type for a rotation
|
|
247
|
+
event, a challenge window measured in confirmed blocks, and a new "current active key"
|
|
248
|
+
resolver in `policy.mjs` — comparable in scope to the original primacy/policy engine.
|
|
249
|
+
|
|
250
|
+
5. **Tranche 3, $120,000 — public specification, wallet interoperability, long-term
|
|
251
|
+
maintenance of the vectors.** Not started; success is measured by independent adopters
|
|
252
|
+
running the CC0 vectors, not by our own claims.
|
|
203
253
|
|
|
204
254
|
## License
|
|
205
255
|
|
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", "demo.mjs", "letter-claims.mjs"]) {
|
|
240
|
+
for (const f of ["transparency.mjs", "primacy.mjs", "anchoring.mjs", "conformance.mjs", "m2-policy.mjs", "m2-broadcast.mjs", "bip341-scriptpath.mjs", "claims.mjs", "demo.mjs", "letter-claims.mjs"]) {
|
|
241
241
|
const t = spawnSync(process.execPath, [join(__dirname, "..", "test", f)],
|
|
242
242
|
{ stdio: "inherit", env: { ...process.env, FRACTAL_SELFTEST_DEPTH: "1" } });
|
|
243
243
|
if ((t.status ?? 1) !== 0) process.exit(t.status ?? 1);
|
|
@@ -468,7 +468,7 @@ Usage:
|
|
|
468
468
|
fractal-pqc verify-letter [file] Check every factual assertion in our letter
|
|
469
469
|
against this package. Exits non-zero if any fails.
|
|
470
470
|
fractal-pqc verify-vector Check the official BIP-340 test vector
|
|
471
|
-
fractal-pqc selftest Run everything:
|
|
471
|
+
fractal-pqc selftest Run everything: 357 real checks, no mocks
|
|
472
472
|
|
|
473
473
|
Docs: integrations/pqc-migration-kit/README.md`);
|
|
474
474
|
process.exit(cmd ? 1 : 0);
|
|
@@ -37,12 +37,12 @@ I built that tool because of the disease in section 5, and I built it for this l
|
|
|
37
37
|
specifically. It found three false numbers in my own draft before you saw it.
|
|
38
38
|
|
|
39
39
|
```
|
|
40
|
-
npm i fractal-pqc@0.
|
|
40
|
+
npm i fractal-pqc@0.10.0
|
|
41
41
|
npx fractal-pqc demo # the whole path, offline, in 20 seconds — see below
|
|
42
42
|
npx fractal-pqc claims # every security claim we make, each with an attack
|
|
43
43
|
npx fractal-pqc claims --gaps # and what the green does NOT cover
|
|
44
44
|
npx fractal-pqc claims --mutate # break the code, watch the sentences die
|
|
45
|
-
npx fractal-pqc selftest #
|
|
45
|
+
npx fractal-pqc selftest # 357 checks, no mocks
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
**Start with `demo`.** It is the answer to your question, executed rather than described.
|
|
@@ -134,7 +134,7 @@ We are not the standard, and anyone claiming to be one at this stage is selling.
|
|
|
134
134
|
|
|
135
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.
|
|
136
136
|
|
|
137
|
-
We built that because we needed it.
|
|
137
|
+
We built that because we needed it. Eleven adversarial siege rounds against our own code — ten of them found a real defect, roughly 1,150 executed exploits between those ten — 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. Round 11 is the odd one out and we say so rather than pad the count: it found no defect in existing code, because the code it tests did not exist yet. `authorizeAndSign` returned a raw signature and nothing else in the package ever turned it into a broadcastable transaction — the custodian integration this letter's M2 section describes as remaining. `m2-broadcast.mjs` is that missing step, admitted to the same matrix: it rebuilds the exact witness a decision authorised, never a caller-supplied one, and the signature is re-verified independently against a freshly recomputed sighash before anything is finalized. 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.**
|
|
138
138
|
|
|
139
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.
|
|
140
140
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fractal-pqc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./transparency": "./src/transparency.mjs",
|
|
21
21
|
"./primacy": "./src/primacy.mjs",
|
|
22
22
|
"./policy": "./src/policy.mjs",
|
|
23
|
+
"./m2-broadcast": "./src/m2-broadcast.mjs",
|
|
23
24
|
"./tapscript": "./src/tapscript.mjs",
|
|
24
25
|
"./ots": "./src/ots.mjs",
|
|
25
26
|
"./address": "./src/address.mjs",
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
"vectors"
|
|
43
44
|
],
|
|
44
45
|
"scripts": {
|
|
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",
|
|
46
|
+
"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/bip341-scriptpath.mjs && node test/claims.mjs && node test/demo.mjs && node test/letter-claims.mjs",
|
|
46
47
|
"selftest": "node bin/cli.mjs selftest",
|
|
47
48
|
"conformance": "node test/conformance.mjs",
|
|
48
49
|
"claims": "node bin/cli.mjs claims",
|
package/src/claims-registry.mjs
CHANGED
|
@@ -15,6 +15,8 @@ const toHexLocal = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).
|
|
|
15
15
|
import * as TS from "./tapscript.mjs";
|
|
16
16
|
import * as OTS from "./ots.mjs";
|
|
17
17
|
import { POLICY_SCOPE, authorizeAndSign, REFUSED, policyOutputScriptPubKey, buildSpendContext } from "./policy.mjs";
|
|
18
|
+
import { rebuildM2Witness } from "./m2-broadcast.mjs";
|
|
19
|
+
import { taprootScriptPathSighash } from "./tx.mjs";
|
|
18
20
|
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
19
21
|
import { generateMigrationIdentity, createMigrationCommitment, authorizeSpend } from "./migration-envelope.mjs";
|
|
20
22
|
|
|
@@ -355,6 +357,68 @@ export const CLAIMS = [
|
|
|
355
357
|
"with the warning that this engine constrains WHOSE coin is spent and never WHERE " +
|
|
356
358
|
"the value goes.",
|
|
357
359
|
},
|
|
360
|
+
{
|
|
361
|
+
id: "R11-finalized-witness-independently-verifies",
|
|
362
|
+
module: "m2-broadcast.mjs",
|
|
363
|
+
statement: "The witness this package assembles for an authorised M2 (script-path) spend " +
|
|
364
|
+
"is genuinely valid: rebuilding it from nothing but the authorizeAndSign() " +
|
|
365
|
+
"receipt reproduces the policy leaf that was actually committed into the " +
|
|
366
|
+
"Taproot output key, and the Schnorr signature verifies against an INDEPENDENTLY " +
|
|
367
|
+
"recomputed BIP-342 sighash — not merely against this module's own bookkeeping. " +
|
|
368
|
+
"finalizeAndBroadcastM2Spend refuses to finalize anything authorizeAndSign did " +
|
|
369
|
+
"not actually authorise.",
|
|
370
|
+
proof: () => {
|
|
371
|
+
const result = authorizeAndSign(goodReq());
|
|
372
|
+
if (result.authorized !== true) return false;
|
|
373
|
+
const { witness } = rebuildM2Witness(result);
|
|
374
|
+
if (witness.length !== 3) return false;
|
|
375
|
+
// Independent re-derivation: recompute the sighash ourselves from the spend context,
|
|
376
|
+
// not from anything m2-broadcast.mjs asserts, and verify against THAT.
|
|
377
|
+
const leafHash = TS.tapLeafHash(witness[1]);
|
|
378
|
+
const digest = taprootScriptPathSighash(SC.spendContext.tx, SC.spendContext.inputIndex,
|
|
379
|
+
SC.spendContext.prevoutValues, SC.spendContext.prevoutScriptPubKeys, leafHash);
|
|
380
|
+
if (toHexLocal(digest) !== result.evidence.digestSigned) return false;
|
|
381
|
+
return schnorr.verify(witness[0], digest, Buffer.from(result.policyPublicKeyHex, "hex")) === true;
|
|
382
|
+
},
|
|
383
|
+
attack: () => {
|
|
384
|
+
// 1. A refused authorization must never yield a witness. Isolate EXACTLY the
|
|
385
|
+
// authorized-flag guard: take a GENUINELY authorized result (real evidence, real
|
|
386
|
+
// signature — everything downstream would happily accept it) and spoof only
|
|
387
|
+
// `authorized` to false, rather than using a naturally-refused object, which is
|
|
388
|
+
// ALSO missing evidence.anchoredFactHash and would be caught by the second guard
|
|
389
|
+
// regardless of whether the first one still works — that would leave this mutation
|
|
390
|
+
// untestable. rebuildM2Witness (sync) is finalizeAndBroadcastM2Spend's (async) first
|
|
391
|
+
// internal step and throws for the exact same reason — this claims runner calls
|
|
392
|
+
// proof/attack synchronously, so we test the sync half directly rather than let an
|
|
393
|
+
// unawaited async throw become an unhandled rejection no try/catch here would ever see.
|
|
394
|
+
const spoofed = { ...authorizeAndSign(goodReq()), authorized: false };
|
|
395
|
+
let threw1 = false;
|
|
396
|
+
try { rebuildM2Witness(spoofed); } catch { threw1 = true; }
|
|
397
|
+
if (!threw1) return false;
|
|
398
|
+
// 2. A fabricated result with no real evidence must never yield a witness.
|
|
399
|
+
let threw2 = false;
|
|
400
|
+
try {
|
|
401
|
+
rebuildM2Witness({ authorized: true, policySignatureHex: "00".repeat(64),
|
|
402
|
+
policyPublicKeyHex: toHexLocal(schnorr.getPublicKey(POLICY_KEY)), evidence: {} });
|
|
403
|
+
} catch { threw2 = true; }
|
|
404
|
+
if (!threw2) return false;
|
|
405
|
+
// 3. A tampered signature byte must fail independent verification.
|
|
406
|
+
const result = authorizeAndSign(goodReq());
|
|
407
|
+
const { witness } = rebuildM2Witness(result);
|
|
408
|
+
const leafHash = TS.tapLeafHash(witness[1]);
|
|
409
|
+
const digest = taprootScriptPathSighash(SC.spendContext.tx, SC.spendContext.inputIndex,
|
|
410
|
+
SC.spendContext.prevoutValues, SC.spendContext.prevoutScriptPubKeys, leafHash);
|
|
411
|
+
const tampered = Uint8Array.from(witness[0]); tampered[0] ^= 0xff;
|
|
412
|
+
return schnorr.verify(tampered, digest, Buffer.from(result.policyPublicKeyHex, "hex")) === false;
|
|
413
|
+
},
|
|
414
|
+
note: "Round 11 (this audit): authorizeAndSign() returned a raw signature and nothing else " +
|
|
415
|
+
"in the package ever turned it into a broadcastable transaction — scriptPathWitness() " +
|
|
416
|
+
"was called exactly once in the whole repo, in one test, only to check its shape. A " +
|
|
417
|
+
"custodian could not attempt a real M2 spend, even in dry-run. m2-broadcast.mjs closes " +
|
|
418
|
+
"that: it rebuilds the leaf/control block from the receipt's own fields (never from a " +
|
|
419
|
+
"caller-supplied leaf, which would let someone splice in a different script), and " +
|
|
420
|
+
"refuses outright unless authorized===true.",
|
|
421
|
+
},
|
|
358
422
|
{
|
|
359
423
|
id: "R10-every-caller-field-read-once",
|
|
360
424
|
module: "policy.mjs",
|
|
@@ -728,15 +792,14 @@ export const UNCLAIMED_GUARDS = Object.freeze([
|
|
|
728
792
|
"blocks) — again stronger than a claim, again not in the ledger" },
|
|
729
793
|
{ where: "policy.mjs", guard: "the ORDER of the refusal guards",
|
|
730
794
|
why: "each guard is claimed individually; nothing claims that reordering them is safe" },
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
"multi-input case this is the honest boundary" },
|
|
795
|
+
// REMOVED (post-round-9): "buildSpendContext is single-input; the engine is not" used to
|
|
796
|
+
// live here. It described a real gap — the helper hardcoded one input while the engine
|
|
797
|
+
// accepted multi-input transactions, so a consolidating custodian would recreate the
|
|
798
|
+
// signing-oracle bug. Round 9 closed it: buildSpendContext now accepts `otherInputs`, and
|
|
799
|
+
// R9-holder-can-reproduce-any-digest (below) proves a 2-input case end to end — the holder's
|
|
800
|
+
// independently-derived digest matches the one the engine signs. Leaving a fixed gap listed
|
|
801
|
+
// as unclaimed would itself be a prose-vs-code drift, the exact disease this file exists to
|
|
802
|
+
// catch, so it's removed rather than left to rot.
|
|
740
803
|
{ where: "policy.mjs", guard: "WHERE THE MONEY GOES — the engine imposes nothing on the outputs",
|
|
741
804
|
why: "the engine verifies the coin being SPENT belongs to this holder, and computes the " +
|
|
742
805
|
"sighash itself, so no one can be made to sign for someone else's coin. It says " +
|
|
@@ -760,4 +823,36 @@ export const UNCLAIMED_GUARDS = Object.freeze([
|
|
|
760
823
|
{ where: "primacy.mjs", guard: "key rotation / revocation",
|
|
761
824
|
why: "NOT IMPLEMENTED. A holder who loses their ML-DSA secret is locked out permanently " +
|
|
762
825
|
"and the old certificate keeps authorising. This is a known gap, not a covered one." },
|
|
826
|
+
{ where: "psbt.mjs / tapscript.mjs / policy.mjs", guard: "multisig PSBT",
|
|
827
|
+
why: "NOT IMPLEMENTED, and until this entry, not even LISTED — the package's own honesty " +
|
|
828
|
+
"ledger had no line item for a promise the funding letter makes. psbt.mjs is " +
|
|
829
|
+
"single-signer P2TR key-path only: no BIP-174 multisig fields, no k-of-N Tapscript " +
|
|
830
|
+
"threshold primitive, and policyLeafScript hardcodes exactly one policy public key. " +
|
|
831
|
+
"Multisig and key rotation (above) are related but DISTINCT problems — rotation asks " +
|
|
832
|
+
"which key is valid NOW, multisig asks how many keys must agree — and a design that " +
|
|
833
|
+
"supports both together (replace one of N signers without invalidating the others) " +
|
|
834
|
+
"does not exist yet in prose or code." },
|
|
835
|
+
{ where: "policy.mjs (nothing downstream of it exists yet)", guard: "finalize + broadcast for a script-path (M2) spend",
|
|
836
|
+
why: "NOT IMPLEMENTED. authorizeAndSign returns a raw Schnorr signature over a digest it " +
|
|
837
|
+
"computed; nothing in this package assembles that signature, the policy leaf, and the " +
|
|
838
|
+
"control block into a witness, serializes the transaction, or hands it to " +
|
|
839
|
+
"broadcast.mjs. scriptPathWitness() (tapscript.mjs) is called exactly once in the " +
|
|
840
|
+
"whole repo, in one test, only to check its shape — its output never reaches a " +
|
|
841
|
+
"serializer. A custodian cannot attempt a real M2 spend, even in dry-run, until this " +
|
|
842
|
+
"exists; it is the first thing anyone integrating M2 needs." },
|
|
843
|
+
{ where: "policy.mjs", guard: "policyKey custody, and singlePolicyKeyPerHolder is not enforced",
|
|
844
|
+
why: "The Taproot internal key is the unspendable NUMS point, so policyPub in the leaf " +
|
|
845
|
+
"script is the ONLY spending path — and it is the SAME Schnorr key for every holder " +
|
|
846
|
+
"under one custodian. Losing or leaking it freezes or drains everyone that custodian " +
|
|
847
|
+
"serves, not just one holder; the letter's Tranche 2 discusses only holder-side key " +
|
|
848
|
+
"loss. Separately, every call site passes singlePolicyKeyPerHolder: true, but " +
|
|
849
|
+
"authorizeAndSign never reads that field — verified live, calling it with or without " +
|
|
850
|
+
"the field produces byte-identical behaviour. The 'concentration, not elimination' " +
|
|
851
|
+
"security story this whole package tells depends on one policy key mapping to " +
|
|
852
|
+
"exactly one holder, and today that is pure operator convention outside the library, " +
|
|
853
|
+
"with no detection if it's violated. The receipt used to echo a fake acknowledgement " +
|
|
854
|
+
"of the caller's assertion (`singlePolicyKeyPerHolderAsserted: true`) on a code path " +
|
|
855
|
+
"that turned out to be dead (spendContext has been mandatory since round 8, so the " +
|
|
856
|
+
"branch that built that field could never run) — removed rather than left as prose " +
|
|
857
|
+
"describing a possibility the code cannot reach. See POLICY_SCOPE.policyKeyCustody." },
|
|
763
858
|
]);
|
package/src/letter-claims.mjs
CHANGED
|
@@ -166,7 +166,7 @@ async function mount() {
|
|
|
166
166
|
return _mount;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
const WORD_NUM = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 };
|
|
169
|
+
const WORD_NUM = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11 };
|
|
170
170
|
|
|
171
171
|
// ── the registry ───────────────────────────────────────────────────────────
|
|
172
172
|
export const LETTER_CLAIMS = [
|
|
@@ -230,7 +230,7 @@ export const LETTER_CLAIMS = [
|
|
|
230
230
|
{
|
|
231
231
|
id: "L8-siege-rounds",
|
|
232
232
|
what: "how many adversarial rounds the letter claims, vs how many the mutation matrix records",
|
|
233
|
-
extract: (t) => extract(t, /(Seven|Six|Eight|Five|Nine|Ten) adversarial siege rounds/, "rounds"),
|
|
233
|
+
extract: (t) => extract(t, /(Seven|Six|Eight|Five|Nine|Ten|Eleven) adversarial siege rounds/, "rounds"),
|
|
234
234
|
measure: () => siegeRounds(),
|
|
235
235
|
compare: (a, m) => WORD_NUM[String(a).toLowerCase()] === m,
|
|
236
236
|
falsify: (a) => (String(a).toLowerCase() === "seven" ? "Eight" : "Seven"),
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Finalize + (optionally) broadcast the M2 policy-governed (script-path) spend that
|
|
4
|
+
// authorizeAndSign() already decided. This is deliberately a SEPARATE module from
|
|
5
|
+
// policy.mjs: the decision engine has zero network I/O and zero transaction-assembly
|
|
6
|
+
// code, and nothing here can influence what it decided — this module can only serialize
|
|
7
|
+
// a decision already made, never make one.
|
|
8
|
+
//
|
|
9
|
+
// WHY THIS EXISTS. authorizeAndSign() returns a raw 64-byte Schnorr signature over a
|
|
10
|
+
// digest it computed itself. Until this file, nothing in the package turned that into a
|
|
11
|
+
// broadcastable transaction: scriptPathWitness() (tapscript.mjs) was called exactly once
|
|
12
|
+
// in the whole repo, in one test, only to check its shape — its output never reached a
|
|
13
|
+
// serializer. A custodian could not attempt a real M2 spend, even in dry-run. This is
|
|
14
|
+
// that missing step, for the letter's M2 milestone ("a custodian running it against
|
|
15
|
+
// funded testnet spends").
|
|
16
|
+
//
|
|
17
|
+
// TRUST MODEL: this module REBUILDS the policy leaf from fields the receipt itself
|
|
18
|
+
// carries (policyPublicKeyHex, evidence.anchoredFactHash) rather than accepting a leaf
|
|
19
|
+
// script from the caller — a caller-supplied leaf would let someone splice a different
|
|
20
|
+
// script into the witness than the one the engine actually authorised. It also refuses
|
|
21
|
+
// outright unless authorizeResult.authorized === true, so a caller cannot broadcast an
|
|
22
|
+
// unauthorised decision by constructing a fake result object with the right shape.
|
|
23
|
+
|
|
24
|
+
import { policyLeafScript } from "./policy.mjs";
|
|
25
|
+
import { tapLeafHash, taprootOutputKeyWithTree, NUMS_INTERNAL_KEY, controlBlock, scriptPathWitness, verifyControlBlock } from "./tapscript.mjs";
|
|
26
|
+
import { serializeSegwit, txid as computeTxid } from "./tx.mjs";
|
|
27
|
+
import { broadcastTx, explorerTxUrl } from "./broadcast.mjs";
|
|
28
|
+
|
|
29
|
+
const fromHex = (h) => Uint8Array.from(Buffer.from(h, "hex"));
|
|
30
|
+
const toHex = (b) => Buffer.from(b).toString("hex");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Rebuild the exact witness `authorizeAndSign` authorised — script, control block, and
|
|
34
|
+
* signature — without trusting anything the caller supplies beyond the receipt itself.
|
|
35
|
+
* Exported separately from the broadcast step so a caller can inspect/serialize the
|
|
36
|
+
* witness before deciding whether to publish it.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} authorizeResult what authorizeAndSign() returned; must have
|
|
39
|
+
* authorized === true
|
|
40
|
+
* @returns {{ leaf: Uint8Array, control: Uint8Array, witness: Uint8Array[] }}
|
|
41
|
+
*/
|
|
42
|
+
export function rebuildM2Witness(authorizeResult) {
|
|
43
|
+
if (!authorizeResult || authorizeResult.authorized !== true) {
|
|
44
|
+
throw new Error("rebuildM2Witness refuses: authorizeResult.authorized is not true — this " +
|
|
45
|
+
"module only serializes a decision the policy engine actually made, never one it refused.");
|
|
46
|
+
}
|
|
47
|
+
const { policySignatureHex, policyPublicKeyHex, evidence } = authorizeResult;
|
|
48
|
+
if (!evidence || typeof evidence.anchoredFactHash !== "string") {
|
|
49
|
+
throw new Error("authorizeResult has no evidence.anchoredFactHash — refusing to guess the policy leaf.");
|
|
50
|
+
}
|
|
51
|
+
const policyXOnly = fromHex(policyPublicKeyHex);
|
|
52
|
+
const leaf = policyLeafScript(evidence.anchoredFactHash, policyXOnly);
|
|
53
|
+
if (!leaf) throw new Error("could not rebuild the policy leaf from the receipt's own fields.");
|
|
54
|
+
const leafHash = tapLeafHash(leaf);
|
|
55
|
+
// Single-leaf tree (the only shape policy.mjs ever produces): the leaf hash IS the
|
|
56
|
+
// tree root, so the Merkle path in the control block is empty.
|
|
57
|
+
const { outputKey, parity } = taprootOutputKeyWithTree(NUMS_INTERNAL_KEY, leafHash);
|
|
58
|
+
const control = controlBlock({ internalXOnly: NUMS_INTERNAL_KEY, parity, path: [] });
|
|
59
|
+
// Verify our OWN construction before handing it back, the same check a verifier would
|
|
60
|
+
// run — if this ever failed it means this module's assembly diverged from the leaf
|
|
61
|
+
// policy.mjs actually committed to, and broadcasting would just be a wasted fee.
|
|
62
|
+
const check = verifyControlBlock(control, leaf, outputKey);
|
|
63
|
+
if (!check.valid) throw new Error(`internal error: rebuilt control block does not verify (${check.reason})`);
|
|
64
|
+
const witness = scriptPathWitness({ stack: [fromHex(policySignatureHex)], script: leaf, control });
|
|
65
|
+
return { leaf, control, witness, outputKeyHex: toHex(outputKey) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Finalize the M2 spend `authorizeAndSign` authorised into a signed, serialized
|
|
70
|
+
* transaction, and (optionally) broadcast it.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} authorizeResult what authorizeAndSign() returned (authorized === true)
|
|
73
|
+
* @param {object} spendContext the SAME { tx, inputIndex, prevoutValues,
|
|
74
|
+
* prevoutScriptPubKeys } object passed to
|
|
75
|
+
* authorizeAndSign — never re-derived, so this can never
|
|
76
|
+
* finalize a transaction other than the one authorised
|
|
77
|
+
* @param {object} [opts]
|
|
78
|
+
* @param {boolean} [opts.broadcast=false] actually publish (default: dry-run)
|
|
79
|
+
* @param {"tb"|"bc"|"signet"|"mutinynet"} [opts.network="tb"]
|
|
80
|
+
* @param {string} [opts.apiBase] override the Esplora API base
|
|
81
|
+
* @returns {Promise<{hex, txid, broadcasted, explorer}>}
|
|
82
|
+
*/
|
|
83
|
+
export async function finalizeAndBroadcastM2Spend(authorizeResult, spendContext, opts = {}) {
|
|
84
|
+
const { witness } = rebuildM2Witness(authorizeResult);
|
|
85
|
+
const { tx, inputIndex } = spendContext || {};
|
|
86
|
+
if (!tx || !Array.isArray(tx.inputs) || !Number.isSafeInteger(inputIndex) ||
|
|
87
|
+
inputIndex < 0 || inputIndex >= tx.inputs.length) {
|
|
88
|
+
throw new Error("spendContext must be the exact { tx, inputIndex, ... } object that was " +
|
|
89
|
+
"passed to authorizeAndSign — this module never re-derives it.");
|
|
90
|
+
}
|
|
91
|
+
if (tx.inputs[inputIndex].witness) {
|
|
92
|
+
throw new Error(`tx.inputs[${inputIndex}] already carries a witness — refusing to overwrite ` +
|
|
93
|
+
"one this module did not produce.");
|
|
94
|
+
}
|
|
95
|
+
const finalTx = {
|
|
96
|
+
...tx,
|
|
97
|
+
inputs: tx.inputs.map((inp, i) => (i === inputIndex ? { ...inp, witness } : inp)),
|
|
98
|
+
};
|
|
99
|
+
const hex = toHex(serializeSegwit(finalTx));
|
|
100
|
+
const finalTxid = computeTxid(finalTx);
|
|
101
|
+
|
|
102
|
+
let broadcasted = false;
|
|
103
|
+
if (opts.broadcast) {
|
|
104
|
+
await broadcastTx(hex, { network: opts.network ?? "tb", apiBase: opts.apiBase });
|
|
105
|
+
broadcasted = true;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
hex, txid: finalTxid, broadcasted,
|
|
109
|
+
explorer: broadcasted ? explorerTxUrl(finalTxid, opts.network ?? "tb") : null,
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/mutations.mjs
CHANGED
|
@@ -336,6 +336,16 @@ export const MUTATIONS = [
|
|
|
336
336
|
// demonstrated bypass — and a mutation we cannot honestly make kill anything is exactly the
|
|
337
337
|
// vacuous-claim disease this file exists to catch. Said so in the letter and in the claim's
|
|
338
338
|
// own note, rather than shipping a mutation that always reads "detected: false".
|
|
339
|
+
{
|
|
340
|
+
id: "M-R11-finalize-accepts-unauthorised",
|
|
341
|
+
describes: "R11: rebuildM2Witness stops refusing an unauthorised result, so " +
|
|
342
|
+
"finalizeAndBroadcastM2Spend could be handed a fabricated {authorized:true} " +
|
|
343
|
+
"object and produce a witness for a spend the policy engine never actually authorised",
|
|
344
|
+
file: "src/m2-broadcast.mjs",
|
|
345
|
+
from: ` if (!authorizeResult || authorizeResult.authorized !== true) {`,
|
|
346
|
+
to: ` if (false) {`,
|
|
347
|
+
mustKill: ["R11-finalized-witness-independently-verifies"],
|
|
348
|
+
},
|
|
339
349
|
];
|
|
340
350
|
|
|
341
351
|
/**
|
package/src/policy.mjs
CHANGED
|
@@ -475,9 +475,10 @@ export function authorizeAndSign(req) {
|
|
|
475
475
|
}
|
|
476
476
|
const spendContextOutputs = (tx.outputs || []).map((o) =>
|
|
477
477
|
o.scriptPubKey instanceof Uint8Array ? o.scriptPubKey : (fromHexP(o.scriptPubKey) || new Uint8Array()));
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
478
|
+
// Only scriptPubKeyHex is ever read from this below — no txid exists yet (the transaction
|
|
479
|
+
// isn't broadcast at signing time) and computedDigestHex/inputIndex are already on the
|
|
480
|
+
// receipt as digestSigned/spendContext, so we don't construct fields nobody consumes.
|
|
481
|
+
const outputBinding = { bound: true, scriptPubKeyHex: toHex(expectedSpk) };
|
|
481
482
|
|
|
482
483
|
const spend = verifySpend(cert, digest, pqSignatureHex, {
|
|
483
484
|
anchoredFactHash: anchor.anchoredFactHash,
|
|
@@ -521,9 +522,14 @@ export function authorizeAndSign(req) {
|
|
|
521
522
|
`public key. Only the SECOND of those depends on no Q-day estimate. It is an ` +
|
|
522
523
|
`extra condition, not a replacement for the first.`,
|
|
523
524
|
} : {}),
|
|
524
|
-
// Stated on EVERY receipt
|
|
525
|
-
//
|
|
526
|
-
|
|
525
|
+
// Stated on EVERY receipt: true, unconditionally, since round 8 made spendContext
|
|
526
|
+
// mandatory — there is no path through authorizeAndSign any more that reaches this
|
|
527
|
+
// line without one. (An earlier version of this field, and a whole caveat branch
|
|
528
|
+
// beside it for the no-spendContext case, described a possibility that could not
|
|
529
|
+
// happen — dead code masquerading as an open question. Removed rather than kept
|
|
530
|
+
// "just in case": a `singlePolicyKeyPerHolder` flag some callers still pass is not
|
|
531
|
+
// read here or anywhere else in this file; see POLICY_SCOPE.policyKeyCustody.)
|
|
532
|
+
outputBound: true,
|
|
527
533
|
// WHAT THIS RECEIPT DOES NOT SAY, printed on every one so its absence is never read
|
|
528
534
|
// as a guarantee: nothing here constrains the DESTINATIONS. The engine proved whose
|
|
529
535
|
// coin is being spent and who authorised it. Where the money goes is the holder's
|
|
@@ -535,15 +541,7 @@ export function authorizeAndSign(req) {
|
|
|
535
541
|
"the transaction before producing the ML-DSA authorisation; signing a digest you " +
|
|
536
542
|
"did not derive yourself is the same mistake this engine stopped making in 0.9.0.",
|
|
537
543
|
outputScriptPubKeys: (spendContextOutputs || []).map((o) => toHex(o)),
|
|
538
|
-
|
|
539
|
-
outputBindingCaveat:
|
|
540
|
-
"no spendContext was supplied. This receipt identifies the HOLDER, not the COIN: " +
|
|
541
|
-
"it records who was authorised to make the policy key sign SOMETHING. It is only " +
|
|
542
|
-
"sound because the caller ASSERTED singlePolicyKeyPerHolder — an assertion this " +
|
|
543
|
-
"engine cannot verify. If that assertion is false, this receipt does not mean " +
|
|
544
|
-
"what it appears to mean.",
|
|
545
|
-
singlePolicyKeyPerHolderAsserted: true,
|
|
546
|
-
}),
|
|
544
|
+
boundScriptPubKeyHex: outputBinding.scriptPubKeyHex,
|
|
547
545
|
digestSigned: toHex(digest),
|
|
548
546
|
logId: anchor.logId,
|
|
549
547
|
logPinned: true,
|
|
@@ -628,11 +626,24 @@ export const POLICY_SCOPE = Object.freeze({
|
|
|
628
626
|
"ML-DSA signature is the only thing standing there, so the holder must INSPECT THE " +
|
|
629
627
|
"TRANSACTION rather than sign the digest they were handed.",
|
|
630
628
|
helperIsSingleInputOnly:
|
|
631
|
-
"
|
|
632
|
-
"authorizeAndSign
|
|
633
|
-
"re-derive
|
|
634
|
-
"
|
|
635
|
-
"
|
|
629
|
+
"CLOSED (round 9). This string used to describe `buildSpendContext` as single-input " +
|
|
630
|
+
"while authorizeAndSign accepted multi-input transactions, leaving the holder unable to " +
|
|
631
|
+
"re-derive a multi-input digest with the only helper shipped. `buildSpendContext` now " +
|
|
632
|
+
"takes `otherInputs`, and R9-holder-can-reproduce-any-digest proves a real 2-input case " +
|
|
633
|
+
"end to end: the holder's independently-derived digest matches what the engine signs. " +
|
|
634
|
+
"Left the name and the round number rather than deleting the field outright, so a reader " +
|
|
635
|
+
"who remembers the old caveat can find where it went.",
|
|
636
|
+
policyKeyCustody:
|
|
637
|
+
"NOT THIS FILE'S PROBLEM TO SOLVE, AND WE SAY SO. `policyKey` is a single Schnorr secret " +
|
|
638
|
+
"per custodian — the ONE spending path once the internal key is the unspendable NUMS " +
|
|
639
|
+
"point (see consensusCaveat) — so losing or leaking it is custodian-WIDE, not just " +
|
|
640
|
+
"holder-wide: every holder under that key is frozen or drained together. Some callers " +
|
|
641
|
+
"pass `singlePolicyKeyPerHolder: true` into authorizeAndSign; this file has never read " +
|
|
642
|
+
"that field (a stateless function call cannot verify a claim about OTHER calls it never " +
|
|
643
|
+
"sees), and no longer even echoes it back in the receipt as if it had been checked. " +
|
|
644
|
+
"'One policy key per holder' is real only if the CALLER enforces it externally (e.g. a " +
|
|
645
|
+
"database uniqueness constraint) — that discipline, and HSM/multisig custody of the key " +
|
|
646
|
+
"itself, belong to the custodian integration this package does not build.",
|
|
636
647
|
limitFour_historyNotPinned:
|
|
637
648
|
"pinning the log IDENTITY does not pin its HISTORY. Two same-size heads under the same " +
|
|
638
649
|
"logId both satisfy every check here. Split view is DETECTABLE (transparency." +
|
package/test/letter-claims.mjs
CHANGED
|
@@ -82,7 +82,7 @@ console.log("\nAdversarial: a tampered letter must be refused");
|
|
|
82
82
|
// ROUND 9: this hardcoded "Seven", so when the letter honestly moved to "Nine" the tamper
|
|
83
83
|
// stopped tampering and the suite reported it as refused. A perturbation that does not
|
|
84
84
|
// perturb is a false green — the same disease, in the test that guards against it.
|
|
85
|
-
["inflated siege rounds", (t) => t.replace(/\b(Five|Six|Seven|Eight|Nine|Ten) adversarial siege rounds/,
|
|
85
|
+
["inflated siege rounds", (t) => t.replace(/\b(Five|Six|Seven|Eight|Nine|Ten|Eleven) adversarial siege rounds/,
|
|
86
86
|
"Twenty adversarial siege rounds")],
|
|
87
87
|
["wrong version", (t) => t.replace(/fractal-pqc@[\d.]+/g, "fractal-pqc@9.9.9")],
|
|
88
88
|
["inflated BIP-341 cases", (t) => t.replace(/7\/7 scriptPubKey/, "9/9 scriptPubKey")],
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// M2 finalize+broadcast — the step after authorizeAndSign() that nothing else in this
|
|
2
|
+
// package performs: assembling the witness for a script-path spend the policy engine
|
|
3
|
+
// already authorised, serializing it, and (in dry-run) producing a real txid. Real keys,
|
|
4
|
+
// real ML-DSA-65 + Schnorr signatures, no mocks — and the signature is re-verified
|
|
5
|
+
// INDEPENDENTLY of rebuildM2Witness, against a digest this test recomputes itself, so a
|
|
6
|
+
// bug that made both sides agree with each other (rather than with the spec) would still
|
|
7
|
+
// be caught.
|
|
8
|
+
|
|
9
|
+
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
10
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
11
|
+
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
12
|
+
import * as T from "./../src/transparency.mjs";
|
|
13
|
+
import * as P from "./../src/primacy.mjs";
|
|
14
|
+
import * as OTS from "./../src/ots.mjs";
|
|
15
|
+
import { tapLeafHash } from "./../src/tapscript.mjs";
|
|
16
|
+
import { taprootScriptPathSighash } from "./../src/tx.mjs";
|
|
17
|
+
import { generateMigrationIdentity, createMigrationCommitment, authorizeSpend } from "./../src/migration-envelope.mjs";
|
|
18
|
+
import { authorizeAndSign, buildSpendContext } from "./../src/policy.mjs";
|
|
19
|
+
import { rebuildM2Witness, finalizeAndBroadcastM2Spend } from "./../src/m2-broadcast.mjs";
|
|
20
|
+
|
|
21
|
+
let pass = 0, fail = 0;
|
|
22
|
+
function check(name, cond) {
|
|
23
|
+
if (cond) { pass++; console.log(` ✓ ${name}`); }
|
|
24
|
+
else { fail++; console.log(` ✗ ${name}`); }
|
|
25
|
+
}
|
|
26
|
+
const hex = (b) => Buffer.from(b).toString("hex");
|
|
27
|
+
const te = new TextEncoder();
|
|
28
|
+
|
|
29
|
+
console.log("M2 finalize+broadcast — the piece after authorizeAndSign that nothing else performs\n");
|
|
30
|
+
|
|
31
|
+
const kp = ml_dsa65.keygen(new Uint8Array(32).fill(0x9c));
|
|
32
|
+
const leaves = (n) => Array.from({ length: n }, (_, i) => T.hashLeaf(te.encode(`c-${i}`)));
|
|
33
|
+
const ident = generateMigrationIdentity();
|
|
34
|
+
const cert = createMigrationCommitment(ident);
|
|
35
|
+
const entry = { classicalPub: cert.classicalPub, pqPublic: cert.pqPublic, classicalSig: cert.classicalSig, pqSig: cert.pqSig };
|
|
36
|
+
const other = createMigrationCommitment(generateMigrationIdentity());
|
|
37
|
+
const ENTRIES = [{ classicalPub: other.classicalPub, pqPublic: other.pqPublic, classicalSig: other.classicalSig, pqSig: other.pqSig }, entry];
|
|
38
|
+
const LEAVES = ENTRIES.map(P.entryLeaf);
|
|
39
|
+
const STH = T.signTreeHead({ secretKey: kp.secretKey, publicKey: kp.publicKey, treeSize: ENTRIES.length, rootHash: T.merkleRoot(LEAVES), timestampMs: 1_756_000_000_000 });
|
|
40
|
+
const HEAD_BYTES = T.sthSignedBytes({ version: STH.version, logId: STH.logId, treeSize: STH.treeSize, rootHash: T.fromHex(STH.rootHash), timestampMs: STH.timestampMs });
|
|
41
|
+
const OTS_DIGEST = OTS.otsSha256(HEAD_BYTES);
|
|
42
|
+
const ANCHOR_HEIGHT = 800_000;
|
|
43
|
+
const OTS_HEX = T.toHex(OTS.encodeOtsFile({ majorVersion: OTS.OTS_MAJOR_VERSION, fileHashOp: "sha256", fileDigest: OTS_DIGEST, timestamp: OTS.timestampWithAttestation(OTS_DIGEST, { kind: "bitcoin", height: ANCHOR_HEIGHT }) }));
|
|
44
|
+
const REAL_ROOT = (() => { const a = OTS.evaluateOts(OTS.decodeOtsFile(T.fromHex(OTS_HEX))).blockAttestations[0]; return a.merkleRootInternalHex.match(/../g).reverse().join(""); })();
|
|
45
|
+
const HEADERS = { [ANCHOR_HEIGHT]: REAL_ROOT };
|
|
46
|
+
const CUTOFF = 900_000;
|
|
47
|
+
const POLICY_KEY = new Uint8Array(32).fill(0x2b);
|
|
48
|
+
const ANCHORED_FH = P.certIsFirstSeen({ cert, entries: ENTRIES, sth: STH, expectedLogId: STH.logId,
|
|
49
|
+
cutoffBlockHeight: CUTOFF, otsHex: OTS_HEX, blockMerkleRoots: HEADERS, requireTemporalFrontier: true }).anchoredFactHash;
|
|
50
|
+
const SC = buildSpendContext({ anchoredFactHash: ANCHORED_FH, policyPublicKey: schnorr.getPublicKey(POLICY_KEY),
|
|
51
|
+
txid: "11".repeat(32), valueSats: 100_000n, outputs: [{ valueSats: 90_000n, scriptPubKey: new Uint8Array(34) }] });
|
|
52
|
+
const SPEND_SIG = authorizeSpend(ident, SC.digest);
|
|
53
|
+
const goodReq = (overrides = {}) => ({ cert, pqSignatureHex: SPEND_SIG,
|
|
54
|
+
anchorEvidence: { sth: STH, entries: ENTRIES, otsHex: OTS_HEX }, expectedLogId: STH.logId,
|
|
55
|
+
policyKey: POLICY_KEY, cutoffBlockHeight: CUTOFF, blockMerkleRoots: HEADERS,
|
|
56
|
+
spendContext: SC.spendContext, ...overrides });
|
|
57
|
+
|
|
58
|
+
console.log("A real authorization, finalized into a witness:");
|
|
59
|
+
{
|
|
60
|
+
const result = authorizeAndSign(goodReq());
|
|
61
|
+
check("baseline authorises (the control is not vacuous)", result.authorized === true);
|
|
62
|
+
|
|
63
|
+
const { witness, outputKeyHex } = rebuildM2Witness(result);
|
|
64
|
+
check("witness is [sig, leaf, control] — 3 items", witness.length === 3);
|
|
65
|
+
check("rebuildM2Witness's own outputKey matches the receipt's bound scriptPubKey",
|
|
66
|
+
result.evidence.boundScriptPubKeyHex.slice(4) === outputKeyHex); // strip OP_1 PUSH32 (0x5120)
|
|
67
|
+
|
|
68
|
+
// INDEPENDENT re-verification: recompute the BIP-342 sighash ourselves, the same way
|
|
69
|
+
// policy.mjs does internally, and check the signature against THAT — not against
|
|
70
|
+
// anything rebuildM2Witness itself asserts. A bug that made witness assembly and
|
|
71
|
+
// signature verification agree with each other but not with the spec would still fail
|
|
72
|
+
// this check.
|
|
73
|
+
const leafHash = tapLeafHash(witness[1]);
|
|
74
|
+
const expectedDigest = taprootScriptPathSighash(SC.spendContext.tx, SC.spendContext.inputIndex,
|
|
75
|
+
SC.spendContext.prevoutValues, SC.spendContext.prevoutScriptPubKeys, leafHash);
|
|
76
|
+
check("recomputed digest matches the receipt's digestSigned",
|
|
77
|
+
hex(expectedDigest) === result.evidence.digestSigned);
|
|
78
|
+
check("★ INDEPENDENT schnorr.verify(sig, digest, policyPubkey) — not rebuildM2Witness's own opinion",
|
|
79
|
+
schnorr.verify(witness[0], expectedDigest, Buffer.from(result.policyPublicKeyHex, "hex")) === true);
|
|
80
|
+
|
|
81
|
+
const fin = await finalizeAndBroadcastM2Spend(result, SC.spendContext, { broadcast: false });
|
|
82
|
+
check("dry-run finalize does NOT broadcast", fin.broadcasted === false);
|
|
83
|
+
check("dry-run finalize returns a real hex-serialized transaction", /^[0-9a-f]+$/.test(fin.hex) && fin.hex.length > 0);
|
|
84
|
+
check("dry-run finalize returns a 64-hex-char txid", /^[0-9a-f]{64}$/.test(fin.txid));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log("\nRefusals — finalize is only as trustworthy as what it refuses to finalize:");
|
|
88
|
+
{
|
|
89
|
+
const refused = authorizeAndSign(goodReq({ cutoffBlockHeight: 1 }));
|
|
90
|
+
check("setup: this variant is genuinely refused", refused.authorized === false);
|
|
91
|
+
let threw = false;
|
|
92
|
+
try { await finalizeAndBroadcastM2Spend(refused, SC.spendContext); }
|
|
93
|
+
catch { threw = true; }
|
|
94
|
+
check("★ refuses to finalize an UNAUTHORISED result", threw);
|
|
95
|
+
|
|
96
|
+
let threwFake = false;
|
|
97
|
+
try {
|
|
98
|
+
rebuildM2Witness({ authorized: true, policySignatureHex: "00".repeat(64),
|
|
99
|
+
policyPublicKeyHex: hex(schnorr.getPublicKey(POLICY_KEY)), evidence: {} });
|
|
100
|
+
} catch { threwFake = true; }
|
|
101
|
+
check("★ refuses a fabricated result with no evidence.anchoredFactHash", threwFake);
|
|
102
|
+
|
|
103
|
+
const result = authorizeAndSign(goodReq());
|
|
104
|
+
let threwBadCtx = false;
|
|
105
|
+
try { await finalizeAndBroadcastM2Spend(result, { tx: SC.spendContext.tx, inputIndex: 99 }); }
|
|
106
|
+
catch { threwBadCtx = true; }
|
|
107
|
+
check("★ refuses a spendContext with an out-of-range inputIndex", threwBadCtx);
|
|
108
|
+
|
|
109
|
+
let threwOverwrite = false;
|
|
110
|
+
try {
|
|
111
|
+
const alreadyWitnessed = { ...SC.spendContext,
|
|
112
|
+
tx: { ...SC.spendContext.tx, inputs: SC.spendContext.tx.inputs.map((i, idx) =>
|
|
113
|
+
idx === SC.spendContext.inputIndex ? { ...i, witness: [new Uint8Array(1)] } : i) } };
|
|
114
|
+
await finalizeAndBroadcastM2Spend(result, alreadyWitnessed);
|
|
115
|
+
} catch { threwOverwrite = true; }
|
|
116
|
+
check("★ refuses to overwrite a witness this module did not produce", threwOverwrite);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log("\nTampering — a modified signature must not independently verify:");
|
|
120
|
+
{
|
|
121
|
+
const result = authorizeAndSign(goodReq());
|
|
122
|
+
const { witness } = rebuildM2Witness(result);
|
|
123
|
+
const leafHash = tapLeafHash(witness[1]);
|
|
124
|
+
const digest = taprootScriptPathSighash(SC.spendContext.tx, SC.spendContext.inputIndex,
|
|
125
|
+
SC.spendContext.prevoutValues, SC.spendContext.prevoutScriptPubKeys, leafHash);
|
|
126
|
+
const tampered = Uint8Array.from(witness[0]); tampered[0] ^= 0xff;
|
|
127
|
+
check("★ a flipped signature byte fails independent schnorr.verify",
|
|
128
|
+
schnorr.verify(tampered, digest, Buffer.from(result.policyPublicKeyHex, "hex")) === false);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log("\nByte-level round-trip — hand-parse the serialized tx, no reuse of tx.mjs internals:");
|
|
132
|
+
{
|
|
133
|
+
const result = authorizeAndSign(goodReq());
|
|
134
|
+
const fin = await finalizeAndBroadcastM2Spend(result, SC.spendContext, { broadcast: false });
|
|
135
|
+
const rawTx = Buffer.from(fin.hex, "hex");
|
|
136
|
+
let off = 0;
|
|
137
|
+
const readU32 = () => { const v = rawTx.readUInt32LE(off); off += 4; return v; };
|
|
138
|
+
const readVarint = () => { const b = rawTx[off]; off += 1; if (b < 0xfd) return b;
|
|
139
|
+
if (b === 0xfd) { const v = rawTx.readUInt16LE(off); off += 2; return v; } throw new Error("varint too large for this test"); };
|
|
140
|
+
const readBytes = (n) => { const v = rawTx.subarray(off, off + n); off += n; return v; };
|
|
141
|
+
|
|
142
|
+
check("version == 2", readU32() === 2);
|
|
143
|
+
const marker = rawTx[off]; off += 1; const flag = rawTx[off]; off += 1;
|
|
144
|
+
check("segwit marker/flag == 0x00/0x01", marker === 0x00 && flag === 0x01);
|
|
145
|
+
const nIn = readVarint();
|
|
146
|
+
check("1 input", nIn === 1);
|
|
147
|
+
readBytes(32); readU32();
|
|
148
|
+
check("empty scriptSig (segwit)", readVarint() === 0);
|
|
149
|
+
readU32(); // sequence
|
|
150
|
+
const nOut = readVarint();
|
|
151
|
+
check("output count matches spendContext", nOut === SC.spendContext.tx.outputs.length);
|
|
152
|
+
for (let i = 0; i < nOut; i++) { readU32(); readU32(); readBytes(readVarint()); }
|
|
153
|
+
const nItems = readVarint();
|
|
154
|
+
check("witness has exactly 3 items [sig, script, control]", nItems === 3);
|
|
155
|
+
const sig = readBytes(readVarint());
|
|
156
|
+
const script = readBytes(readVarint());
|
|
157
|
+
const control = readBytes(readVarint());
|
|
158
|
+
check("signature is 64 bytes (SIGHASH_DEFAULT, no appended sighash byte)", sig.length === 64);
|
|
159
|
+
check("control block is 33 bytes (single-leaf tree)", control.length === 33);
|
|
160
|
+
check("locktime == 0", readU32() === 0);
|
|
161
|
+
check("consumed the entire buffer (no trailing garbage, no truncation)", off === rawTx.length);
|
|
162
|
+
|
|
163
|
+
// The strongest check in this file: recompute the Taproot output key from RAW BYTES,
|
|
164
|
+
// hand-rolling the TapLeaf/TapTweak tagged-hash construction rather than calling
|
|
165
|
+
// tapLeafHash/taprootOutputKeyWithTree — so a bug shared between this test and the
|
|
166
|
+
// module under test (both calling the same helper) cannot hide from it.
|
|
167
|
+
const taggedHash = (tag, ...parts) => {
|
|
168
|
+
const th = sha256(new TextEncoder().encode(tag));
|
|
169
|
+
return sha256(Buffer.concat([th, th, ...parts]));
|
|
170
|
+
};
|
|
171
|
+
const leafHash = taggedHash("TapLeaf", Buffer.from([control[0] & 0xfe]), Buffer.from([script.length]), script);
|
|
172
|
+
const internalKey = control.subarray(1, 33);
|
|
173
|
+
const tweak = taggedHash("TapTweak", internalKey, leafHash);
|
|
174
|
+
let t = 0n; for (const b of tweak) t = (t << 8n) | BigInt(b);
|
|
175
|
+
const n = schnorr.utils.secp256k1?.CURVE?.n ?? schnorr.Point?.Fn?.ORDER;
|
|
176
|
+
let ix = 0n; for (const b of internalKey) ix = (ix << 8n) | BigInt(b);
|
|
177
|
+
const Q = schnorr.utils.lift_x(ix).add(schnorr.Point.BASE.multiply(t % n));
|
|
178
|
+
const outputKeyIndependent = Buffer.from(Q.toBytes(true).subarray(1));
|
|
179
|
+
check("★ hand-rolled TapLeaf+TapTweak (no shared code with m2-broadcast.mjs) recomputes " +
|
|
180
|
+
"the EXACT scriptPubKey being spent — the check Bitcoin consensus itself performs",
|
|
181
|
+
hex(SC.spendContext.prevoutScriptPubKeys[0]).slice(4) === outputKeyIndependent.toString("hex"));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
185
|
+
process.exit(fail === 0 ? 0 : 1);
|