fractal-pqc 0.5.1 → 0.6.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
@@ -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
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.6.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",
@@ -224,6 +224,30 @@ export const CLAIMS = [
224
224
  note: "The founder's insight: what is at risk is the PAST. A global Q-day is a guess; " +
225
225
  "a key's exposure block is a fact in Bitcoin.",
226
226
  },
227
+ {
228
+ id: "R6-exposure-gate-enforced",
229
+ module: "policy.mjs",
230
+ statement: "If a caller supplies an exposure height, the engine ENFORCES it: a commitment " +
231
+ "anchored after the chain revealed the key is REFUSED and no signature is " +
232
+ "released. The parameter cannot be passed and silently ignored.",
233
+ proof: () => {
234
+ const r = authorizeAndSign({ ...goodReq(), exposureHeight: ANCHOR_HEIGHT + 50_000 });
235
+ return r.authorized === true && r.evidence.exposureRelative === true;
236
+ },
237
+ attack: () => {
238
+ // Anchored AFTER exposure: must refuse, and must release nothing.
239
+ const late = authorizeAndSign({ ...goodReq(), exposureHeight: ANCHOR_HEIGHT - 1 });
240
+ if (late.authorized !== false || late.policySignatureHex !== null) return false;
241
+ // A malformed height must not degrade to "ignored".
242
+ if (authorizeAndSign({ ...goodReq(), exposureHeight: NaN }).authorized !== false) return false;
243
+ // Present-but-undefined must not be treated as absent.
244
+ if (authorizeAndSign({ ...goodReq(), exposureHeight: undefined }).authorized !== false) return false;
245
+ // And omitting it entirely must still work — the gate is opt-in, not a break.
246
+ return authorizeAndSign(goodReq()).authorized === true;
247
+ },
248
+ note: "R6 left this as an explicit choice: wire it, or say it is not a gate. Doing " +
249
+ "neither would ship a stronger proof that nothing consumes.",
250
+ },
227
251
  {
228
252
  id: "R4-cutoff-never-coerced",
229
253
  module: "primacy.mjs",
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",
package/src/policy.mjs CHANGED
@@ -47,7 +47,7 @@
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
52
 
53
53
  const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
@@ -153,6 +153,36 @@ export function authorizeAndSign(req) {
153
153
  }
154
154
  }
155
155
 
156
+ // ── M2: EXPOSURE-RELATIVE PRIMACY, WIRED AS A GATE ─────────────────────
157
+ // Round 6 of our own siege left this as an explicit decision: either wire it into the
158
+ // signing path, or say in writing that it is a proof primitive and not a gate. Doing
159
+ // neither — shipping a stronger proof that nothing consumes — is how a package ends up
160
+ // claiming more than it enforces. This is the wiring.
161
+ //
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.
165
+ if (req && Object.hasOwn(req, "exposureHeight")) {
166
+ 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,
171
+ });
172
+ if (!exp.exposureRelative) {
173
+ return refuse(REFUSED.ANCHOR_UNRESOLVED,
174
+ exp.reason === "anchored-after-exposure"
175
+ ? `The commitment was anchored at block ${exp.anchoredAtHeight}, but the chain had ` +
176
+ `already revealed this public key at block ${exp.exposureHeight}. From that block ` +
177
+ `on, anyone able to break secp256k1 could have produced this commitment too — so ` +
178
+ `it no longer distinguishes the holder from an attacker. These coins should be moved.`
179
+ : `Exposure-relative primacy could not be established (${exp.reason}).`,
180
+ { exposure: exp });
181
+ }
182
+ // Carry the stronger evidence forward so the caller can see WHY it was authorised.
183
+ req = { ...req, _exposureProof: exp };
184
+ }
185
+
156
186
  // PRIMACY, not membership. The anchor is the EARLIEST binding of this certificate's
157
187
  // own classical key in the pinned log — never a value lifted off the certificate.
158
188
  // The temporal frontier is REQUIRED here. After Q-day the attacker's commitment is as
@@ -198,6 +228,15 @@ export function authorizeAndSign(req) {
198
228
  ignoredUnsignedEntries: anchor.ignoredUnsignedEntries,
199
229
  temporalFrontier: anchor.temporalFrontier,
200
230
  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",
239
+ } : {}),
201
240
  digestSigned: toHex(digest),
202
241
  logId: anchor.logId,
203
242
  logPinned: true,
@@ -271,6 +310,12 @@ export const POLICY_SCOPE = Object.freeze({
271
310
  "detectEquivocation) and not preventable by this module. Pass `knownHeads` from a " +
272
311
  "witness, mirror or gossip channel and the engine refuses on any contradiction; without " +
273
312
  "them you are trusting the log to have shown you its only history.",
313
+ 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.",
274
319
  precondition:
275
320
  "the true holder must have registered a dual-signed commitment BEFORE the cutoff. If " +
276
321
  "they never did, no one is authorised — not them, not an attacker. Those coins are " +
package/test/primacy.mjs CHANGED
@@ -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 };
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);