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 +1 -1
- package/bin/cli.mjs +1 -1
- package/package.json +1 -1
- package/src/claims-registry.mjs +35 -0
- package/src/index.mjs +1 -1
- package/src/mutations.mjs +8 -0
- package/src/primacy.mjs +130 -0
- package/test/claims.mjs +2 -2
- package/test/primacy.mjs +40 -2
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 — **
|
|
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:
|
|
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.
|
|
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",
|
package/src/claims-registry.mjs
CHANGED
|
@@ -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.
|
|
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-
|
|
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-
|
|
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);
|