fractal-pqc 0.3.2 → 0.4.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
@@ -1,11 +1,5 @@
1
1
  # fractal-pqc
2
2
 
3
- > ⚠️ **Reference toolkit — NOT for production custody of real funds (yet).** It is
4
- > **unaudited**, **key-path-only**, proven on **signet (not mainnet)**, and its quantum
5
- > guarantee depends on a first-seen anchor registry that is still on the roadmap. Use it
6
- > for **evaluation, testnet, education, and building on** — do **not** secure real Bitcoin
7
- > with it until it is audited and 1.0. We say precisely what it is.
8
-
9
3
  A small, **runnable, test-vector-verified** reference for quantum-safe migration of a
10
4
  Bitcoin-style key. It binds a classical **secp256k1 / Taproot** key to a post-quantum
11
5
  **ML-DSA-65 (NIST FIPS-204)** key and requires a post-quantum signature to authorize —
@@ -25,7 +19,19 @@ without invalidating already-signed history. This kit is a concrete, honest firs
25
19
 
26
20
  ## What is REAL here (verified, not aspirational)
27
21
 
28
- Everything below is exercised by `npm test` with real keys — **46/46 checks pass**:
22
+ > **The security layer (v0.4.0):** `transparency.mjs` (PQ-ANCHOR-v1 append-only log:
23
+ > inclusion + consistency proofs, ML-DSA-65 signed heads, equivocation detection),
24
+ > `primacy.mjs` (first-seen proven by complete enumeration + a Bitcoin-anchored temporal
25
+ > frontier), `policy.mjs` (fail-closed custodian gate), `tapscript.mjs` (BIP-341
26
+ > script-path, asserted against the official wallet vectors) and `ots.mjs` (independent
27
+ > OpenTimestamps codec). All are exported: `fractal-pqc/primacy`, `/policy`,
28
+ > `/transparency`, `/tapscript`, `/ots`.
29
+ >
30
+ > **The limit we have not closed, stated up front:** a holder who never registered a
31
+ > dual-signed commitment before the verifier's cutoff height cannot be rescued by any of
32
+ > this. `test/primacy.mjs` asserts that out loud rather than leaving it to be discovered.
33
+
34
+ Everything below is exercised by `npm test` with real keys — **281 checks pass**:
29
35
 
30
36
  - **secp256k1** commitment + spend authorization (`@noble/curves`).
31
37
  - **Taproot BIP-340 Schnorr** sign/verify, asserted against the **official BIP-340 test
@@ -134,7 +140,7 @@ so the property is never silently over-claimed.
134
140
  - **Key-path-only (no script tree / merkle root).** `taprootTweakPrivateKey`/`signTaprootKeyPath`
135
141
  handle key-path-only outputs — exactly what this kit's `p2trAddress` derives. Pointing the
136
142
  signer at a foreign Taproot output that commits to a script tree would produce an invalid
137
- signature. Script-path (tapscript) spends are not implemented (key-path PSBT + fees are).
143
+ signature. Script-path (tapscript) spends ARE implemented and asserted against the official BIP-341 wallet test vectors (7/7 cases, 12/12 control blocks, byte for byte).
138
144
  - **Secrets are not zeroized.** Private-key `Uint8Array`s are not wiped after use (best-effort
139
145
  only in JS); `keygen` prints secrets by design (testnet/experimental).
140
146
  - **Not a BIP and not consensus.** The "recovery commitment" is an application-layer
package/bin/cli.mjs CHANGED
@@ -33,6 +33,9 @@ import {
33
33
  sendP2trKeyPath,
34
34
  explorerAddrUrl,
35
35
  } from "../src/index.mjs";
36
+ import * as T from "../src/transparency.mjs";
37
+ import * as O from "../src/ots.mjs";
38
+ import * as P from "../src/primacy.mjs";
36
39
 
37
40
  const __dirname = dirname(fileURLToPath(import.meta.url));
38
41
  const hex = (b) => Buffer.from(b).toString("hex");
@@ -188,6 +191,43 @@ switch (cmd) {
188
191
  break;
189
192
  }
190
193
 
