fractal-pqc 0.5.1 → 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
@@ -31,7 +31,7 @@ without invalidating already-signed history. This kit is a concrete, honest firs
31
31
  > dual-signed commitment before the verifier's cutoff height cannot be rescued by any of
32
32
  > this. `test/primacy.mjs` asserts that out loud rather than leaving it to be discovered.
33
33
 
34
- Everything below is exercised by `npm test` with real keys — **293 checks pass**:
34
+ Everything below is exercised by `npm test` with real keys — **304 checks pass**:
35
35
 
36
36
  - **secp256k1** commitment + spend authorization (`@noble/curves`).
37
37
  - **Taproot BIP-340 Schnorr** sign/verify, asserted against the **official BIP-340 test
@@ -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/bin/cli.mjs CHANGED
@@ -424,7 +424,7 @@ Usage:
424
424
  code it names is broken. A claim no mutation can
425
425
  kill is vacuous, and is reported as such.
426
426
  fractal-pqc verify-vector Check the official BIP-340 test vector
427
- fractal-pqc selftest Run everything: 293 real checks, no mocks
427
+ fractal-pqc selftest Run everything: 304 real checks, no mocks
428
428
 
429
429
  Docs: integrations/pqc-migration-kit/README.md`);
430
430
  process.exit(cmd ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fractal-pqc",
3
- "version": "0.5.1",
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,102 @@ 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
+ },
304
+ {
305
+ id: "R6-exposure-gate-enforced",
306
+ module: "policy.mjs",
307
+ statement: "If a caller supplies an exposure height, the engine ENFORCES it: a commitment " +
308
+ "anchored after the chain revealed the key is REFUSED and no signature is " +
309
+ "released. The parameter cannot be passed and silently ignored.",
310
+ proof: () => {
311
+ const r = authorizeAndSign({ ...goodReq(), exposureHeight: ANCHOR_HEIGHT + 50_000 });
312
+ return r.authorized === true && r.evidence.exposureRelative === true;
313
+ },
314
+ attack: () => {
315
+ // Anchored AFTER exposure: must refuse, and must release nothing.
316
+ const late = authorizeAndSign({ ...goodReq(), exposureHeight: ANCHOR_HEIGHT - 1 });
317
+ if (late.authorized !== false || late.policySignatureHex !== null) return false;
318
+ // A malformed height must not degrade to "ignored".
319
+ if (authorizeAndSign({ ...goodReq(), exposureHeight: NaN }).authorized !== false) return false;
320
+ // Present-but-undefined must not be treated as absent.
321
+ if (authorizeAndSign({ ...goodReq(), exposureHeight: undefined }).authorized !== false) return false;
322
+ // And omitting it entirely must still work — the gate is opt-in, not a break.
323
+ return authorizeAndSign(goodReq()).authorized === true;
324
+ },
325
+ note: "R6 left this as an explicit choice: wire it, or say it is not a gate. Doing " +
326
+ "neither would ship a stronger proof that nothing consumes.",
327
+ },
227
328
  {
228
329
  id: "R4-cutoff-never-coerced",
229
330
  module: "primacy.mjs",
@@ -477,9 +578,18 @@ export const UNCLAIMED_GUARDS = Object.freeze([
477
578
  "blocks) — again stronger than a claim, again not in the ledger" },
478
579
  { where: "policy.mjs", guard: "the ORDER of the refusal guards",
479
580
  why: "each guard is claimed individually; nothing claims that reordering them is safe" },
480
- { where: "primacy.mjs", guard: "exposure-relative primacy is not an authorisation gate",
481
- why: "policy.mjs does not consume it. It is a verifiable proof primitive; nothing in the " +
482
- "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" },
483
593
  { where: "primacy.mjs", guard: "'never exposed' is the CALLER's assertion",
484
594
  why: "the kit cannot verify non-exposure. It can only refuse when the field is absent, " +
485
595
  "which it now does. Off-chain publication of a key is outside what we measure" },
package/src/index.mjs CHANGED
@@ -56,7 +56,7 @@ export {
56
56
  parsePsbt, signPsbtTaprootKeyPath, finalizePsbt,
57
57
  } from "./psbt.mjs";
58
58
 
59
- export const VERSION = "0.5.1";
59
+ export const VERSION = "0.6.0";
60
60
 
61
61
  // The security layer, actually exported. Round 2 of our own siege found that primacy,
62
62
  // policy and transparency existed in the repo and were unreachable from the published
package/src/mutations.mjs CHANGED
@@ -129,6 +129,14 @@ export const MUTATIONS = [
129
129
  reason: "anchored-after-exposure",`,
