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