194
+ // The honesty ledger: every security sentence this package makes, re-derived on every
195
+ // run, each with the attack written to break it. Four sieges found the same disease
196
+ // four times — an English sentence and a code path drifting apart with a green suite in
197
+ // between — so a claim here is inadmissible unless it ships with both.
198
+ case "claims": {
199
+ const C = await import("../src/claims.mjs");
200
+ const R = await import("../src/claims-registry.mjs");
201
+ const report = C.runClaims(R.CLAIMS);
202
+ if (rest.includes("--gaps")) {
203
+ // What the green does NOT buy. Publishing N/N without this is how an honest tool
204
+ // starts looking like an over-claiming one.
205
+ const R2 = await import("../src/claims-registry.mjs");
206
+ out({ claimed: report.results.map((r) => ({ id: r.id, module: r.module, holds: r.holds })),
207
+ unclaimedGuards: R2.UNCLAIMED_GUARDS,
208
+ note: "A guard on this list is not covered by any claim. Round 5 of our own siege " +
209
+ "deleted an unclaimed guard and the ledger still printed all-green." });
210
+ process.exit(0);
211
+ }
212
+ if (rest.includes("--attribution")) {
213
+ const M = await import("../src/mutations.mjs");
214
+ const a = M.runAttackAttribution();
215
+ out(a);
216
+ process.exit(a.ok ? 0 : 1);
217
+ }
218
+ if (rest.includes("--mutate")) {
219
+ // The third admissibility condition: a claim must DIE when its code dies.
220
+ const M = await import("../src/mutations.mjs");
221
+ const mut = M.runMutations({ claimIds: report.results.filter((r) => r.holds).map((r) => r.id) });
222
+ if (rest.includes("--json")) out({ claims: report, mutations: mut });
223
+ else { console.log(C.formatClaims(report)); console.log(""); console.log(M.formatMutations(mut)); }
224
+ process.exit(report.ok && mut.ok ? 0 : 1);
225
+ }
226
+ if (rest.includes("--json")) out(report);
227
+ else console.log(C.formatClaims(report));
228
+ process.exit(report.ok ? 0 : 1);
229
+ }
230
+
191
231
  case "verify-vector": {
192
232
  const ok = verifiesBip340OfficialVector();
193
233
  out({ bip340_official_vector: ok ? "VERIFIED (Bitcoin-consensus-correct)" : "FAILED" });
@@ -196,7 +236,164 @@ switch (cmd) {
196
236
 
197
237
  case "selftest": {
198
238
  const r = spawnSync(process.execPath, [join(__dirname, "..", "test", "vectors.mjs")], { stdio: "inherit" });
199
- process.exit(r.status ?? 1);
239
+ if ((r.status ?? 1) !== 0) process.exit(r.status ?? 1);
240
+ for (const f of ["transparency.mjs", "primacy.mjs", "anchoring.mjs", "conformance.mjs", "m2-policy.mjs", "bip341-scriptpath.mjs", "claims.mjs"]) {
241
+ const t = spawnSync(process.execPath, [join(__dirname, "..", "test", f)], { stdio: "inherit" });
242
+ if ((t.status ?? 1) !== 0) process.exit(t.status ?? 1);
243
+ }
244
+ process.exit(0);
245
+ }
246
+
247
+ // Verify a transparency bundle WITHOUT trusting the log that served it.
248
+ // Everything is recomputed locally: the head signature, the Merkle root from the
249
+ // audit path, and (when a previous head is present) the append-only property.
250
+ case "verify-anchor": {
251
+ const path = rest[0];
252
+ if (!path) die("usage: fractal-pqc verify-anchor <bundle.json> [--log-id <hex>]");
253
+ const b = JSON.parse(readFileSync(path, "utf8"));
254
+ // PROVENANCE, applied completely this time. Round 3 fixed the headers and left the
255
+ // OTHER two halves of the predicate readable from the claimant's own JSON: the log
256
+ // identity to pin against, and the cutoff height. Both must come from the operator.
257
+ const pin = flag("log-id");
258
+ if (!pin) die("verify-anchor requires --log-id <hex>, obtained independently of this " +
259
+ "bundle. Pinning against an identity the bundle supplies proves nothing.");
260
+ const cutoffRaw = flag("cutoff");
261
+ if (cutoffRaw === undefined) {
262
+ die("verify-anchor requires --cutoff <height>: the block height below which an anchor " +
263
+ "counts as 'before'. There is no objective Q-day and the bundle does not get to choose it.");
264
+ }
265
+ const cutoff = Number(cutoffRaw);
266
+ if (!Number.isSafeInteger(cutoff) || cutoff < 0) die(`--cutoff must be a non-negative integer, got ${cutoffRaw}`);
267
+ // PROVENANCE. The block headers are the entire trust boundary, so they must arrive on a
268
+ // channel the claimant does not control. Reading them out of the same JSON that carries
269
+ // the .ots makes the confirmation self-fulfilling — round 3 of our own siege did exactly
270
+ // that and printed `quantum_property_holds: true` over a forged anchor.
271
+ const roots = {};
272
+ for (const a of rest) {
273
+ const m = /^--block-merkle-root=(\d+)=([0-9a-fA-F]{64})$/.exec(a);
274
+ if (m) roots[Number(m[1])] = m[2].toLowerCase();
275
+ }
276
+ const haveRoots = Object.keys(roots).length > 0;
277
+ // Round 2 of our own siege found this verb still called the ROUND-0 membership-only
278
+ // gate, so a package consumer got exactly the trust model the fixes replaced. It now
279
+ // calls primacy, and reports the temporal frontier rather than implying it.
280
+ const r = b.entries
281
+ ? P.proveFirstSeen({
282
+ subjectClassicalPub: b.subjectClassicalPub || b.classicalPub,
283
+ entries: b.entries, sth: b.sth, expectedLogId: pin,
284
+ cutoffBlockHeight: cutoff,
285
+ otsHex: b.otsHex,
286
+ blockMerkleRoots: haveRoots ? roots : undefined, // NEVER b.blockMerkleRoots
287
+ })
288
+ : { valid: false, reason: "no-enumeration",
289
+ detail: "This bundle carries no `entries` list. Inclusion alone proves membership, " +
290
+ "never primacy, and primacy is the whole quantum property. Supply the full " +
291
+ "ordered entry list for the signed head." };
292
+ const result = {
293
+ head_signature: T.verifyTreeHead(b.sth, pin ? { expectedLogId: pin } : {}),
294
+ primacy: r.valid
295
+ ? `VERIFIED — earliest DUAL-SIGNED binding at index ${r.firstSeenIndex}, enumeration ` +
296
+ `checked against the signed root`
297
+ : `FAILED (${r.reason})${r.detail ? " — " + r.detail : ""}`,
298
+ first_seen_fact_hash: r.firstSeenFactHash || null,
299
+ temporal_frontier: r.temporalFrontier || "NOT ESTABLISHED (no cutoff/anchor supplied)",
300
+ // Never print an affirmative boolean the evidence does not support. This field used to
301
+ // read `true` while the nested note admitted the header had not been checked.
302
+ quantum_property_holds: r.quantumPropertyHolds === true,
303
+ block_headers_provenance: haveRoots
304
+ ? "supplied on the command line, independent of the bundle"
305
+ : "ABSENT — the bundle's own .ots is unauthenticated data. Pass " +
306
+ "--block-merkle-root=<height>=<root> from your own node.",
307
+ ignored_unsigned_entries: r.ignoredUnsignedEntries || [],
308
+ log_identity_pinned: Boolean(pin),
309
+ };
310
+ if (b.previousSth && b.consistency) {
311
+ // The previous head was NEVER authenticated here: only .treeSize and .rootHash were
312
+ // read, and its logId was never compared, so the pin did not reach it. An operator
313
+ // who rewrote history could attach a FABRICATED previous head and this tool printed
314
+ // the product's strongest guarantee. Found by our own siege. Authenticate first.
315
+ const prev = T.verifyTreeHead(b.previousSth, pin ? { expectedLogId: pin } : {});
316
+ if (!prev.valid) {
317
+ result.append_only = `NOT CHECKED — the previous head does not verify (${prev.reason}). ` +
318
+ `An unauthenticated previous head proves nothing, so no append-only claim is made.`;
319
+ out(result);
320
+ process.exit(r.valid ? 0 : 1);
321
+ }
322
+ if (b.previousSth.logId !== b.sth.logId) {
323
+ result.append_only = "NOT CHECKED — the two heads are from DIFFERENT logs, so " +
324
+ "consistency between them is meaningless.";
325
+ out(result);
326
+ process.exit(r.valid ? 0 : 1);
327
+ }
328
+ if (b.previousSth.treeSize > b.sth.treeSize) {
329
+ result.append_only = "NOT CHECKED — the 'previous' head is LARGER than the current one.";
330
+ out(result);
331
+ process.exit(r.valid ? 0 : 1);
332
+ }
333
+ const c = T.verifyConsistency(
334
+ b.previousSth.treeSize, b.sth.treeSize,
335
+ T.fromHex(b.previousSth.rootHash), T.fromHex(b.sth.rootHash),
336
+ b.consistency.map(T.fromHex)
337
+ );
338
+ result.append_only = c
339
+ ? "VERIFIED (both heads authenticated under the pinned log; this head extends the " +
340
+ "previous one, so no history was rewritten between them)"
341
+ : "UNRESOLVED — the supplied consistency proof does not verify. The proof is an " +
342
+ "UNSIGNED field, so this is consistent with the log rewriting history AND with " +
343
+ "whoever handed you this bundle altering the proof. Ask the log for the proof " +
344
+ "between these exact heads before concluding anything.";
345
+ if (!c) result.allegation = T.detectEquivocation(b.previousSth, b.sth, { consistencyProof: b.consistency });
346
+ } else {
347
+ result.append_only = "NOT CHECKED (bundle carried no previous head + consistency proof)";
348
+ }
349
+ // Bitcoin anchoring: from the .ots alone, derive the block height and the Merkle
350
+ // root that block must contain. You then check that root against a header you get
351
+ // from your own node. We are not in that path.
352
+ if (b.otsHex && b.anchorFileHex) {
353
+ try {
354
+ const anchorFile = T.fromHex(b.anchorFileHex);
355
+ const ev = O.evaluateOts(O.decodeOtsFile(T.fromHex(b.otsHex)));
356
+ const commits = ev.fileDigestHex === T.toHex(O.otsSha256(anchorFile));
357
+ if (!commits) {
358
+ result.bitcoin_anchor = "FAILED — the .ots does not commit to the anchor file supplied";
359
+ } else if ((ev.blockAttestations || []).length > 0) {
360
+ const att = ev.blockAttestations[0];
361
+ const rootDisplay = (att.merkleRootInternalHex.match(/../g) || []).reverse().join("");
362
+ result.bitcoin_anchor = {
363
+ status: "BITCOIN-ATTESTED",
364
+ chain: att.chain,
365
+ block_height: att.height,
366
+ block_merkle_root: rootDisplay,
367
+ check_it_yourself: `bitcoin-cli getblockheader $(bitcoin-cli getblockhash ${att.height}) | grep merkleroot`,
368
+ means: "the tree head existed no later than this block. Anteriority, not uniqueness.",
369
+ };
370
+ } else {
371
+ result.bitcoin_anchor = {
372
+ status: "PENDING",
373
+ calendars: (ev.pending || []).map((p) => p.uri),
374
+ means: "the calendars accepted it but no Bitcoin block confirms it YET. " +
375
+ "A pending anchor is NOT a Bitcoin timestamp. Upgrade the proof and re-check.",
376
+ };
377
+ }
378
+ } catch (e) {
379
+ result.bitcoin_anchor = `FAILED to parse the .ots: ${e.message}`;
380
+ }
381
+ } else {
382
+ result.bitcoin_anchor = "ABSENT (bundle carried no .ots). The head is our assertion only.";
383
+ }
384
+
385
+ if (!pin) {
386
+ result.caveat = "No log identity was pinned, so this proves nothing about THE log. " +
387
+ "Pin it with --log-id from an independently obtained source.";
388
+ }
389
+ if (r.valid && r.quantumPropertyHolds !== true) {
390
+ result.warning = "Primacy holds WITHIN the log, but the temporal frontier is not " +
391
+ "established. Being first in a log is not being first in TIME, and only time " +
392
+ "separates the holder from a post-quantum attacker. Do not authorise a spend on this.";
393
+ }
394
+ result.scope = T.HONEST_SCOPE.doesNotProve;
395
+ out(result);
396
+ process.exit(r.valid ? 0 : 1);
200
397
  }
201
398
 
202
399
  default:
@@ -212,8 +409,22 @@ Usage:
212
409
  fractal-pqc receive --key <taproot> [--network tb] Show the funding (P2TR) address
213
410
  fractal-pqc send-testnet --key <taproot> --to <addr> --amount <sats> [--fee-rate N] [--network tb|signet] [--broadcast]
214
411
  Build+sign a P2TR spend; dry-run unless --broadcast
412
+ fractal-pqc verify-anchor <bundle.json> --log-id <hex> --cutoff <height>
413
+ --block-merkle-root=<height>=<root> (from YOUR node)
414
+ Verify a transparency bundle offline: head
415
+ signature, inclusion (root recomputed) and
416
+ append-only consistency. Trusts nothing.
417
+ fractal-pqc claims [--mutate|--gaps|--attribution] [--json]
418
+ THE HONESTY LEDGER: every security claim this
419
+ package makes, with the attack written to break
420
+ it. Exits non-zero if any claim is false or was
421
+ stated without a proof AND an attack.
422
+ --mutate also reintroduces bugs that really
423
+ shipped here and proves each claim DIES when the
424
+ code it names is broken. A claim no mutation can
425
+ kill is vacuous, and is reported as such.
215
426
  fractal-pqc verify-vector Check the official BIP-340 test vector
216
- fractal-pqc selftest Run the full real test suite (19 checks)
427
+ fractal-pqc selftest Run everything: 281 real checks, no mocks
217
428
 
218
429
  Docs: integrations/pqc-migration-kit/README.md`);
219
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.2",
3
+ "version": "0.4.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",
@@ -18,21 +18,37 @@
18
18
  "exports": {
19
19
  ".": "./src/index.mjs",
20
20
  "./migration-envelope": "./src/migration-envelope.mjs",
21
- "./bitcoin": "./src/bitcoin.mjs"
21
+ "./bitcoin": "./src/bitcoin.mjs",
22
+ "./transparency": "./src/transparency.mjs",
23
+ "./primacy": "./src/primacy.mjs",
24
+ "./policy": "./src/policy.mjs",
25
+ "./tapscript": "./src/tapscript.mjs",
26
+ "./ots": "./src/ots.mjs",
27
+ "./address": "./src/address.mjs",
28
+ "./claims": "./src/claims.mjs",
29
+ "./claims-registry": "./src/claims-registry.mjs",
30
+ "./mutations": "./src/mutations.mjs"
22
31
  },
23
32
  "bin": {
24
33
  "fractal-pqc": "./bin/cli.mjs"
25
34
  },
26
35
  "files": [
27
- "src",
36
+ "LICENSE",
37
+ "README.md",
28
38
  "bin",
39
+ "src",
29
40
  "test",
30
- "README.md",
31
- "LICENSE"
41
+ "tools",
42
+ "vectors"
32
43
  ],
33
44
  "scripts": {
34
- "test": "node test/vectors.mjs",
35
- "selftest": "node bin/cli.mjs selftest"
45
+ "test": "node test/vectors.mjs && node test/transparency.mjs && node test/primacy.mjs && node test/anchoring.mjs && node test/conformance.mjs && node test/m2-policy.mjs && node test/bip341-scriptpath.mjs && node test/claims.mjs",
46
+ "selftest": "node bin/cli.mjs selftest",
47
+ "conformance": "node test/conformance.mjs",
48
+ "claims": "node bin/cli.mjs claims",
49
+ "mutate": "node bin/cli.mjs claims --mutate",
50
+ "attribution": "node bin/cli.mjs claims --attribution",
51
+ "gaps": "node bin/cli.mjs claims --gaps"
36
52
  },
37
53
  "dependencies": {
38
54
  "@noble/curves": "^2.2.0",
package/src/address.mjs CHANGED
@@ -59,6 +59,28 @@ export function p2trAddress(internalXOnly, hrp = "bc") {
59
59
  return addr;
60
60
  }
61
61
 
62
+ /**
63
+ * Encode a P2TR address from an ALREADY-TWEAKED output key (witness v1, bech32m).
64
+ *
65
+ * `p2trAddress` takes an INTERNAL key and applies the key-path tweak itself, which is
66
+ * right for key-path-only outputs and wrong for anything with a script tree — tweaking
67
+ * an output key a second time yields a valid-looking address for coins nobody can spend.
68
+ * Script-path callers derive the output key with `taprootOutputKeyWithTree` and encode it
69
+ * here. Asserted against the official BIP-341 `bip350Address` vectors.
70
+ *
71
+ * @param {Uint8Array} outputKeyXOnly 32-byte x-only TWEAKED output key
72
+ * @param {"bc"|"tb"|"bcrt"} [hrp="bc"] network prefix
73
+ */
74
+ export function p2trAddressFromOutputKey(outputKeyXOnly, hrp = "bc") {
75
+ assertHrp(hrp);
76
+ if (!(outputKeyXOnly instanceof Uint8Array) || outputKeyXOnly.length !== 32) {
77
+ throw new Error("p2trAddressFromOutputKey: expected a 32-byte x-only output key");
78
+ }
79
+ const addr = segwitEncode(hrp, 1, outputKeyXOnly);
80
+ if (!addr) throw new Error("failed to encode P2TR address");
81
+ return addr;
82
+ }
83
+
62
84
  /**
63
85
  * Decode a bech32/bech32m SegWit address into its scriptPubKey bytes, so we can pay it.
64
86
  * witver 0 → OP_0 PUSH<20|32> program (P2WPKH / P2WSH)