fractal-pqc 0.5.0 → 0.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fractal-pqc",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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",
@@ -181,10 +181,11 @@ export const CLAIMS = [
181
181
  {
182
182
  id: "R6-exposure-relative-primacy",
183
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.",
184
+ statement: "A commitment anchored in Bitcoin strictly BEFORE the block in which the " +
185
+ "CHAIN first revealed the holder's public key needs no Q-day estimate: at " +
186
+ "that moment breaking secp256k1 was not sufficient to produce it. Anchored " +
187
+ "after exposure — or with the exposure height merely ABSENT — the strong " +
188
+ "claim is refused, and the refusal says so in `valid`, not only in a new field.",
188
189
  proof: () => {
189
190
  const r = P.proveExposureRelativePrimacy({
190
191
  subjectClassicalPub: cert.classicalPub, entries: ENTRIES, sth: STH,
@@ -201,6 +202,16 @@ export const CLAIMS = [
201
202
  // coins should be moved rather than quietly degrading to the guessed cutoff.
202
203
  const late = P.proveExposureRelativePrimacy({ ...base, exposureHeight: ANCHOR_HEIGHT - 1 });
203
204
  if (late.exposureRelative !== false || late.reason !== "anchored-after-exposure") return false;
205
+ // ROUND 6: the refusal must be visible to a consumer that checks `.valid` — which is
206
+ // what every other gate in this package does. It used to come back green.
207
+ if (late.valid !== false || late.quantumPropertyHolds !== false) return false;
208
+ // ROUND 6: an ABSENT field is nobody's assertion. It used to yield "never-exposed",
209
+ // the strongest claim the module makes.
210
+ const omitted = P.proveExposureRelativePrimacy(base);
211
+ if (omitted.exposureRelative !== false || omitted.reason !== "exposure-height-not-supplied") return false;
212
+ // ROUND 6: the internal sentinel must never be accepted as a real exposure height.
213
+ if (P.proveExposureRelativePrimacy({ ...base, exposureHeight: Number.MAX_SAFE_INTEGER })
214
+ .exposureRelative !== false) return false;
204
215
  // Exposed at EXACTLY the anchor height is not "before" — the boundary must be strict.
205
216
  const same = P.proveExposureRelativePrimacy({ ...base, exposureHeight: ANCHOR_HEIGHT });
206
217
  if (same.exposureRelative !== false) return false;
@@ -466,6 +477,12 @@ export const UNCLAIMED_GUARDS = Object.freeze([
466
477
  "blocks) — again stronger than a claim, again not in the ledger" },
467
478
  { where: "policy.mjs", guard: "the ORDER of the refusal guards",
468
479
  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" },
483
+ { where: "primacy.mjs", guard: "'never exposed' is the CALLER's assertion",
484
+ why: "the kit cannot verify non-exposure. It can only refuse when the field is absent, " +
485
+ "which it now does. Off-chain publication of a key is outside what we measure" },
469
486
  { where: "primacy.mjs", guard: "key rotation / revocation",
470
487
  why: "NOT IMPLEMENTED. A holder who loses their ML-DSA secret is locked out permanently " +
471
488
  "and the old certificate keeps authorising. This is a known gap, not a covered one." },
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.0";
59
+ export const VERSION = "0.5.1";
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
@@ -109,6 +109,26 @@ export const MUTATIONS = [
109
109
  to: ` if (false) {`,
110
110
  mustKill: ["R6-exposure-relative-primacy"],
111
111
  },
112
+ {
113
+ id: "M-R6-absent-height-is-assertion",
114
+ describes: "R6: an ABSENT exposure height is treated as the 'never exposed' assertion again",
115
+ file: "src/primacy.mjs",
116
+ from: ` if (!args || !Object.hasOwn(args, "exposureHeight")) {`,
117
+ to: ` if (false) {`,
118
+ mustKill: ["R6-exposure-relative-primacy"],
119
+ },
120
+ {
121
+ id: "M-R6-refusal-stays-green",
122
+ describes: "R6: a refused exposure result comes back valid:true, as it did before",
123
+ file: "src/primacy.mjs",
124
+ from: ` ...base, valid: false, quantumPropertyHolds: false,
125
+ exposureRelative: false, anchoredAtHeight: anchoredAt, exposureHeight,
126
+ reason: "anchored-after-exposure",`,
127
+ to: ` ...base,
128
+ exposureRelative: false, anchoredAtHeight: anchoredAt, exposureHeight,
129
+ reason: "anchored-after-exposure",`,
130
+ mustKill: ["R6-exposure-relative-primacy"],
131
+ },
112
132
  {
113
133
  id: "M-R4-cutoff-unvalidated",
114
134
  describes: "R4: a malformed cutoff is coerced again — `height > NaN` passes silently",
package/src/primacy.mjs CHANGED
@@ -546,10 +546,27 @@ export const PRIMACY_SCOPE = Object.freeze({
546
546
  * checkable in Bitcoin itself. Pass `null` to assert the key has never been exposed —
547
547
  * which the verifier must also confirm themselves.
548
548
  */
549
- export function proveExposureRelativePrimacy({
550
- subjectClassicalPub, entries, sth, expectedLogId,
551
- exposureHeight, otsHex, blockMerkleRoots,
552
- }) {
549
+ export function proveExposureRelativePrimacy(args) {
550
+ const {
551
+ subjectClassicalPub, entries, sth, expectedLogId,
552
+ exposureHeight, otsHex, blockMerkleRoots,
553
+ } = args || {};
554
+
555
+ // FAIL-OPEN BY OMISSION (round 6 of our own siege). The guard below used `== null`, so
556
+ // an ABSENT field, an `undefined`, or a typo produced `strength: "never-exposed"` — the
557
+ // strongest claim this module makes — byte-identical to the deliberate `null` the
558
+ // docstring documents as a verifier ASSERTION. Absence is nobody's assertion. Worse, the
559
+ // sibling function in this same file uses the identical sentinel with the OPPOSITE
560
+ // polarity: it refuses. Presence is now required, and the comparison is strict.
561
+ if (!args || !Object.hasOwn(args, "exposureHeight")) {
562
+ return {
563
+ valid: false, exposureRelative: false, quantumPropertyHolds: false,
564
+ reason: "exposure-height-not-supplied",
565
+ note: "Non-exposure is an ASSERTION the verifier makes from their own node. A field " +
566
+ "that is simply absent does not make it. Pass an explicit height, or an " +
567
+ "explicit null to assert the key has never appeared on chain.",
568
+ };
569
+ }
553
570
  // Establish the anchor first, WITHOUT letting the exposure height act as the cutoff.
554
571
  // Passing the exposure as `cutoffBlockHeight` would make the base check fire first and
555
572
  // report a generic "anchored-after-cutoff", losing the specific — and far more useful —
@@ -565,7 +582,10 @@ export function proveExposureRelativePrimacy({
565
582
 
566
583
  if (!base.quantumPropertyHolds) {
567
584
  return {
568
- ...base, exposureRelative: false,
585
+ // A refusal must say NO in the field consumers read. This kept `valid: true` from
586
+ // the spread, so the case this function exists to reject came back green to every
587
+ // caller that checks `.valid` — which is what the rest of this package does.
588
+ ...base, valid: false, quantumPropertyHolds: false, exposureRelative: false,
569
589
  reason: "no-confirmed-anchor",
570
590
  note: "Without a header-confirmed Bitcoin anchor there is no height to compare " +
571
591
  "against the exposure, so the stronger claim cannot be made.",
@@ -573,8 +593,16 @@ export function proveExposureRelativePrimacy({
573
593
  }
574
594
 
575
595
  const anchoredAt = base.temporalFrontier.height;
596
+ // The permissive cutoff passed internally is a SENTINEL, not a verifier's choice. It
597
+ // must never travel in the result as `cutoffBlockHeight`, where a reader could cite it
598
+ // as an established frontier.
599
+ base.temporalFrontier = {
600
+ ...base.temporalFrontier,
601
+ cutoffBlockHeight: undefined,
602
+ cutoffNotApplied: "none — exposure-relative mode; the comparison is against the key's exposure block",
603
+ };
576
604
 
577
- if (exposureHeight == null) {
605
+ if (exposureHeight === null) {
578
606
  return {
579
607
  ...base, exposureRelative: true, anchoredAtHeight: anchoredAt, exposureHeight: null,
580
608
  strength: "never-exposed",
@@ -585,13 +613,21 @@ export function proveExposureRelativePrimacy({
585
613
  };
586
614
  }
587
615
 
588
- if (!Number.isSafeInteger(exposureHeight) || exposureHeight < 0) {
589
- return { ...base, exposureRelative: false, reason: "malformed-exposure-height" };
616
+ // An implausible height is not a height. MAX_SAFE_INTEGER especially: it is the internal
617
+ // sentinel, and accepting it as a real exposure would make every anchor look early.
618
+ if (!Number.isSafeInteger(exposureHeight) || exposureHeight < 0 || exposureHeight > 10_000_000) {
619
+ return {
620
+ ...base, valid: false, quantumPropertyHolds: false, exposureRelative: false,
621
+ reason: "malformed-exposure-height",
622
+ note: "Expected a plausible Bitcoin block height (0..10,000,000). Refused rather than " +
623
+ "coerced.",
624
+ };
590
625
  }
591
626
 
592
627
  if (anchoredAt >= exposureHeight) {
593
628
  return {
594
- ...base, exposureRelative: false, anchoredAtHeight: anchoredAt, exposureHeight,
629
+ ...base, valid: false, quantumPropertyHolds: false,
630
+ exposureRelative: false, anchoredAtHeight: anchoredAt, exposureHeight,
595
631
  reason: "anchored-after-exposure",
596
632
  strength: "cutoff-only",
597
633
  note: `Anchored at block ${anchoredAt}, but the key was already public at block ` +
@@ -607,8 +643,8 @@ export function proveExposureRelativePrimacy({
607
643
  blocksBefore: exposureHeight - anchoredAt,
608
644
  note: `Anchored at block ${anchoredAt}, ${exposureHeight - anchoredAt} blocks BEFORE ` +
609
645
  `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.`,
646
+ `chain had NOT yet revealed the public key, so breaking secp256k1 was not sufficient ` +
647
+ `to produce this commitment. This claim does not depend on any Q-day estimate.`,
612
648
  };
613
649
  }
614
650
 
@@ -626,6 +662,16 @@ export const EXPOSURE_SCOPE = Object.freeze({
626
662
  cannotSave:
627
663
  "keys already exposed by an earlier spend. You cannot anchor into the past, and no " +
628
664
  "evidence scheme changes that — those coins must be moved.",
665
+ alsoRequires:
666
+ "that the holder published the key through NO OTHER CHANNEL. We measure on-chain " +
667
+ "appearance only. A key revealed in a mempool broadcast that never confirmed, in a " +
668
+ "shared xpub, or anywhere off-chain was derivable earlier than this proof suggests. " +
669
+ "The adversary's window opens at min(any publication of the key, on-chain exposure); " +
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.",
629
675
  exposureHeightIsNotOurs:
630
676
  "the verifier supplies it from their own node. An exposure height we asserted would be " +
631
677
  "one more thing to trust us about, and the point is that it is independently checkable.",