fractal-pqc 0.4.0 → 0.5.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 — **281 checks pass**:
34
+ Everything below is exercised by `npm test` with real keys — **293 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: 281 real checks, no mocks
427
+ fractal-pqc selftest Run everything: 293 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.4.0",
3
+ "version": "0.5.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",
@@ -178,6 +178,41 @@ export const CLAIMS = [
178
178
  },
179
179
  note: "R5: the most consequential guard in the module, and nothing named it.",
180
180
  },
181
+ {
182
+ id: "R6-exposure-relative-primacy",
183
+ module: "primacy.mjs",
184
+ statement: "A commitment anchored in Bitcoin strictly BEFORE the block that first " +
185
+ "exposed the holder's public key needs no Q-day estimate: at that moment " +
186
+ "there was no public key to break. Anchored after exposure, the strong " +
187
+ "claim is refused rather than softened.",
188
+ proof: () => {
189
+ const r = P.proveExposureRelativePrimacy({
190
+ subjectClassicalPub: cert.classicalPub, entries: ENTRIES, sth: STH,
191
+ expectedLogId: STH.logId, otsHex: OTS_HEX, blockMerkleRoots: HEADERS,
192
+ exposureHeight: ANCHOR_HEIGHT + 50_000,
193
+ });
194
+ return r.exposureRelative === true && r.strength === "anchored-before-exposure" &&
195
+ r.blocksBefore === 50_000;
196
+ },
197
+ attack: () => {
198
+ const base = { subjectClassicalPub: cert.classicalPub, entries: ENTRIES, sth: STH,
199
+ expectedLogId: STH.logId, otsHex: OTS_HEX, blockMerkleRoots: HEADERS };
200
+ // Exposed BEFORE the anchor: the strong claim must be refused, and it must say the
201
+ // coins should be moved rather than quietly degrading to the guessed cutoff.
202
+ const late = P.proveExposureRelativePrimacy({ ...base, exposureHeight: ANCHOR_HEIGHT - 1 });
203
+ if (late.exposureRelative !== false || late.reason !== "anchored-after-exposure") return false;
204
+ // Exposed at EXACTLY the anchor height is not "before" — the boundary must be strict.
205
+ const same = P.proveExposureRelativePrimacy({ ...base, exposureHeight: ANCHOR_HEIGHT });
206
+ if (same.exposureRelative !== false) return false;
207
+ // A malformed height must refuse, never coerce.
208
+ if (P.proveExposureRelativePrimacy({ ...base, exposureHeight: NaN }).exposureRelative !== false) return false;
209
+ // And with no header-confirmed anchor there is no height to compare at all.
210
+ return P.proveExposureRelativePrimacy({ ...base, blockMerkleRoots: undefined,
211
+ exposureHeight: ANCHOR_HEIGHT + 50_000 }).exposureRelative === false;
212
+ },
213
+ note: "The founder's insight: what is at risk is the PAST. A global Q-day is a guess; " +
214
+ "a key's exposure block is a fact in Bitcoin.",
215
+ },
181
216
  {
182
217
  id: "R4-cutoff-never-coerced",
183
218
  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.4.0";
59
+ export const VERSION = "0.5.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
@@ -101,6 +101,14 @@ export const MUTATIONS = [
101
101
  to: ` "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"`,
102
102
  mustKill: ["R3-nums-required-for-enforcement"],
103
103
  },
104
+ {
105
+ id: "M-R6-exposure-boundary-not-strict",
106
+ describes: "R6: anchoring AT or AFTER exposure is accepted as if it were before",
107
+ file: "src/primacy.mjs",
108
+ from: ` if (anchoredAt >= exposureHeight) {`,
109
+ to: ` if (false) {`,
110
+ mustKill: ["R6-exposure-relative-primacy"],
111
+ },
104
112
  {
105
113
  id: "M-R4-cutoff-unvalidated",
106
114
  describes: "R4: a malformed cutoff is coerced again — `height > NaN` passes silently",
package/src/primacy.mjs CHANGED
@@ -500,3 +500,133 @@ export const PRIMACY_SCOPE = Object.freeze({
500
500
  "a subject-indexed side tree with non-membership proofs, committed in the head, would " +
501
501
  "make the enumeration O(log n). It is not implemented and is not claimed.",
502
502
  });
503
+
504
+
505
+ /* ── EXPOSURE-RELATIVE PRIMACY ──────────────────────────────────────────── */
506
+
507
+ /**
508
+ * Prove a commitment was anchored BEFORE the holder's public key was ever exposed.
509
+ *
510
+ * WHY THIS IS STRICTLY STRONGER THAN A Q-DAY CUTOFF
511
+ * -------------------------------------------------
512
+ * The cutoff frontier has an admitted weakness, and it is limit #2 in our README: the
513
+ * guarantee is "anchored before the cryptography broke", and NOBODY KNOWS WHEN IT BROKE.
514
+ * Every verifier is guessing a date, and a private break earlier than the guess produces
515
+ * forged entries indistinguishable from real ones.
516
+ *
517
+ * This removes the guess for a large and important class of holders.
518
+ *
519
+ * A secp256k1 public key has an exact, immutable, publicly verifiable moment at which it
520
+ * became known to the world: the block in which it first appeared on chain — the first
521
+ * spend from that address. Before that block the key existed only in its owner's wallet.
522
+ *
523
+ * So: if a commitment's tree head was anchored in Bitcoin STRICTLY BEFORE the block that
524
+ * first exposed the public key, then at the moment of anchoring NOBODY — with or without a
525
+ * quantum computer — could have derived the private key, because the PUBLIC key was not
526
+ * public yet. There was nothing to break.
527
+ *
528
+ * That is a per-key, chain-verifiable fact. It does not depend on anyone's estimate of
529
+ * Q-day, and it does not weaken if that estimate turns out to be wrong.
530
+ *
531
+ * WHO THIS ACTUALLY SAVES — AND WHO IT CANNOT
532
+ * -------------------------------------------
533
+ * In an UNSPENT P2PKH or P2WPKH output the chain holds only the HASH of the public key.
534
+ * The key itself is still private. Those holders can anchor today and obtain this stronger
535
+ * proof. That is the set of coins still worth protecting, and it is large.
536
+ *
537
+ * For a key already exposed by a previous spend, this gives NOTHING retroactively. You
538
+ * cannot anchor into the past. Those coins need to be moved, and no evidence scheme
539
+ * changes that. We say so rather than implying coverage we do not have.
540
+ *
541
+ * WHERE THE EXPOSURE HEIGHT COMES FROM — NOT FROM US
542
+ * --------------------------------------------------
543
+ * `exposureHeight` is supplied by the VERIFIER, derived from their own node or index. We
544
+ * deliberately do not ship an oracle for it: an exposure height we asserted would be one
545
+ * more thing to trust us about, and the entire point is that this fact is independently
546
+ * checkable in Bitcoin itself. Pass `null` to assert the key has never been exposed —
547
+ * which the verifier must also confirm themselves.
548
+ */
549
+ export function proveExposureRelativePrimacy({
550
+ subjectClassicalPub, entries, sth, expectedLogId,
551
+ exposureHeight, otsHex, blockMerkleRoots,
552
+ }) {
553
+ // Establish the anchor first, WITHOUT letting the exposure height act as the cutoff.
554
+ // Passing the exposure as `cutoffBlockHeight` would make the base check fire first and
555
+ // report a generic "anchored-after-cutoff", losing the specific — and far more useful —
556
+ // distinction between "you anchored too late" and "you never anchored at all". The
557
+ // permissive cutoff here only obtains a header-CONFIRMED height; the real comparison is
558
+ // the one below, and it is the whole point of this function.
559
+ const base = proveFirstSeen({
560
+ subjectClassicalPub, entries, sth, expectedLogId,
561
+ cutoffBlockHeight: Number.MAX_SAFE_INTEGER,
562
+ otsHex, blockMerkleRoots,
563
+ });
564
+ if (!base.valid) return { ...base, exposureRelative: false };
565
+
566
+ if (!base.quantumPropertyHolds) {
567
+ return {
568
+ ...base, exposureRelative: false,
569
+ reason: "no-confirmed-anchor",
570
+ note: "Without a header-confirmed Bitcoin anchor there is no height to compare " +
571
+ "against the exposure, so the stronger claim cannot be made.",
572
+ };
573
+ }
574
+
575
+ const anchoredAt = base.temporalFrontier.height;
576
+
577
+ if (exposureHeight == null) {
578
+ return {
579
+ ...base, exposureRelative: true, anchoredAtHeight: anchoredAt, exposureHeight: null,
580
+ strength: "never-exposed",
581
+ note: `Anchored at block ${anchoredAt}, and you assert this key has NEVER appeared ` +
582
+ `on chain. If that holds, the key was never derivable by anyone at any point, ` +
583
+ `so no Q-day estimate is involved. Confirm the non-exposure yourself: this is ` +
584
+ `the one fact we cannot check for you.`,
585
+ };
586
+ }
587
+
588
+ if (!Number.isSafeInteger(exposureHeight) || exposureHeight < 0) {
589
+ return { ...base, exposureRelative: false, reason: "malformed-exposure-height" };
590
+ }
591
+
592
+ if (anchoredAt >= exposureHeight) {
593
+ return {
594
+ ...base, exposureRelative: false, anchoredAtHeight: anchoredAt, exposureHeight,
595
+ reason: "anchored-after-exposure",
596
+ strength: "cutoff-only",
597
+ note: `Anchored at block ${anchoredAt}, but the key was already public at block ` +
598
+ `${exposureHeight}. From that block on, anyone able to break secp256k1 could ` +
599
+ `have produced this commitment too. The proof falls back to whatever your ` +
600
+ `Q-day cutoff is worth — and that is a guess. These coins should be moved.`,
601
+ };
602
+ }
603
+
604
+ return {
605
+ ...base, exposureRelative: true, anchoredAtHeight: anchoredAt, exposureHeight,
606
+ strength: "anchored-before-exposure",
607
+ blocksBefore: exposureHeight - anchoredAt,
608
+ note: `Anchored at block ${anchoredAt}, ${exposureHeight - anchoredAt} blocks BEFORE ` +
609
+ `the key was first exposed at ${exposureHeight}. At the moment of anchoring the ` +
610
+ `public key was not public, so no adversary — quantum or otherwise — had anything ` +
611
+ `to break. This claim does not depend on any Q-day estimate.`,
612
+ };
613
+ }
614
+
615
+ export const EXPOSURE_SCOPE = Object.freeze({
616
+ proves:
617
+ "that a commitment was anchored in Bitcoin strictly before the block that first made " +
618
+ "the holder's public key public — a per-key fact, checkable in Bitcoin, that needs no " +
619
+ "Q-day estimate",
620
+ whyStronger:
621
+ "the cutoff frontier asks the verifier to guess when the cryptography broke. This asks " +
622
+ "nothing: before exposure there was no public key to break.",
623
+ savesWho:
624
+ "holders of UNSPENT P2PKH/P2WPKH outputs, where the chain holds only the key's hash. " +
625
+ "They can still anchor before exposing anything.",
626
+ cannotSave:
627
+ "keys already exposed by an earlier spend. You cannot anchor into the past, and no " +
628
+ "evidence scheme changes that — those coins must be moved.",
629
+ exposureHeightIsNotOurs:
630
+ "the verifier supplies it from their own node. An exposure height we asserted would be " +
631
+ "one more thing to trust us about, and the point is that it is independently checkable.",
632
+ });
package/test/claims.mjs CHANGED
@@ -26,7 +26,7 @@ check("a proof that throws does not pass silently",
26
26
  check("HONESTY: the method states it does NOT prove completeness",
27
27
  CLAIMS_METHOD.whatItDoesNotProve.includes("completeness"));
28
28
  check("every claim names the siege round that taught us it needed guarding",
29
- CLAIMS.every((c) => /^R[1-5]-/.test(c.id)));
29
+ CLAIMS.every((c) => /^R[1-6]-/.test(c.id)));
30
30
 
31
31
  /* ── the third admissibility condition ─────────────────────────────────── */
32
32
  import { MUTATIONS, MUTATION_METHOD } from "../src/mutations.mjs";
@@ -44,7 +44,7 @@ console.log("\nMutation coverage (cheap structural check; run `npm run mutate` f
44
44
  check("every mutation names a real claim",
45
45
  MUTATIONS.every((m) => m.mustKill.every((id) => CLAIMS.some((c) => c.id === id))));
46
46
  check("every mutation reintroduces a bug from a named siege round",
47
- MUTATIONS.every((m) => /^M-R[1-5]-/.test(m.id) && typeof m.describes === "string"));
47
+ MUTATIONS.every((m) => /^M-R[1-6]-/.test(m.id) && typeof m.describes === "string"));
48
48
  check("HONESTY: the method states a surviving mutation can mean defence in depth, not a defect",
49
49
  MUTATION_METHOD.whatItDoesNotProve.includes("defence in depth"));
50
50
  check("HONESTY: and that it does not prove completeness either",
package/test/primacy.mjs CHANGED
@@ -25,7 +25,7 @@ function check(name, cond) {
25
25
  }
26
26
  const te = new TextEncoder();
27
27
  const CUTOFF = 900_000;
28
- let OTS, HB, HDRS; // the verifier's declared Q-day boundary
28
+ let OTS, HB, HDRS, STH_A; // the verifier's declared Q-day boundary
29
29
 
30
30
  console.log("PRIMACY — 'first' only counts if it means 'before the cryptography broke'\n");
31
31
 
@@ -179,7 +179,7 @@ console.log("\n★★★ ROUND 3 — what Bitcoin freezes is the PROOF OF CONTRO
179
179
  majorVersion: O.OTS_MAJOR_VERSION, fileHashOp: "sha256", fileDigest: digest, timestamp: ts,
180
180
  }));
181
181
  };
