fractal-pqc 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ without invalidating already-signed history. This kit is a concrete, honest firs
19
19
 
20
20
  ## What is REAL here (verified, not aspirational)
21
21
 
22
- > **The security layer (v0.4.0):** `transparency.mjs` (PQ-ANCHOR-v1 append-only log:
22
+ > **The security layer (v0.7.0):** `transparency.mjs` (PQ-ANCHOR-v1 append-only log:
23
23
  > inclusion + consistency proofs, ML-DSA-65 signed heads, equivocation detection),
24
24
  > `primacy.mjs` (first-seen proven by complete enumeration + a Bitcoin-anchored temporal
25
25
  > frontier), `policy.mjs` (fail-closed custodian gate), `tapscript.mjs` (BIP-341
@@ -94,7 +94,7 @@ verifyMigrationCommitment(cert).valid; // true
94
94
 
95
95
  ```bash
96
96
  npx fractal-pqc verify-vector # assert the official BIP-340 test vector
97
- npx fractal-pqc selftest # run the full real test suite (46 checks)
97
+ npx fractal-pqc selftest # run the full real test suite (304 checks)
98
98
  npx fractal-pqc keygen > key.json # generate a migration identity (testnet/experimental)
99
99
  npx fractal-pqc keygen --taproot > tk.json # a Taproot key
100
100
  npx fractal-pqc address --key tk.json --network tb # derive a real tb1p… Taproot address
@@ -132,7 +132,18 @@ Providing the anchor registry (first-seen, no-duplicate, immutable) is part of t
132
132
  grant-funded roadmap below; this reference makes the anchor a **required verifier input**
133
133
  so the property is never silently over-claimed.
134
134
 
135
- ## What this is NOT (honest scope — do not overstate)
135
+ ## What this is NOT
136
+
137
+ > **ONE POLICY KEY PER HOLDER — OR BIND THE OUTPUT.** `authorizeAndSign` reads a 32-byte
138
+ > `spendDigest` chosen by the requester. Round 7 of our own siege used that to have one
139
+ > *legitimate* client of a custodian obtain a policy-key signature over **another client's
140
+ > sighash** — which is exactly the "concentration" deployment this package describes. The
141
+ > engine now **refuses any request that does not say which coin it is for**: supply
142
+ > `spendContext.prevoutScriptPubKey` (enforced against the holder's own anchored commitment)
143
+ > or explicitly assert `singlePolicyKeyPerHolder: true`. Silence is refused. Residual, stated
144
+ > rather than hidden: the engine checks the scriptPubKey the commitment governs, it does not
145
+ > recompute the BIP-341 sighash from the transaction.
146
+ (honest scope — do not overstate)
136
147
 
137
148
  - **Not yet confirmed in a live mempool.** The sighash is proven consensus-correct against
138
149
  the official BIP-341 vector, and the broadcaster is built + dry-run-tested — but an actual
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fractal-pqc",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -12,7 +12,8 @@ import * as T from "./transparency.mjs";
12
12
  import * as P from "./primacy.mjs";
13
13
  import * as TS from "./tapscript.mjs";
14
14
  import * as OTS from "./ots.mjs";
15
- import { POLICY_SCOPE, authorizeAndSign, REFUSED } from "./policy.mjs";
15
+ import { POLICY_SCOPE, authorizeAndSign, REFUSED, policyOutputScriptPubKey } from "./policy.mjs";
16
+ import { schnorr } from "@noble/curves/secp256k1.js";
16
17
  import { generateMigrationIdentity, createMigrationCommitment, authorizeSpend } from "./migration-envelope.mjs";
17
18
 
18
19
  const te = new TextEncoder();
@@ -68,6 +69,10 @@ const goodReq = () => ({
68
69
  anchorEvidence: { sth: STH, entries: ENTRIES, otsHex: OTS_HEX },
69
70
  expectedLogId: STH.logId, policyKey: POLICY_KEY,
70
71
  cutoffBlockHeight: CUTOFF, blockMerkleRoots: HEADERS,
72
+ // Round 7 made this explicit rather than defaulted: the engine refuses a request that
73
+ // does not say WHICH coin it is for. These fixtures are single-holder scenarios, so they
74
+ // assert the safe deployment instead of silently relying on it.
75
+ singlePolicyKeyPerHolder: true,
71
76
  });
72
77
 
73
78
  export const CLAIMS = [
@@ -224,6 +229,78 @@ export const CLAIMS = [
224
229
  note: "The founder's insight: what is at risk is the PAST. A global Q-day is a guess; " +
225
230
  "a key's exposure block is a fact in Bitcoin.",
226
231
  },
232
+ {
233
+ id: "R7-output-binding-or-refusal",
234
+ module: "policy.mjs",
235
+ statement: "The engine never signs without knowing WHICH coin it signs for. Either the " +
236
+ "caller binds the spend to an output (enforced against the holder's own " +
237
+ "anchored commitment) or the caller explicitly asserts one policy key per " +
238
+ "holder. Silence is refused, and one holder's certificate can never authorise " +
239
+ "a spend of another holder's output.",
240
+ proof: () => {
241
+ // Bound to the output this holder's commitment actually governs: authorises, and the
242
+ // receipt SAYS it is bound.
243
+ const anchoredFactHash = authorizeAndSign(goodReq()).evidence.anchoredFactHash;
244
+ const spk = policyOutputScriptPubKey(anchoredFactHash, schnorr.getPublicKey(POLICY_KEY));
245
+ const r = authorizeAndSign({ ...goodReq(), spendContext: { prevoutScriptPubKey: spk } });
246
+ return r.authorized === true && r.evidence.outputBound === true;
247
+ },
248
+ attack: () => {
249
+ // 1. Silence must NOT be the permissive default. This is the whole finding: round 7
250
+ // had one legitimate client of a custodian buy a signature over another client's
251
+ // sighash precisely because omitting the binding authorised anyway.
252
+ const { singlePolicyKeyPerHolder: _drop, ...silent } = goodReq();
253
+ const q = authorizeAndSign(silent);
254
+ if (q.authorized !== false || q.policySignatureHex !== null) return false;
255
+ // 2. A DIFFERENT holder's output must be refused, not signed.
256
+ const other = "11".repeat(32);
257
+ const foreign = policyOutputScriptPubKey(other, schnorr.getPublicKey(POLICY_KEY));
258
+ const r = authorizeAndSign({ ...goodReq(), spendContext: { prevoutScriptPubKey: foreign } });
259
+ if (r.authorized !== false || r.policySignatureHex !== null) return false;
260
+ // 3. A malformed binding must refuse rather than fall back to the unbound path.
261
+ const bad = authorizeAndSign({ ...goodReq(), spendContext: { prevoutScriptPubKey: "00" } });
262
+ if (bad.authorized !== false) return false;
263
+ // 4. The asserted single-key mode still works, and the receipt does not claim binding.
264
+ const a = authorizeAndSign(goodReq());
265
+ return a.authorized === true && a.evidence.outputBound === false;
266
+ },
267
+ note: "Round 7's critical. `spendDigest` is 32 opaque bytes chosen by the requester; " +
268
+ "before this, the receipt named the HOLDER while the coin could be anyone's. " +
269
+ "RESIDUAL, stated rather than hidden: the engine checks that the scriptPubKey is " +
270
+ "the one this commitment governs, but does not recompute the BIP-341 sighash from " +
271
+ "the transaction — the caller must derive the digest from the prevout it presents.",
272
+ },
273
+ {
274
+ id: "R7-no-claim-without-the-proof-that-earns-it",
275
+ module: "policy.mjs",
276
+ statement: "No evidence field this engine emits can be supplied by the caller. Exposure " +
277
+ "claims are DERIVED from a proof this engine ran, and evidence that can change " +
278
+ "between the check and the use is refused rather than read twice.",
279
+ proof: () => {
280
+ const r = authorizeAndSign({ ...goodReq(), exposureHeight: ANCHOR_HEIGHT + 50_000 });
281
+ return r.evidence.exposureRelative === true && r.evidence.anchoredAtHeight === ANCHOR_HEIGHT;
282
+ },
283
+ attack: () => {
284
+ // 1. Inject the conclusion: a fabricated internal field must never become a claim.
285
+ const inj = authorizeAndSign({ ...goodReq(),
286
+ _exposureProof: { exposureRelative: true, anchoredAtHeight: 1,
287
+ exposureHeight: 9_999_999, strength: "anchored-before-exposure" } });
288
+ if (inj.authorized !== false) return false;
289
+ if (inj.evidence && inj.evidence.exposureRelative === true) return false;
290
+ // 2. Two-faced evidence: an accessor can return different bytes to the gate and to the
291
+ // authorisation. It must be refused outright, not read twice.
292
+ const g = goodReq();
293
+ const twoFaced = { get sth() { return g.anchorEvidence.sth; },
294
+ entries: g.anchorEvidence.entries, otsHex: g.anchorEvidence.otsHex };
295
+ const t = authorizeAndSign({ ...g, anchorEvidence: twoFaced });
296
+ return t.authorized === false && t.policySignatureHex === null;
297
+ },
298
+ note: "Round 7 found BOTH halves: `_exposureProof` travelled on the caller's object and " +
299
+ "`exposureRelative: true` was a hardcoded literal, so a plain JSON request produced " +
300
+ "this package's strongest claim with no proof behind it — even when the injected " +
301
+ "object said false. The TOCTOU discipline applied to spendDigest had not been " +
302
+ "applied to anchorEvidence one field further out.",
303
+ },
227
304
  {
228
305
  id: "R6-exposure-gate-enforced",
229
306
  module: "policy.mjs",
@@ -501,9 +578,18 @@ export const UNCLAIMED_GUARDS = Object.freeze([
501
578
  "blocks) — again stronger than a claim, again not in the ledger" },
502
579
  { where: "policy.mjs", guard: "the ORDER of the refusal guards",
503
580
  why: "each guard is claimed individually; nothing claims that reordering them is safe" },
504
- { where: "primacy.mjs", guard: "exposure-relative primacy is not an authorisation gate",
505
- why: "policy.mjs does not consume it. It is a verifiable proof primitive; nothing in the " +
506
- "signing path requires it, and we do not imply otherwise" },
581
+ { where: "policy.mjs", guard: "WHICH OUTPUT is being spent — unless spendContext is supplied",
582
+ why: "the engine reads a 32-byte spendDigest chosen by the requester. WITHOUT " +
583
+ "`spendContext.prevoutScriptPubKey` the receipt identifies the HOLDER, never the " +
584
+ "COIN: it records who was authorised to make the policy key sign SOMETHING. Round 7 " +
585
+ "used exactly this to have one legitimate client of a custodian buy a signature over " +
586
+ "another client's sighash. Supply spendContext (enforced when present) or use ONE " +
587
+ "POLICY KEY PER HOLDER, without exception" },
588
+ { where: "policy.mjs / primacy.mjs", guard: "prose-to-code agreement",
589
+ why: "the ledger governs CODE -> CLAIM (kill the code and the claim dies). NOTHING " +
590
+ "governs PROSE -> CODE. Every one of round 7's findings landed in that hole, " +
591
+ "including a frozen *_SCOPE string that asserted the M2 gate did not exist while " +
592
+ "the gate was refusing signatures. This is the ledger's own known limit" },
507
593
  { where: "primacy.mjs", guard: "'never exposed' is the CALLER's assertion",
508
594
  why: "the kit cannot verify non-exposure. It can only refuse when the field is absent, " +
509
595
  "which it now does. Off-chain publication of a key is outside what we measure" },
package/src/mutations.mjs CHANGED
@@ -238,6 +238,33 @@ export const MUTATIONS = [
238
238
  to: ` return { equivocation: false, reason: "heads-agree" };`,
239
239
  mustKill: ["R1-same-size-is-non-repudiable"],
240
240
  },
241
+ {
242
+ id: "M-R7-output-binding-optional",
243
+ describes: "R7: silence becomes the permissive default again — the engine signs without " +
244
+ "ever knowing which coin it is signing for",
245
+ file: "src/policy.mjs",
246
+ from: ` if (!req || (!Object.hasOwn(req, "spendContext") && req.singlePolicyKeyPerHolder !== true)) {`,
247
+ to: ` if (false) {`,
248
+ mustKill: ["R7-output-binding-or-refusal"],
249
+ },
250
+ {
251
+ id: "M-R7-injected-conclusion-accepted",
252
+ describes: "R7: internal fields travel on the caller's request again, so a fabricated " +
253
+ "_exposureProof states this package's strongest claim with no proof behind it",
254
+ file: "src/policy.mjs",
255
+ from: ` if (k.startsWith("_")) {`,
256
+ to: ` if (k.startsWith("\u0000NEVER")) {`,
257
+ mustKill: ["R7-no-claim-without-the-proof-that-earns-it"],
258
+ },
259
+ {
260
+ id: "M-R7-two-faced-evidence",
261
+ describes: "R7: accessor-backed anchorEvidence is read twice again — the gate judges one " +
262
+ "anchor and the authorisation rests on another",
263
+ file: "src/policy.mjs",
264
+ from: ` if (d && typeof d.get === "function") {`,
265
+ to: ` if (false) {`,
266
+ mustKill: ["R7-no-claim-without-the-proof-that-earns-it"],
267
+ },
241
268
  ];
242
269
 
243
270
  /**
package/src/policy.mjs CHANGED
@@ -49,8 +49,46 @@ import { schnorr } from "@noble/curves/secp256k1.js";
49
49
  import { verifySpend } from "./migration-envelope.mjs";
50
50
  import { certIsFirstSeen, proveExposureRelativePrimacy } from "./primacy.mjs";
51
51
  import { detectEquivocation } from "./transparency.mjs";
52
+ import { NUMS_INTERNAL_KEY, tapLeafHash, taprootOutputKeyWithTree } from "./tapscript.mjs";
52
53
 
53
54
  const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
55
+ const fromHexP = (h) => {
56
+ if (typeof h !== "string" || h.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(h)) return null;
57
+ const out = new Uint8Array(h.length / 2);
58
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
59
+ return out;
60
+ };
61
+
62
+ /**
63
+ * THE CANONICAL POLICY LEAF — the thing that makes an authorisation about a COIN and not
64
+ * merely about a person.
65
+ *
66
+ * <32-byte anchoredFactHash> OP_DROP <32-byte policy x-only key> OP_CHECKSIG
67
+ *
68
+ * The pushed fact hash is consensus-inert (OP_DROP discards it) but it is COMMITTED: it is
69
+ * inside the leaf, so it is inside the TapLeaf hash, so it is inside the Taproot output key,
70
+ * so it is inside the scriptPubKey the chain already holds. One holder's commitment therefore
71
+ * produces exactly one output key, and no other holder's does.
72
+ */
73
+ export function policyLeafScript(anchoredFactHashHex, policyXOnly) {
74
+ const fh = fromHexP(anchoredFactHashHex);
75
+ if (!fh || fh.length !== 32) return null;
76
+ if (!(policyXOnly instanceof Uint8Array) || policyXOnly.length !== 32) return null;
77
+ const out = new Uint8Array(1 + 32 + 1 + 1 + 32 + 1);
78
+ out[0] = 0x20; out.set(fh, 1); out[33] = 0x75; // push32 <factHash> OP_DROP
79
+ out[34] = 0x20; out.set(policyXOnly, 35); out[67] = 0xac; // push32 <policyPub> OP_CHECKSIG
80
+ return out;
81
+ }
82
+
83
+ /** The P2TR scriptPubKey a given holder's commitment must produce: OP_1 PUSH32 <outputKey>. */
84
+ export function policyOutputScriptPubKey(anchoredFactHashHex, policyXOnly) {
85
+ const leaf = policyLeafScript(anchoredFactHashHex, policyXOnly);
86
+ if (!leaf) return null;
87
+ const { outputKey } = taprootOutputKeyWithTree(NUMS_INTERNAL_KEY, tapLeafHash(leaf));
88
+ const spk = new Uint8Array(34);
89
+ spk[0] = 0x51; spk[1] = 0x20; spk.set(outputKey, 2);
90
+ return spk;
91
+ }
54
92
 
55
93
  export const REFUSED = Object.freeze({
56
94
  NO_ANCHOR_EVIDENCE: "no-anchor-evidence",
@@ -59,6 +97,8 @@ export const REFUSED = Object.freeze({
59
97
  PQ_INVALID: "pq-signature-invalid",
60
98
  NO_POLICY_KEY: "policy-key-unavailable",
61
99
  BAD_DIGEST: "spend-digest-malformed",
100
+ MALFORMED_REQUEST: "request-malformed",
101
+ OUTPUT_NOT_BOUND: "spend-not-bound-to-this-holder-output",
62
102
  ERROR: "internal-error-fail-closed",
63
103
  });
64
104
 
@@ -88,6 +128,22 @@ export function authorizeAndSign(req) {
88
128
  cert, spendDigest, pqSignatureHex, anchorEvidence, expectedLogId, policyKey,
89
129
  } = req || {};
90
130
 
131
+ // ROUND 7. `_exposureProof` used to be routed BACK through the caller's own object, so a
132
+ // plain JSON request carrying a fabricated `_exposureProof` made the engine emit this
133
+ // package's STRONGEST claim — exposure-relative primacy, invented heights, "no Q-day
134
+ // estimate is involved" — without the gate ever running, and even when the injected
135
+ // object said the opposite. That is round 6's defect (absent field buys the maximal
136
+ // claim) reappearing inside the file written to cure it. Internal state never travels
137
+ // on the request, and a request that carries any is refused rather than sanitised.
138
+ for (const k of Object.keys(req || {})) {
139
+ if (k.startsWith("_")) {
140
+ return refuse(REFUSED.MALFORMED_REQUEST,
141
+ `The request carries an internal field (${k}). Fields prefixed with "_" are produced ` +
142
+ `by this engine and never accepted from a caller: supplying one is an attempt to ` +
143
+ `state a conclusion instead of proving it.`);
144
+ }
145
+ }
146
+
91
147
  // TOCTOU: `instanceof` + `.length` are both satisfied by a Proxy that returns
92
148
  // different bytes on successive reads, so the ML-DSA check and the Schnorr signature
93
149
  // saw DIFFERENT digests — one authorised spend bought a signature over another.
@@ -109,6 +165,29 @@ export function authorizeAndSign(req) {
109
165
  "head and the complete ordered entry list. The anchor is the whole quantum property; " +
110
166
  "without it there is nothing to enforce.");
111
167
  }
168
+
169
+ // ROUND 7, THE SAME TOCTOU WE ALREADY FIXED ONCE — one field further out. `spendDigest`
170
+ // got `snapshot32` after an earlier siege; `anchorEvidence` did not, and it is read by
171
+ // the M2 gate, then again by certIsFirstSeen, then again by detectEquivocation. A single
172
+ // accessor split that seam: the exposure gate judged an EARLY anchor while authorisation
173
+ // was founded on a LATE one, and the receipt came out contradicting itself. Discipline is
174
+ // only discipline if it is applied to every caller-supplied input: refuse accessor-backed
175
+ // carriers, then snapshot ONCE and read nothing but the snapshot below.
176
+ for (const k of ["sth", "entries", "otsHex"]) {
177
+ const d = Object.getOwnPropertyDescriptor(anchorEvidence, k);
178
+ if (d && typeof d.get === "function") {
179
+ return refuse(REFUSED.NO_ANCHOR_EVIDENCE,
180
+ `anchorEvidence.${k} is accessor-backed, so it can return different bytes on ` +
181
+ `successive reads. Evidence that is allowed to change between the check and the ` +
182
+ `use is refused outright rather than read twice.`);
183
+ }
184
+ }
185
+ const ev = Object.freeze({
186
+ sth: anchorEvidence.sth,
187
+ entries: anchorEvidence.entries.slice(),
188
+ otsHex: anchorEvidence.otsHex,
189
+ });
190
+ const subjectPub = cert?.classicalPub; // read ONCE; every consumer below uses this copy
112
191
  if (req && req.cutoffBlockHeight != null &&
113
192
  (typeof req.cutoffBlockHeight !== "number" || !Number.isSafeInteger(req.cutoffBlockHeight) ||
114
193
  req.cutoffBlockHeight < 0)) {
@@ -144,7 +223,7 @@ export function authorizeAndSign(req) {
144
223
  // contradiction instead of warning about it.
145
224
  if (Array.isArray(req.knownHeads) && req.knownHeads.length) {
146
225
  for (const other of req.knownHeads) {
147
- const d = detectEquivocation(anchorEvidence.sth, other);
226
+ const d = detectEquivocation(ev.sth, other);
148
227
  if (d.equivocation === true) {
149
228
  return refuse(REFUSED.ANCHOR_UNRESOLVED,
150
229
  "This log signed two contradictory histories. Every proof it serves is void, " +
@@ -159,15 +238,23 @@ export function authorizeAndSign(req) {
159
238
  // neither — shipping a stronger proof that nothing consumes — is how a package ends up
160
239
  // claiming more than it enforces. This is the wiring.
161
240
  //
162
- // FAIL-CLOSED BY CONSTRUCTION: if the caller supplies `exposureHeight` at all, it is
163
- // ENFORCED. There is no way to pass it and have it ignored, because a parameter that
164
- // can be supplied and silently dropped is worse than one that does not exist.
241
+ // FAIL-CLOSED WHEN SUPPLIED: if the caller supplies `exposureHeight` as an OWN data
242
+ // property, it is ENFORCED — it cannot be passed and silently dropped, because a
243
+ // parameter that can be is worse than one that does not exist. Two honest bounds, both
244
+ // found by round 7 and both stated here rather than only in prose: (1) "supplied" means
245
+ // an own property, so an inherited or Proxy-hidden one is treated as ABSENT and falls
246
+ // back to the cutoff frontier — it never yields a false exposure claim, but it is not
247
+ // enforced either; (2) this gate is a CONJUNCTION, never a substitution: cutoffBlockHeight
248
+ // stays mandatory above and certIsFirstSeen still applies it below. Supplying an exposure
249
+ // height NARROWS what authorises. It never rescues an anchor the cutoff rejects.
250
+ let exposureProof = null;
165
251
  if (req && Object.hasOwn(req, "exposureHeight")) {
252
+ const exposureHeight = req.exposureHeight; // ONE read; the gate and the evidence share it
166
253
  const exp = proveExposureRelativePrimacy({
167
- subjectClassicalPub: cert?.classicalPub,
168
- entries: anchorEvidence.entries, sth: anchorEvidence.sth, expectedLogId,
169
- exposureHeight: req.exposureHeight,
170
- otsHex: anchorEvidence.otsHex, blockMerkleRoots: req.blockMerkleRoots,
254
+ subjectClassicalPub: subjectPub,
255
+ entries: ev.entries, sth: ev.sth, expectedLogId,
256
+ exposureHeight,
257
+ otsHex: ev.otsHex, blockMerkleRoots: req.blockMerkleRoots,
171
258
  });
172
259
  if (!exp.exposureRelative) {
173
260
  return refuse(REFUSED.ANCHOR_UNRESOLVED,
@@ -179,8 +266,9 @@ export function authorizeAndSign(req) {
179
266
  : `Exposure-relative primacy could not be established (${exp.reason}).`,
180
267
  { exposure: exp });
181
268
  }
182
- // Carry the stronger evidence forward so the caller can see WHY it was authorised.
183
- req = { ...req, _exposureProof: exp };
269
+ // Held in a LOCAL. It never re-enters the caller's object, so nothing the caller
270
+ // supplies can impersonate a proof this engine did not run.
271
+ exposureProof = exp;
184
272
  }
185
273
 
186
274
  // PRIMACY, not membership. The anchor is the EARLIEST binding of this certificate's
@@ -190,9 +278,9 @@ export function authorizeAndSign(req) {
190
278
  // below the cutoff separates them. `requireTemporalFrontier` defaults to true and this
191
279
  // engine never turns it off.
192
280
  const anchor = certIsFirstSeen({
193
- cert, entries: anchorEvidence.entries, sth: anchorEvidence.sth, expectedLogId,
281
+ cert, entries: ev.entries, sth: ev.sth, expectedLogId,
194
282
  cutoffBlockHeight: req.cutoffBlockHeight,
195
- otsHex: anchorEvidence.otsHex,
283
+ otsHex: ev.otsHex,
196
284
  blockMerkleRoots: req.blockMerkleRoots,
197
285
  requireTemporalFrontier: true,
198
286
  });
@@ -206,6 +294,67 @@ export function authorizeAndSign(req) {
206
294
  { anchor });
207
295
  }
208
296
 
297
+ // ── ROUND 7'S CRITICAL: THE ENGINE NEVER KNEW WHICH COIN IT WAS SIGNING ──────
298
+ // `spendDigest` is 32 opaque bytes chosen by whoever asks, and until now NOTHING tied
299
+ // them to an output. Our own siege stood up a SECOND, entirely legitimate client of the
300
+ // same custodian — its own first-seen certificate, honestly anchored strictly before its
301
+ // own exposure — and asked for a signature over the VICTIM's sighash. Every check passed
302
+ // and the policy key signed. The receipt named the bystander's fact hash while the coin
303
+ // belonged to someone else.
304
+ //
305
+ // This is not a corner case. It is precisely the deployment the paragraph below SELLS:
306
+ // one policy key across N tapscript leaves. Concentration without output binding turns
307
+ // every authorised client into a thief of every other client of the same custodian.
308
+ //
309
+ // The binding: the leaf governing a holder's output COMMITS to that holder's
310
+ // anchoredFactHash, so we can recompute the only scriptPubKey this certificate could
311
+ // legitimately govern and refuse if it is not the coin in front of us. Enforced whenever
312
+ // `spendContext` is supplied, with the same discipline as the cutoff: supplied means
313
+ // enforced, and refusal is the default on anything malformed.
314
+ // FAIL-CLOSED, NOT MERELY DOCUMENTED. A footgun you can step on by omission is a
315
+ // fail-open default with a paragraph next to it. There are exactly two safe
316
+ // configurations, and the caller must be in one of them ON PURPOSE:
317
+ // (a) supply `spendContext` and the engine binds the authorisation to the coin; or
318
+ // (b) assert `singlePolicyKeyPerHolder: true`, which is safe because a key that
319
+ // governs one holder's outputs cannot be aimed at another holder's coin.
320
+ // Silence is neither, and silence is what round 7 exploited.
321
+ let outputBinding = null;
322
+ if (!req || (!Object.hasOwn(req, "spendContext") && req.singlePolicyKeyPerHolder !== true)) {
323
+ return refuse(REFUSED.OUTPUT_NOT_BOUND,
324
+ "This request does not say WHICH COIN it is for. `spendDigest` is 32 opaque bytes " +
325
+ "chosen by the requester, so without a binding this engine would authorise the " +
326
+ "holder rather than the spend — and under one shared policy key that lets any " +
327
+ "authorised client obtain a signature over any other client's sighash. Our own " +
328
+ "round-7 siege did exactly that. Supply `spendContext.prevoutScriptPubKey` (the " +
329
+ "34-byte P2TR scriptPubKey of the output being spent) and it will be ENFORCED, or " +
330
+ "state `singlePolicyKeyPerHolder: true` if this policy key governs exactly one " +
331
+ "holder's outputs. We will not pick for you, and we will not default to the unsafe one.");
332
+ }
333
+ if (req && Object.hasOwn(req, "spendContext")) {
334
+ const sc = req.spendContext;
335
+ const spk = sc && sc.prevoutScriptPubKey;
336
+ const given = spk instanceof Uint8Array ? spk.slice()
337
+ : (typeof spk === "string" ? fromHexP(spk) : null);
338
+ if (!given || given.length !== 34 || given[0] !== 0x51 || given[1] !== 0x20) {
339
+ return refuse(REFUSED.OUTPUT_NOT_BOUND,
340
+ "spendContext.prevoutScriptPubKey must be the 34-byte P2TR scriptPubKey of the " +
341
+ "output being spent (OP_1 PUSH32 <output key>). It is the only thing that makes " +
342
+ "this authorisation about a COIN rather than about a person.");
343
+ }
344
+ const expected = policyOutputScriptPubKey(anchor.anchoredFactHash,
345
+ schnorr.getPublicKey(policyKey));
346
+ if (!expected || toHex(expected) !== toHex(given)) {
347
+ return refuse(REFUSED.OUTPUT_NOT_BOUND,
348
+ "This output is not governed by THIS holder's commitment. The Taproot output key " +
349
+ "recomputed from the anchored first-seen fact hash and the policy key does not " +
350
+ "match the scriptPubKey being spent, so authorising it would let one client of " +
351
+ "this custodian spend another client's coin.",
352
+ { expectedScriptPubKeyHex: expected ? toHex(expected) : null,
353
+ presentedScriptPubKeyHex: toHex(given) });
354
+ }
355
+ outputBinding = { bound: true, scriptPubKeyHex: toHex(given) };
356
+ }
357
+
209
358
  const spend = verifySpend(cert, digest, pqSignatureHex, {
210
359
  anchoredFactHash: anchor.anchoredFactHash,
211
360
  });
@@ -228,15 +377,38 @@ export function authorizeAndSign(req) {
228
377
  ignoredUnsignedEntries: anchor.ignoredUnsignedEntries,
229
378
  temporalFrontier: anchor.temporalFrontier,
230
379
  quantumPropertyHolds: anchor.quantumPropertyHolds,
231
- ...(req._exposureProof ? {
232
- exposureRelative: true,
233
- anchoredAtHeight: req._exposureProof.anchoredAtHeight,
234
- exposureHeight: req._exposureProof.exposureHeight,
235
- strength: req._exposureProof.strength,
236
- whyThisIsStronger: "no Q-day estimate is involved: at the moment of anchoring the " +
237
- "chain had not yet revealed this public key, so breaking secp256k1 was not " +
238
- "sufficient to produce this commitment",
380
+ // DERIVED from the proof this engine ran, never a literal. Round 7 emitted a
381
+ // hardcoded `true` here, so an injected object produced the maximal claim with no
382
+ // proof behind it — including when the injected object itself said false.
383
+ ...(exposureProof ? {
384
+ exposureRelative: exposureProof.exposureRelative === true,
385
+ anchoredAtHeight: exposureProof.anchoredAtHeight,
386
+ exposureHeight: exposureProof.exposureHeight,
387
+ strength: exposureProof.strength,
388
+ // HONEST WORDING, round 7. The previous sentence said "no Q-day estimate is
389
+ // involved" about an authorisation that had ALSO required, and applied, the
390
+ // caller's cutoff. The exposure gate is a CONJUNCTION: it narrows, it never
391
+ // rescues. An anchor the cutoff rejects is still rejected however strong the
392
+ // exposure proof is.
393
+ whyThisIsStronger:
394
+ `this authorisation satisfied BOTH frontiers: the cutoff you chose ` +
395
+ `(H=${req.cutoffBlockHeight}), which is an estimate, AND — additionally — that ` +
396
+ `the commitment was anchored strictly before the chain first revealed this ` +
397
+ `public key. Only the SECOND of those depends on no Q-day estimate. It is an ` +
398
+ `extra condition, not a replacement for the first.`,
239
399
  } : {}),
400
+ // Stated on EVERY receipt, true or false, so its absence can never be mistaken for
401
+ // a guarantee that was never given.
402
+ outputBound: outputBinding !== null,
403
+ ...(outputBinding ? { boundScriptPubKeyHex: outputBinding.scriptPubKeyHex } : {
404
+ outputBindingCaveat:
405
+ "no spendContext was supplied. This receipt identifies the HOLDER, not the COIN: " +
406
+ "it records who was authorised to make the policy key sign SOMETHING. It is only " +
407
+ "sound because the caller ASSERTED singlePolicyKeyPerHolder — an assertion this " +
408
+ "engine cannot verify. If that assertion is false, this receipt does not mean " +
409
+ "what it appears to mean.",
410
+ singlePolicyKeyPerHolderAsserted: true,
411
+ }),
240
412
  digestSigned: toHex(digest),
241
413
  logId: anchor.logId,
242
414
  logPinned: true,
@@ -301,9 +473,20 @@ export const POLICY_SCOPE = Object.freeze({
301
473
  "the policy key and nothing else, so a quantum adversary who reaches that key spends " +
302
474
  "anyway. What this does is CONCENTRATE risk — N exposed classical keys become one " +
303
475
  "hash-hidden, rotatable, custodian-controlled key — and produce a post-quantum-signed, " +
304
- "verifiable record of who SHOULD have been allowed to spend, giving the custodian a " +
305
- "cryptographic reason to refuse. That is a real reduction in surface and a governance " +
306
- "point. It is not immunity, and we will not describe it as one.",
476
+ "verifiable record of who was authorised to make that key sign. It is not immunity, and " +
477
+ "we will not describe it as one. NOTE THE CORRECTION: this string used to read 'who " +
478
+ "SHOULD have been allowed to spend', which claims a binding to a COIN that the engine " +
479
+ "only performs when `spendContext` is supplied — see outputBinding below.",
480
+ outputBinding:
481
+ "THE COIN IS ONLY BOUND WHEN YOU BIND IT. Supply `spendContext.prevoutScriptPubKey` and " +
482
+ "the engine recomputes the Taproot output key from THIS holder's anchoredFactHash and " +
483
+ "the policy key, and refuses any other output — enforced whenever supplied. OMIT IT and " +
484
+ "the authorisation is about the HOLDER, not the coin: the requester chooses 32 opaque " +
485
+ "digest bytes and nothing checks what they belong to. Round 7 of our own siege used that " +
486
+ "to have one legitimate client of a custodian obtain a policy-key signature over another " +
487
+ "client's sighash — which is exactly the 'concentration' deployment described above. " +
488
+ "Either supply spendContext, or run ONE POLICY KEY PER HOLDER. Every receipt states " +
489
+ "`outputBound` so its absence can never be mistaken for a guarantee.",
307
490
  limitFour_historyNotPinned:
308
491
  "pinning the log IDENTITY does not pin its HISTORY. Two same-size heads under the same " +
309
492
  "logId both satisfy every check here. Split view is DETECTABLE (transparency." +
@@ -311,11 +494,15 @@ export const POLICY_SCOPE = Object.freeze({
311
494
  "witness, mirror or gossip channel and the engine refuses on any contradiction; without " +
312
495
  "them you are trusting the log to have shown you its only history.",
313
496
  exposureRelativeGate:
314
- "if `exposureHeight` is supplied it is ENFORCED, never advisory — a parameter that can " +
315
- "be passed and silently dropped is worse than one that does not exist. When enforced, " +
316
- "authorisation requires the commitment to have been anchored strictly before the chain " +
317
- "first revealed the key, which needs no Q-day estimate. Omit it and the engine falls " +
318
- "back to the cutoff frontier, which is a guess and says so.",
497
+ "if `exposureHeight` is supplied as an OWN property it is ENFORCED, never advisory — a " +
498
+ "parameter that can be passed and silently dropped is worse than one that does not " +
499
+ "exist. When enforced, authorisation ADDITIONALLY requires the commitment to have been " +
500
+ "anchored strictly before the chain first revealed the key, and THAT condition needs no " +
501
+ "Q-day estimate. It is a CONJUNCTION, never a substitution: cutoffBlockHeight stays " +
502
+ "mandatory and is still applied afterwards, so a commitment anchored at 800000 with " +
503
+ "exposure at 850000 is STILL refused under a cutoff of 790000. The gate narrows what " +
504
+ "authorises; it never rescues what the cutoff rejects. Omit it and the engine falls back " +
505
+ "to the cutoff frontier alone, which is a guess and says so.",
319
506
  precondition:
320
507
  "the true holder must have registered a dual-signed commitment BEFORE the cutoff. If " +
321
508
  "they never did, no one is authorised — not them, not an attacker. Those coins are " +
package/src/primacy.mjs CHANGED
@@ -668,10 +668,24 @@ export const EXPOSURE_SCOPE = Object.freeze({
668
668
  "shared xpub, or anywhere off-chain was derivable earlier than this proof suggests. " +
669
669
  "The adversary's window opens at min(any publication of the key, on-chain exposure); " +
670
670
  "this kit measures the second.",
671
- notAnAuthorisationGate:
672
- "policy.mjs does not consume this. It is an independently verifiable proof primitive, " +
673
- "not yet a mandatory gate on the signing path. Saying otherwise would be the sixth " +
674
- "incarnation of the mistake this package documents.",
671
+ isAnAuthorisationGate:
672
+ "policy.mjs DOES consume this. If authorizeAndSign receives `exposureHeight` as an own " +
673
+ "property, exposure-relative primacy becomes a NECESSARY condition for signing and the " +
674
+ "engine refuses without it. It stays opt-in: omit the field and the engine falls back " +
675
+ "to the cutoff frontier. (This string previously said the opposite and stayed stale " +
676
+ "after M2 wired the gate — round 7 caught the package contradicting itself in its own " +
677
+ "machine-readable scope. That is the disease, with the polarity inverted.)",
678
+ narrowsNeverRescues:
679
+ "the gate is a CONJUNCTION, never a substitution. cutoffBlockHeight remains mandatory " +
680
+ "and certIsFirstSeen still applies it AFTER this check, so an anchor at 800000 with " +
681
+ "exposure at 850000 — the strongest position this scheme can give — is still REFUSED " +
682
+ "under a cutoff of 790000. Supplying an exposure height narrows what authorises; it " +
683
+ "never rescues what the cutoff rejects.",
684
+ blindToTheCertificate:
685
+ "proveExposureRelativePrimacy judges by `subjectClassicalPub` ALONE — it never sees the " +
686
+ "certificate presented. In policy.mjs that composes correctly, because certIsFirstSeen " +
687
+ "kills the post-Shor rebinding attacker afterwards. Used ON ITS OWN as a gate, this " +
688
+ "primitive AUTHORISES that attacker. If you reuse it, compose it as policy.mjs does.",
675
689
  exposureHeightIsNotOurs:
676
690
  "the verifier supplies it from their own node. An exposure height we asserted would be " +
677
691
  "one more thing to trust us about, and the point is that it is independently checkable.",
@@ -99,7 +99,7 @@ const evidence = { sth, entries: entryList };
99
99
  const HEADERS = { 800000: "aa".repeat(32) };
100
100
  const base = { cert, spendDigest: digest, pqSignatureHex: pqSig, anchorEvidence: evidence,
101
101
  expectedLogId: sth.logId, policyKey: policyPriv, cutoffBlockHeight: CUTOFF,
102
- blockMerkleRoots: HEADERS };
102
+ blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true };
103
103
  {
104
104
  check("REFUSES with no transparency-log evidence at all",
105
105
  authorizeAndSign({ ...base, anchorEvidence: undefined }).reason === REFUSED.NO_ANCHOR_EVIDENCE);
@@ -108,7 +108,7 @@ const base = { cert, spendDigest: digest, pqSignatureHex: pqSig, anchorEvidence:
108
108
  check("★ REFUSES when no Q-day cutoff is supplied — it will not guess it for you",
109
109
  authorizeAndSign({ ...base, cutoffBlockHeight: undefined }).reason === REFUSED.NO_ANCHOR_EVIDENCE);
110
110
  check("★ REFUSES when no block headers are supplied — the .ots alone is not a Bitcoin fact",
111
- authorizeAndSign({ ...base, blockMerkleRoots: undefined }).detail.includes("never from the bundle"));
111
+ authorizeAndSign({ ...base, blockMerkleRoots: undefined, singlePolicyKeyPerHolder: true }).detail.includes("never from the bundle"));
112
112
  check("REFUSES when the entry list is incomplete (primacy cannot be proven)",
113
113
  authorizeAndSign({ ...base, anchorEvidence: { ...evidence, entries: entryList.slice(0, 1) } }).reason === REFUSED.ANCHOR_UNRESOLVED);
114
114
  check("REFUSES when the log is the WRONG log",
package/test/primacy.mjs CHANGED
@@ -189,7 +189,7 @@ console.log("\n★★★ ROUND 3 — what Bitcoin freezes is the PROOF OF CONTRO
189
189
  const HEADERS = { [real.height]: real.root }; HDRS = HEADERS; // what a verifier gets from THEIR node
190
190
 
191
191
  // (a) THE POSITIVE PATH — the test that did not exist in any previous round.
192
- const ok = establishTemporalFrontier({ headBytes, otsHex, cutoffBlockHeight: 900_000, blockMerkleRoots: HEADERS });
192
+ const ok = establishTemporalFrontier({ headBytes, otsHex, cutoffBlockHeight: 900_000, blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true });
193
193
  check("★ (a) POSITIVE PATH: header-confirmed anchor below the cutoff ESTABLISHES the frontier",
194
194
  ok.established === true && ok.headerConfirmed === true && ok.height === real.height);
195
195
 
@@ -208,17 +208,17 @@ console.log("\n★★★ ROUND 3 — what Bitcoin freezes is the PROOF OF CONTRO
208
208
  f.timestamp.attestations = [...(f.timestamp.attestations || []), { kind: "bitcoin", height: 1 }];
209
209
  return T.toHex(O.encodeOtsFile(f));
210
210
  })();
211
- const t = establishTemporalFrontier({ headBytes, otsHex: tampered, cutoffBlockHeight: 500, blockMerkleRoots: HEADERS });
211
+ const t = establishTemporalFrontier({ headBytes, otsHex: tampered, cutoffBlockHeight: 500, blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true });
212
212
  check("★ (d) REGRESSION: grafting {bitcoin, height:1} no longer downgrades a real anchor",
213
213
  t.established === false);
214
214
  check("...and the real anchor is still selected when the cutoff allows it",
215
215
  establishTemporalFrontier({ headBytes, otsHex: tampered, cutoffBlockHeight: 900_000,
216
- blockMerkleRoots: HEADERS }).height === real.height);
216
+ blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true }).height === real.height);
217
217
 
218
218
  // (e) a .ots forged from scratch, claiming a height the verifier never enumerated.
219
219
  check("★ (e) FIX: a forged .ots claiming an unenumerated height is REFUSED, not skipped",
220
220
  establishTemporalFrontier({ headBytes, otsHex: mkOts(123), cutoffBlockHeight: 900_000,
221
- blockMerkleRoots: HEADERS }).reason === "no-attestation-header-confirmed");
221
+ blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true }).reason === "no-attestation-header-confirmed");
222
222
 
223
223
  // (f) THE DECISIVE ONE: squat a bare pair pre-cutoff, attach signatures after Shor.
224
224
  const attacker2 = generateMigrationIdentity();
@@ -251,7 +251,7 @@ console.log("\n★★★ ROUND 3 — what Bitcoin freezes is the PROOF OF CONTRO
251
251
  authorizeAndSign(req).detail.includes("never from the bundle you are judging"));
252
252
 
253
253
  // …and AUTHORISES when every condition, including the header, is satisfied.
254
- const good = authorizeAndSign({ ...req, blockMerkleRoots: HEADERS });
254
+ const good = authorizeAndSign({ ...req, blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true });
255
255
  check("★★★ POSITIVE PATH END TO END: authorised, with a header-confirmed frontier",
256
256
  good.authorized === true && good.evidence.quantumPropertyHolds === true &&
257
257
  good.evidence.temporalFrontier.headerConfirmed === true);
@@ -262,15 +262,15 @@ console.log("\n★★★ ROUND 3 — what Bitcoin freezes is the PROOF OF CONTRO
262
262
  for (const bad of [NaN, "abc", Infinity, -1, 1.5, "900000", true, [900000]]) {
263
263
  check(`★ cutoff ${JSON.stringify(bad) ?? String(bad)} is REFUSED, never coerced`,
264
264
  establishTemporalFrontier({ headBytes: headBytes, otsHex, cutoffBlockHeight: bad,
265
- blockMerkleRoots: HEADERS }).established === false);
265
+ blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true }).established === false);
266
266
  }
267
267
  check("★ and the policy engine refuses a malformed cutoff with its own reason",
268
- authorizeAndSign({ ...req, cutoffBlockHeight: NaN, blockMerkleRoots: HEADERS })
268
+ authorizeAndSign({ ...req, cutoffBlockHeight: NaN, blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true })
269
269
  .detail.includes("refused rather than coerced"));
270
270
 
271
271
  check("★ a forged .ots does NOT authorise, even with everything else correct",
272
272
  authorizeAndSign({ ...req, anchorEvidence: { ...req.anchorEvidence, otsHex: mkOts(123) },
273
- blockMerkleRoots: HEADERS }).authorized === false);
273
+ blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true }).authorized === false);
274
274
  }
275
275
 
276
276
  console.log("\nThe three irreducible limits, stated in the code itself:");
@@ -302,7 +302,7 @@ console.log("\n★ LIMIT #4 — pinning the log's IDENTITY does not pin its HIST
302
302
  const d3 = sha256(te.encode("spend-3"));
303
303
  const base3 = { cert: victimCert, spendDigest: d3, pqSignatureHex: authorizeSpend(victim, d3),
304
304
  anchorEvidence: { sth: head.sth, entries, otsHex: OTS }, expectedLogId: LOG_ID,
305
- policyKey: policyPriv3, cutoffBlockHeight: 900_000, blockMerkleRoots: HDRS };
305
+ policyKey: policyPriv3, cutoffBlockHeight: 900_000, blockMerkleRoots: HDRS, singlePolicyKeyPerHolder: true };
306
306
  check("without knownHeads the engine authorises — it cannot see the other history",
307
307
  authorizeAndSign(base3).authorized === true);
308
308
  check("★★ WITH a contradicting head supplied, the engine REFUSES (not warns)",
@@ -335,7 +335,7 @@ console.log("\n★★★ PRIMACÍA RELATIVA A LA EXPOSICIÓN — no depende de a
335
335
  {
336
336
  const { proveExposureRelativePrimacy, EXPOSURE_SCOPE } = await import("../src/primacy.mjs");
337
337
  const common = { subjectClassicalPub: victimCert.classicalPub, entries, sth: STH_A,
338
- expectedLogId: LOG_ID, otsHex: OTS, blockMerkleRoots: HDRS };
338
+ expectedLogId: LOG_ID, otsHex: OTS, blockMerkleRoots: HDRS, singlePolicyKeyPerHolder: true };
339
339
 
340
340
  // El ancla real está en el bloque 800000 (ver el montaje de arriba).
341
341
  const before = proveExposureRelativePrimacy({ ...common, exposureHeight: 850_000 });
@@ -360,7 +360,7 @@ console.log("\n★★★ PRIMACÍA RELATIVA A LA EXPOSICIÓN — no depende de a
360
360
  check("una altura de exposición malformada se rehúsa, no se coerciona",
361
361
  proveExposureRelativePrimacy({ ...common, exposureHeight: NaN }).exposureRelative === false);
362
362
  check("sin ancla confirmada por cabecera no se hace ninguna afirmación fuerte",
363
- proveExposureRelativePrimacy({ ...common, blockMerkleRoots: undefined, exposureHeight: 850_000 })
363
+ proveExposureRelativePrimacy({ ...common, blockMerkleRoots: undefined, singlePolicyKeyPerHolder: true, exposureHeight: 850_000 })
364
364
  .exposureRelative === false);
365
365
 
366
366
  check("HONESTIDAD: declara a quién NO puede salvar (claves ya expuestas)",
@@ -375,7 +375,7 @@ console.log("\n★★★ M2 CABLEADO — el gate de exposición en la ruta de fi
375
375
  const d4 = sha256(te.encode("spend-m2"));
376
376
  const req4 = { cert: victimCert, spendDigest: d4, pqSignatureHex: authorizeSpend(victim, d4),
377
377
  anchorEvidence: { sth: head.sth, entries, otsHex: OTS }, expectedLogId: LOG_ID,
378
- policyKey: policyPriv4, cutoffBlockHeight: 900_000, blockMerkleRoots: HDRS };
378
+ policyKey: policyPriv4, cutoffBlockHeight: 900_000, blockMerkleRoots: HDRS, singlePolicyKeyPerHolder: true };
379
379
 
380
380
  // El ancla está en el bloque 800000 (montaje de arriba).
381
381
  const ok = authorizeAndSign({ ...req4, exposureHeight: 850_000 });