130
130
  mustKill: ["R6-exposure-relative-primacy"],
131
131
  },
132
+ {
133
+ id: "M-R6-exposure-gate-ignored",
134
+ describes: "R6: the engine accepts an exposure height and ignores it",
135
+ file: "src/policy.mjs",
136
+ from: ` if (req && Object.hasOwn(req, "exposureHeight")) {`,
137
+ to: ` if (false) {`,
138
+ mustKill: ["R6-exposure-gate-enforced"],
139
+ },
132
140
  {
133
141
  id: "M-R4-cutoff-unvalidated",
134
142
  describes: "R4: a malformed cutoff is coerced again — `height > NaN` passes silently",
@@ -230,6 +238,33 @@ export const MUTATIONS = [
230
238
  to: ` return { equivocation: false, reason: "heads-agree" };`,
231
239
  mustKill: ["R1-same-size-is-non-repudiable"],
232
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
+ },
233
268
  ];
234
269
 
235
270
  /**
package/src/policy.mjs CHANGED
@@ -47,10 +47,48 @@
47
47
 
48
48
  import { schnorr } from "@noble/curves/secp256k1.js";
49
49
  import { verifySpend } from "./migration-envelope.mjs";
50
- import { certIsFirstSeen } from "./primacy.mjs";
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, " +
@@ -153,6 +232,45 @@ export function authorizeAndSign(req) {
153
232
  }
154
233
  }
155
234
 
235
+ // ── M2: EXPOSURE-RELATIVE PRIMACY, WIRED AS A GATE ─────────────────────
236
+ // Round 6 of our own siege left this as an explicit decision: either wire it into the
237
+ // signing path, or say in writing that it is a proof primitive and not a gate. Doing
238
+ // neither — shipping a stronger proof that nothing consumes — is how a package ends up
239
+ // claiming more than it enforces. This is the wiring.
240
+ //
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;
251
+ if (req && Object.hasOwn(req, "exposureHeight")) {
252
+ const exposureHeight = req.exposureHeight; // ONE read; the gate and the evidence share it
253
+ const exp = proveExposureRelativePrimacy({
254
+ subjectClassicalPub: subjectPub,
255
+ entries: ev.entries, sth: ev.sth, expectedLogId,
256
+ exposureHeight,
257
+ otsHex: ev.otsHex, blockMerkleRoots: req.blockMerkleRoots,
258
+ });
259
+ if (!exp.exposureRelative) {
260
+ return refuse(REFUSED.ANCHOR_UNRESOLVED,
261
+ exp.reason === "anchored-after-exposure"
262
+ ? `The commitment was anchored at block ${exp.anchoredAtHeight}, but the chain had ` +
263
+ `already revealed this public key at block ${exp.exposureHeight}. From that block ` +
264
+ `on, anyone able to break secp256k1 could have produced this commitment too — so ` +
265
+ `it no longer distinguishes the holder from an attacker. These coins should be moved.`
266
+ : `Exposure-relative primacy could not be established (${exp.reason}).`,
267
+ { exposure: exp });
268
+ }
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;
272
+ }
273
+
156
274
  // PRIMACY, not membership. The anchor is the EARLIEST binding of this certificate's
157
275
  // own classical key in the pinned log — never a value lifted off the certificate.
158
276
  // The temporal frontier is REQUIRED here. After Q-day the attacker's commitment is as
@@ -160,9 +278,9 @@ export function authorizeAndSign(req) {
160
278
  // below the cutoff separates them. `requireTemporalFrontier` defaults to true and this
161
279
  // engine never turns it off.
162
280
  const anchor = certIsFirstSeen({
163
- cert, entries: anchorEvidence.entries, sth: anchorEvidence.sth, expectedLogId,
281
+ cert, entries: ev.entries, sth: ev.sth, expectedLogId,
164
282
  cutoffBlockHeight: req.cutoffBlockHeight,
165
- otsHex: anchorEvidence.otsHex,
283
+ otsHex: ev.otsHex,
166
284
  blockMerkleRoots: req.blockMerkleRoots,
167
285
  requireTemporalFrontier: true,
168
286
  });
@@ -176,6 +294,67 @@ export function authorizeAndSign(req) {
176
294
  { anchor });
177
295
  }
178
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
+
179
358
  const spend = verifySpend(cert, digest, pqSignatureHex, {
180
359
  anchoredFactHash: anchor.anchoredFactHash,
181
360
  });
@@ -198,6 +377,38 @@ export function authorizeAndSign(req) {
198
377
  ignoredUnsignedEntries: anchor.ignoredUnsignedEntries,
199
378
  temporalFrontier: anchor.temporalFrontier,
200
379
  quantumPropertyHolds: anchor.quantumPropertyHolds,
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.`,
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
+ }),
201
412
  digestSigned: toHex(digest),
202
413
  logId: anchor.logId,
203
414
  logPinned: true,
@@ -262,15 +473,36 @@ export const POLICY_SCOPE = Object.freeze({
262
473
  "the policy key and nothing else, so a quantum adversary who reaches that key spends " +
263
474
  "anyway. What this does is CONCENTRATE risk — N exposed classical keys become one " +
264
475
  "hash-hidden, rotatable, custodian-controlled key — and produce a post-quantum-signed, " +
265
- "verifiable record of who SHOULD have been allowed to spend, giving the custodian a " +
266
- "cryptographic reason to refuse. That is a real reduction in surface and a governance " +
267
- "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.",
268
490
  limitFour_historyNotPinned:
269
491
  "pinning the log IDENTITY does not pin its HISTORY. Two same-size heads under the same " +
270
492
  "logId both satisfy every check here. Split view is DETECTABLE (transparency." +
271
493
  "detectEquivocation) and not preventable by this module. Pass `knownHeads` from a " +
272
494
  "witness, mirror or gossip channel and the engine refuses on any contradiction; without " +
273
495
  "them you are trusting the log to have shown you its only history.",
496
+ exposureRelativeGate:
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.",
274
506
  precondition:
275
507
  "the true holder must have registered a dual-signed commitment BEFORE the cutoff. If " +
276
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)",
@@ -369,5 +369,41 @@ console.log("\n★★★ PRIMACÍA RELATIVA A LA EXPOSICIÓN — no depende de a
369
369
  EXPOSURE_SCOPE.exposureHeightIsNotOurs.includes("independently checkable"));
370
370
  }
371
371
 
372
+ console.log("\n★★★ M2 CABLEADO — el gate de exposición en la ruta de firma:");
373
+ {
374
+ const policyPriv4 = schnorr.utils.randomSecretKey();
375
+ const d4 = sha256(te.encode("spend-m2"));
376
+ const req4 = { cert: victimCert, spendDigest: d4, pqSignatureHex: authorizeSpend(victim, d4),
377
+ anchorEvidence: { sth: head.sth, entries, otsHex: OTS }, expectedLogId: LOG_ID,
378
+ policyKey: policyPriv4, cutoffBlockHeight: 900_000, blockMerkleRoots: HDRS, singlePolicyKeyPerHolder: true };
379
+
380
+ // El ancla está en el bloque 800000 (montaje de arriba).
381
+ const ok = authorizeAndSign({ ...req4, exposureHeight: 850_000 });
382
+ check("★★ AUTORIZA cuando el compromiso es anterior a la exposición",
383
+ ok.authorized === true && ok.evidence.exposureRelative === true);
384
+ check("...y la evidencia dice POR QUÉ es más fuerte",
385
+ ok.evidence.whyThisIsStronger.includes("no Q-day estimate"));
386
+ check("...y libera una firma Schnorr verificable",
387
+ schnorr.verify(T.fromHex(ok.policySignatureHex), d4, schnorr.getPublicKey(policyPriv4)) === true);
388
+
389
+ const late = authorizeAndSign({ ...req4, exposureHeight: 700_000 });
390
+ check("★★ REHÚSA cuando la clave ya estaba expuesta al anclar",
391
+ late.authorized === false && late.policySignatureHex === null);
392
+ check("...y le dice al custodio que esas monedas hay que moverlas",
393
+ late.detail.includes("should be moved"));
394
+
395
+ check("★ el parámetro NO se puede pasar y que lo ignoren (fail-closed)",
396
+ authorizeAndSign({ ...req4, exposureHeight: NaN }).authorized === false);
397
+ check("★ un campo presente pero undefined tampoco se ignora",
398
+ authorizeAndSign({ ...req4, exposureHeight: undefined }).authorized === false);
399
+ check("omitirlo por completo → cae al cutoff, que es una estimación y lo dice",
400
+ authorizeAndSign(req4).authorized === true &&
401
+ authorizeAndSign(req4).evidence.exposureRelative === undefined);
402
+ check("nunca-expuesta (null explícito) autoriza con la fuerza máxima",
403
+ authorizeAndSign({ ...req4, exposureHeight: null }).authorized === true);
404
+ check("HONESTIDAD: el scope declara que si se suministra, se IMPONE",
405
+ POLICY_SCOPE.exposureRelativeGate.includes("ENFORCED, never advisory"));
406
+ }
407
+
372
408
  console.log(`\n${pass} passed, ${fail} failed`);
373
409
  process.exit(fail === 0 ? 0 : 1);