182
- const otsHex = mkOts(800_000); OTS = otsHex; HB = headBytes;
182
+ const otsHex = mkOts(800_000); OTS = otsHex; HB = headBytes; STH_A = head.sth;
183
183
  const attRoot = (hex) => {
184
184
  const ev = O.evaluateOts(O.decodeOtsFile(T.fromHex(hex)));
185
185
  const a = ev.blockAttestations.find((x) => x.chain === "bitcoin");
@@ -331,5 +331,43 @@ console.log("\nRound-4 structural fixes:");
331
331
  })());
332
332
  }
333
333
 
334
+ console.log("\n★★★ PRIMACÍA RELATIVA A LA EXPOSICIÓN — no depende de adivinar el Día-Q:");
335
+ {
336
+ const { proveExposureRelativePrimacy, EXPOSURE_SCOPE } = await import("../src/primacy.mjs");
337
+ const common = { subjectClassicalPub: victimCert.classicalPub, entries, sth: STH_A,
338
+ expectedLogId: LOG_ID, otsHex: OTS, blockMerkleRoots: HDRS };
339
+
340
+ // El ancla real está en el bloque 800000 (ver el montaje de arriba).
341
+ const before = proveExposureRelativePrimacy({ ...common, exposureHeight: 850_000 });
342
+ check("★ anclado ANTES de la exposición → la afirmación fuerte se sostiene",
343
+ before.exposureRelative === true && before.strength === "anchored-before-exposure");
344
+ check("...y dice cuántos bloques de margen hay", before.blocksBefore === 50_000);
345
+ check("...y declara que NO depende de ninguna estimación de Día-Q",
346
+ before.note.includes("does not depend on any Q-day estimate"));
347
+
348
+ const after = proveExposureRelativePrimacy({ ...common, exposureHeight: 700_000 });
349
+ check("★ anclado DESPUÉS de la exposición → NO se hace la afirmación fuerte",
350
+ after.exposureRelative === false && after.reason === "anchored-after-exposure");
351
+ check("...y dice sin rodeos que esas monedas hay que moverlas",
352
+ after.note.includes("should be moved"));
353
+
354
+ const never = proveExposureRelativePrimacy({ ...common, exposureHeight: null });
355
+ check("clave nunca expuesta → la afirmación más fuerte posible",
356
+ never.exposureRelative === true && never.strength === "never-exposed");
357
+ check("HONESTIDAD: pide al verificador confirmar la no-exposición él mismo",
358
+ never.note.includes("we cannot check for you"));
359
+
360
+ check("una altura de exposición malformada se rehúsa, no se coerciona",
361
+ proveExposureRelativePrimacy({ ...common, exposureHeight: NaN }).exposureRelative === false);
362
+ check("sin ancla confirmada por cabecera no se hace ninguna afirmación fuerte",
363
+ proveExposureRelativePrimacy({ ...common, blockMerkleRoots: undefined, exposureHeight: 850_000 })
364
+ .exposureRelative === false);
365
+
366
+ check("HONESTIDAD: declara a quién NO puede salvar (claves ya expuestas)",
367
+ EXPOSURE_SCOPE.cannotSave.includes("must be moved"));
368
+ check("HONESTIDAD: la altura de exposición NO la ponemos nosotros",
369
+ EXPOSURE_SCOPE.exposureHeightIsNotOurs.includes("independently checkable"));
370
+ }
371
+
334
372
  console.log(`\n${pass} passed, ${fail} failed`);
335
373
  process.exit(fail === 0 ? 0 : 1);