fractal-pqc 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FRACTAL AI S.A.S.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # fractal-pqc
2
+
3
+ A small, **runnable, test-vector-verified** reference for quantum-safe migration of a
4
+ Bitcoin-style key. It binds a classical **secp256k1 / Taproot** key to a post-quantum
5
+ **ML-DSA-65 (NIST FIPS-204)** key and requires a post-quantum signature to authorize —
6
+ the core mechanism of any Bitcoin quantum-readiness transition.
7
+
8
+ Built from the same crypto-agility discipline FractalAI already runs in production on
9
+ its own post-quantum L1 (verify-both-during-migration, no-downgrade), distilled to the
10
+ `secp256k1 → ML-DSA-65` case relevant to Bitcoin.
11
+
12
+ ## Why this exists
13
+
14
+ A large quantum computer can recover a `secp256k1` private key from an exposed public
15
+ key (Shor's algorithm), so any Bitcoin output whose pubkey is on-chain becomes
16
+ spendable by an attacker. The migration problem: let a holder **bind their existing key
17
+ to a post-quantum successor key** and, from then on, require a PQC signature to spend —
18
+ without invalidating already-signed history. This kit is a concrete, honest first step.
19
+
20
+ ## What is REAL here (verified, not aspirational)
21
+
22
+ Everything below is exercised by `npm test` with real keys — **46/46 checks pass**:
23
+
24
+ - **secp256k1** commitment + spend authorization (`@noble/curves`).
25
+ - **Taproot BIP-340 Schnorr** sign/verify, asserted against the **official BIP-340 test
26
+ vector** — i.e. byte-for-byte consensus-correct with Bitcoin, not an approximation.
27
+ - **ML-DSA-65 (FIPS-204, Cat-3)** signing/verification (`@noble/post-quantum`): 1952-byte
28
+ public key, 4032-byte secret, 3309-byte signature.
29
+ - **Dual-signature migration commitment**: classical + PQC both sign the same canonical,
30
+ domain-separated binding; a verifier reconstructs identical bytes.
31
+ - **Taproot → PQC recovery commitment** (P2QRH-style): binds a 32-byte x-only Taproot
32
+ output key to an ML-DSA-65 key, dual-signed.
33
+ - **Real Bitcoin address derivation**: bech32/bech32m (**official BIP-173/350 vectors**),
34
+ P2TR `bc1p…`/`tb1p…` via the **BIP-341** taproot tweak (**official BIP-341 vector →
35
+ `bc1p2wsldez…`**), and P2WPKH `bc1q…`.
36
+ - **Real Bitcoin transactions**: segwit/legacy serialization + txid (validated against the
37
+ **Bitcoin genesis coinbase txid**), and a **BIP-341 key-path sighash that matches the
38
+ OFFICIAL `bip-0341/wallet-test-vectors.json` byte-for-byte** (a permanent test in the
39
+ suite) — i.e. a key-path SIGHASH_DEFAULT spend signs the Bitcoin-consensus message and
40
+ will not be sighash-rejected by the network. Key-path signing produces a Schnorr sig
41
+ that also verifies against the Taproot output key.
42
+ - **Fees + coin selection**: exact segwit vsize (1-in/1-out P2TR = 111 vB), accumulative
43
+ coin selection with change/dust handling and a fee-rate (sat/vB) → conserves value.
44
+ - **BIP-174 PSBT** (P2TR key-path): `createPsbt` → `signPsbtTaprootKeyPath` → `finalizePsbt`
45
+ into a broadcastable tx; serialize↔parse round-trips, base64 carries the `psbt\xff` magic,
46
+ and the finalized witness signature verifies against the Taproot output key. The signer
47
+ refuses any input the key doesn't control (custodian-safe).
48
+ - **Testnet broadcaster**: fetches your address's UTXOs + the live fee rate (mempool.space
49
+ Esplora API), builds+signs the spend, and publishes it — **dry-run by default**, `--broadcast`
50
+ to send. `addressToScriptPubKey` lets you pay any SegWit address.
51
+ - **The quantum property under test** (correctly modeled): `verifySpend` requires the
52
+ verifier to pin the holder's **anchored** commitment (`anchoredFactHash`). Given that
53
+ pin, an attacker who recovered the classical key post-Shor — and who can forge a
54
+ *well-formed* cert re-binding the victim's classical key to the attacker's OWN PQC key
55
+ — is still rejected (`anchor-mismatch`), because the forged cert's factHash differs
56
+ from the anchored one. Without the anchor, `verifySpend` fails closed (`anchor-required`).
57
+
58
+ Run it:
59
+
60
+ ```bash
61
+ cd integrations/pqc-migration-kit
62
+ npm install # or reuse the workspace's @noble packages
63
+ npm test
64
+ ```
65
+
66
+ ## SDK
67
+
68
+ ```js
69
+ import {
70
+ generateMigrationIdentity, createMigrationCommitment, verifyMigrationCommitment,
71
+ authorizeSpend, verifySpend,
72
+ generateTaprootKey, taprootSign, taprootVerify, verifiesBip340OfficialVector,
73
+ createTaprootPqcCommitment, verifyTaprootPqcCommitment,
74
+ } from "fractal-pqc";
75
+
76
+ const id = generateMigrationIdentity(); // secp256k1 + ML-DSA-65
77
+ const cert = createMigrationCommitment(id); // dual-signed, anchorable
78
+ verifyMigrationCommitment(cert).valid; // true
79
+ ```
80
+
81
+ ## CLI
82
+
83
+ ```bash
84
+ npx fractal-pqc verify-vector # assert the official BIP-340 test vector
85
+ npx fractal-pqc selftest # run the full real test suite (46 checks)
86
+ npx fractal-pqc keygen > key.json # generate a migration identity (testnet/experimental)
87
+ npx fractal-pqc keygen --taproot > tk.json # a Taproot key
88
+ npx fractal-pqc address --key tk.json --network tb # derive a real tb1p… Taproot address
89
+ npx fractal-pqc commit --key key.json > commit.json
90
+ npx fractal-pqc verify commit.json # exit 0 = valid, 1 = invalid/tampered
91
+ npx fractal-pqc taproot-commit --key <taproot+pq key.json> # Taproot -> PQC recovery commitment
92
+ ```
93
+
94
+ `verify`/`taproot-verify`/`verify-vector` exit non-zero on failure, so they compose into
95
+ CI and custody scripts.
96
+
97
+ ## Send a real spend on testnet
98
+
99
+ ```bash
100
+ npx fractal-pqc keygen --taproot > tk.json # your Taproot key
101
+ npx fractal-pqc receive --key tk.json --network tb # → tb1p… funding address + explorer link
102
+ # fund that address from a testnet faucet, then:
103
+ npx fractal-pqc send-testnet --key tk.json --to <dest tb1…> --amount 20000 --network tb
104
+ # ↑ DRY-RUN: fetches your UTXOs + live fee rate, builds+signs, prints hex + txid (does NOT send)
105
+ npx fractal-pqc send-testnet --key tk.json --to <dest tb1…> --amount 20000 --network tb --broadcast
106
+ # ↑ actually publishes via mempool.space and returns the txid + explorer URL
107
+ ```
108
+
109
+ The SDK equivalent is `sendP2trKeyPath({ internalPriv, to, amountSats, network, broadcast })`.
110
+
111
+ ## Security model — the anchor is load-bearing
112
+
113
+ The quantum protection does **not** come from a cert verifying in isolation (a cert only
114
+ proves whoever built it held both secret keys). It comes from **anchoring**: the holder
115
+ publishes their commitment first-seen and immutably (e.g. an on-chain Merkle leaf) BEFORE
116
+ Q-day, and every verifier **pins that anchored factHash**. `verifySpend`/`verifyMigrationCommitment`
117
+ take `anchoredFactHash` and reject any cert that doesn't match it. Skip the anchor and the
118
+ kit provides no quantum protection — `verifySpend` fails closed rather than pretend otherwise.
119
+ Providing the anchor registry (first-seen, no-duplicate, immutable) is part of the
120
+ grant-funded roadmap below; this reference makes the anchor a **required verifier input**
121
+ so the property is never silently over-claimed.
122
+
123
+ ## What this is NOT (honest scope — do not overstate)
124
+
125
+ - **Not yet confirmed in a live mempool.** The sighash is proven consensus-correct against
126
+ the official BIP-341 vector, and the broadcaster is built + dry-run-tested — but an actual
127
+ funded **testnet** broadcast (which needs tBTC from a faucet) is the operator's final step.
128
+ - **Key-path-only (no script tree / merkle root).** `taprootTweakPrivateKey`/`signTaprootKeyPath`
129
+ handle key-path-only outputs — exactly what this kit's `p2trAddress` derives. Pointing the
130
+ signer at a foreign Taproot output that commits to a script tree would produce an invalid
131
+ signature. Script-path (tapscript) spends are not implemented (key-path PSBT + fees are).
132
+ - **Secrets are not zeroized.** Private-key `Uint8Array`s are not wiped after use (best-effort
133
+ only in JS); `keygen` prints secrets by design (testnet/experimental).
134
+ - **Not a BIP and not consensus.** The "recovery commitment" is an application-layer
135
+ construction, not a Bitcoin output type. A real quantum-resistant output requires a
136
+ soft fork (e.g. a P2QRH / BIP-360-style commitment) — out of scope for this reference.
137
+ - **Not audited.** Reference code; the underlying `@noble` libraries are audited, this
138
+ composition is not.
139
+
140
+ ## Roadmap — from reference to real Bitcoin custody tooling (the grant-funded work)
141
+
142
+ Each milestone is independently verifiable, open-source, and shippable on its own:
143
+
144
+ 1. **Bitcoin address + tx layer** — ✅ *done + vector-verified*: bech32/bech32m (BIP-173/350),
145
+ P2TR `bc1p…` derivation (BIP-341), segwit tx + txid (genesis-checked), a BIP-341 key-path
146
+ sighash **matching the official BIP-341 vector byte-for-byte**, **fees + coin selection**,
147
+ **BIP-174 PSBT** create→sign→finalize, and a **testnet broadcaster** (fetch UTXOs/fees →
148
+ build+sign → publish; dry-run tested offline) — all permanent tests.
149
+ **Remaining:** a funded **testnet** broadcast to confirm in a live mempool (needs tBTC),
150
+ and script-path (tapscript) spends.
151
+ 2. **P2QRH-style quantum-resistant output** — a concrete commitment scheme + spending
152
+ path a custodian can pre-register for held UTXOs, with a reference verifier.
153
+ 3. **Custodian migration CLI** — take a real (testnet) UTXO set, emit PQC recovery
154
+ commitments, and produce the dual-signed migration record; end-to-end on testnet.
155
+ 4. **On-chain anchoring** (first-seen, immutable) — anchor recovery commitments as
156
+ verifiable leaves (reusing FractalAI's Merkle-anchor pipeline) so a holder's migration
157
+ is publicly provable and pinnable — this is what makes the `verifySpend` anchor real.
158
+ 5. **Soft-fork integration analysis** — a PQC security review of candidate Bitcoin
159
+ quantum proposals at the signature-integration surface.
160
+
161
+ ## License
162
+
163
+ MIT. Uses `@noble/curves`, `@noble/hashes`, `@noble/post-quantum` (audited, MIT).
package/bin/cli.mjs ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // fractal-pqc — CLI for the FractalAI PQC migration kit.
5
+ // Quantum-safe migration of a Bitcoin-style key (secp256k1/Taproot -> ML-DSA-65).
6
+ //
7
+ // Commands:
8
+ // fractal-pqc keygen [--taproot] Generate a migration identity (hex).
9
+ // fractal-pqc commit --key <file> Create a dual-signed migration commitment.
10
+ // fractal-pqc verify <commitment.json> Verify a migration commitment (exit 0/1).
11
+ // fractal-pqc taproot-commit --key <file> Create a Taproot->PQC recovery commitment.
12
+ // fractal-pqc taproot-verify <commitment.json>
13
+ // fractal-pqc verify-vector Check the official BIP-340 test vector.
14
+ // fractal-pqc selftest Run the full real test suite (19 checks).
15
+ //
16
+ // SECURITY: keys printed by `keygen` are for testnet / experimentation. Do NOT paste a
17
+ // production Bitcoin secret key into any tool; this kit is a reference, not a signer HSM.
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { fileURLToPath } from "node:url";
21
+ import { dirname, join } from "node:path";
22
+ import { spawnSync } from "node:child_process";
23
+ import {
24
+ generateMigrationIdentity,
25
+ createMigrationCommitment,
26
+ verifyMigrationCommitment,
27
+ generateTaprootKey,
28
+ createTaprootPqcCommitment,
29
+ verifyTaprootPqcCommitment,
30
+ verifiesBip340OfficialVector,
31
+ p2trAddress,
32
+ p2wpkhAddress,
33
+ sendP2trKeyPath,
34
+ explorerAddrUrl,
35
+ } from "../src/index.mjs";
36
+
37
+ const __dirname = dirname(fileURLToPath(import.meta.url));
38
+ const hex = (b) => Buffer.from(b).toString("hex");
39
+ const fromHex = (h) => Uint8Array.from(Buffer.from(h, "hex"));
40
+ const out = (o) => console.log(JSON.stringify(o, null, 2));
41
+ const die = (msg) => { console.error(`error: ${msg}`); process.exit(2); };
42
+
43
+ const [, , cmd, ...rest] = process.argv;
44
+
45
+ function flag(name) {
46
+ const i = rest.indexOf(`--${name}`);
47
+ return i >= 0 ? rest[i + 1] : undefined;
48
+ }
49
+
50
+ function readJson(path, what = "file") {
51
+ if (!path) die(`a ${what} path is required`);
52
+ let raw;
53
+ try { raw = readFileSync(path, "utf8"); } catch (e) { die(`cannot read ${what}: ${e.message}`); }
54
+ try { return JSON.parse(raw); } catch { die(`invalid JSON in ${what}: ${path}`); }
55
+ }
56
+ function loadKeyFile(path) {
57
+ return readJson(path, "--key <file>");
58
+ }
59
+ // Require a hex field on a key object, else fail cleanly (exit 2) instead of letting
60
+ // fromHex(undefined) throw a raw Node stack trace.
61
+ function reqHex(obj, ...names) {
62
+ const name = names.find((n) => typeof obj[n] === "string" && /^[0-9a-fA-F]*$/.test(obj[n]));
63
+ if (!name) die(`--key file is missing/invalid field: ${names.join(" | ")}`);
64
+ return fromHex(obj[name]);
65
+ }
66
+ const VALID_NETWORKS = new Set(["bc", "tb", "bcrt"]);
67
+
68
+ switch (cmd) {
69
+ case "keygen": {
70
+ if (rest.includes("--taproot")) {
71
+ const t = generateTaprootKey();
72
+ out({
73
+ kind: "taproot",
74
+ secretKey: hex(t.secretKey),
75
+ xOnlyPub: hex(t.xOnlyPub),
76
+ _warning: "TESTNET/EXPERIMENTAL ONLY — never use a real Bitcoin secret key here.",
77
+ });
78
+ } else {
79
+ const id = generateMigrationIdentity();
80
+ out({
81
+ kind: "migration-identity",
82
+ classicalPriv: hex(id.classicalPriv),
83
+ classicalPub: hex(id.classicalPub),
84
+ pqSecret: hex(id.pqSecret),
85
+ pqPublic: hex(id.pqPublic),
86
+ _warning: "TESTNET/EXPERIMENTAL ONLY — secrets printed in cleartext.",
87
+ });
88
+ }
89
+ break;
90
+ }
91
+
92
+ case "commit": {
93
+ const k = loadKeyFile(flag("key"));
94
+ const id = {
95
+ classicalPriv: reqHex(k, "classicalPriv"),
96
+ classicalPub: reqHex(k, "classicalPub"),
97
+ pqSecret: reqHex(k, "pqSecret"),
98
+ pqPublic: reqHex(k, "pqPublic"),
99
+ };
100
+ out(createMigrationCommitment(id));
101
+ break;
102
+ }
103
+
104
+ case "verify": {
105
+ const path = rest.find((a) => !a.startsWith("--"));
106
+ if (!path) die("usage: fractal-pqc verify <commitment.json> [--anchor <factHash>]");
107
+ const cert = readJson(path, "commitment");
108
+ const anchor = flag("anchor");
109
+ if (!anchor) {
110
+ console.error("note: no --anchor given → checking WELL-FORMEDNESS only, not that this is the holder's anchored binding. Pass --anchor <factHash> for the security check.");
111
+ }
112
+ const r = verifyMigrationCommitment(cert, anchor ? { anchoredFactHash: anchor } : {});
113
+ out(r);
114
+ process.exit(r.valid ? 0 : 1);
115
+ }
116
+
117
+ case "taproot-commit": {
118
+ const k = loadKeyFile(flag("key"));
119
+ // Accept either a taproot keyfile (+ --pq for the PQC key) or a combined file.
120
+ const taproot = { secretKey: reqHex(k, "secretKey", "taprootSecret"), xOnlyPub: reqHex(k, "xOnlyPub", "taprootXOnlyPub") };
121
+ const pqFile = flag("pq") ? readJson(flag("pq"), "--pq <file>") : k;
122
+ const pqId = { pqSecret: reqHex(pqFile, "pqSecret"), pqPublic: reqHex(pqFile, "pqPublic") };
123
+ out(createTaprootPqcCommitment(taproot, pqId));
124
+ break;
125
+ }
126
+
127
+ case "taproot-verify": {
128
+ const path = rest.find((a) => !a.startsWith("--"));
129
+ if (!path) die("usage: fractal-pqc taproot-verify <commitment.json> [--anchor <factHash>]");
130
+ const cert = readJson(path, "commitment");
131
+ const anchor = flag("anchor");
132
+ if (!anchor) {
133
+ console.error("note: no --anchor given → well-formedness only. Pass --anchor <factHash> for the security check.");
134
+ }
135
+ const r = verifyTaprootPqcCommitment(cert, anchor ? { anchoredFactHash: anchor } : {});
136
+ out(r);
137
+ process.exit(r.valid ? 0 : 1);
138
+ }
139
+
140
+ case "address": {
141
+ const k = loadKeyFile(flag("key"));
142
+ const net = flag("network") || "bc"; // bc | tb | bcrt
143
+ if (!VALID_NETWORKS.has(net)) die(`unknown --network '${net}' (expected bc | tb | bcrt)`);
144
+ const result = {};
145
+ const xonly = k.xOnlyPub ?? k.taprootXOnlyPub;
146
+ if (xonly) result.p2tr = p2trAddress(reqHex({ xonly }, "xonly"), net);
147
+ if (k.classicalPub) result.p2wpkh = p2wpkhAddress(reqHex(k, "classicalPub"), net);
148
+ if (!result.p2tr && !result.p2wpkh) die("key file needs xOnlyPub (Taproot) or classicalPub (P2WPKH)");
149
+ result.network = net;
150
+ out(result);
151
+ break;
152
+ }
153
+
154
+ case "receive": {
155
+ const k = loadKeyFile(flag("key"));
156
+ const net = flag("network") || "tb";
157
+ if (!VALID_NETWORKS.has(net)) die(`unknown --network '${net}' (expected bc | tb | bcrt)`);
158
+ const xonly = k.xOnlyPub ?? k.taprootXOnlyPub;
159
+ if (!xonly) die("--key must be a Taproot key (from `keygen --taproot`)");
160
+ const addr = p2trAddress(reqHex({ xonly }, "xonly"), net);
161
+ out({ fundingAddress: addr, network: net, explorer: explorerAddrUrl(addr, net),
162
+ hint: net === "tb" ? "Fund it from a testnet faucet, then: fractal-pqc send-testnet --key <file> --to <addr> --amount <sats>" : undefined });
163
+ break;
164
+ }
165
+
166
+ case "send-testnet": {
167
+ const k = loadKeyFile(flag("key"));
168
+ const net = flag("network") || "tb";
169
+ const to = flag("to");
170
+ const amount = Number(flag("amount"));
171
+ if (!to) die("--to <address> is required");
172
+ if (!Number.isInteger(amount) || amount <= 0) die("--amount <sats> must be a positive integer");
173
+ const secret = k.secretKey ?? k.taprootSecret;
174
+ if (!secret) die("--key must be a Taproot key with a secretKey (from `keygen --taproot`)");
175
+ const doBroadcast = rest.includes("--broadcast");
176
+ const feeRate = flag("fee-rate") ? Number(flag("fee-rate")) : undefined;
177
+ try {
178
+ const r = await sendP2trKeyPath({
179
+ internalPriv: reqHex({ secret }, "secret"), to, amountSats: amount,
180
+ network: net === "bc" ? "bc" : net, feeRate, broadcast: doBroadcast,
181
+ });
182
+ out({
183
+ ...r,
184
+ note: doBroadcast ? "BROADCAST — published to the network." : "DRY-RUN — not sent. Re-run with --broadcast to publish.",
185
+ });
186
+ process.exit(0);
187
+ } catch (e) { die(e.message); }
188
+ break;
189
+ }
190
+
191
+ case "verify-vector": {
192
+ const ok = verifiesBip340OfficialVector();
193
+ out({ bip340_official_vector: ok ? "VERIFIED (Bitcoin-consensus-correct)" : "FAILED" });
194
+ process.exit(ok ? 0 : 1);
195
+ }
196
+
197
+ case "selftest": {
198
+ const r = spawnSync(process.execPath, [join(__dirname, "..", "test", "vectors.mjs")], { stdio: "inherit" });
199
+ process.exit(r.status ?? 1);
200
+ }
201
+
202
+ default:
203
+ console.log(`fractal-pqc — quantum-safe migration for a Bitcoin-style key
204
+
205
+ Usage:
206
+ fractal-pqc keygen [--taproot] Generate a migration identity (hex)
207
+ fractal-pqc commit --key <file> Create a dual-signed migration commitment
208
+ fractal-pqc verify <commitment.json> Verify a migration commitment (exit 0/1)
209
+ fractal-pqc taproot-commit --key <file> Create a Taproot->PQC recovery commitment
210
+ fractal-pqc taproot-verify <file.json> Verify a Taproot->PQC recovery commitment
211
+ fractal-pqc address --key <file> [--network bc|tb|bcrt] Derive P2TR/P2WPKH address
212
+ fractal-pqc receive --key <taproot> [--network tb] Show the funding (P2TR) address
213
+ fractal-pqc send-testnet --key <taproot> --to <addr> --amount <sats> [--fee-rate N] [--network tb|signet] [--broadcast]
214
+ Build+sign a P2TR spend; dry-run unless --broadcast
215
+ fractal-pqc verify-vector Check the official BIP-340 test vector
216
+ fractal-pqc selftest Run the full real test suite (19 checks)
217
+
218
+ Docs: integrations/pqc-migration-kit/README.md`);
219
+ process.exit(cmd ? 1 : 0);
220
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "fractal-pqc",
3
+ "version": "0.3.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "FractalAI",
8
+ "homepage": "https://fractalai.net.co",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/fractalai/fractal-ai",
12
+ "directory": "integrations/pqc-migration-kit"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "main": "./src/index.mjs",
18
+ "exports": {
19
+ ".": "./src/index.mjs",
20
+ "./migration-envelope": "./src/migration-envelope.mjs",
21
+ "./bitcoin": "./src/bitcoin.mjs"
22
+ },
23
+ "bin": {
24
+ "fractal-pqc": "./bin/cli.mjs"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "bin",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "test": "node test/vectors.mjs",
34
+ "selftest": "node bin/cli.mjs selftest"
35
+ },
36
+ "dependencies": {
37
+ "@noble/curves": "^2.2.0",
38
+ "@noble/hashes": "^2.2.0",
39
+ "@noble/post-quantum": "^0.6.1"
40
+ },
41
+ "keywords": [
42
+ "bitcoin",
43
+ "post-quantum",
44
+ "ml-dsa",
45
+ "fips-204",
46
+ "secp256k1",
47
+ "taproot",
48
+ "quantum-migration",
49
+ "crypto-agility"
50
+ ]
51
+ }
@@ -0,0 +1,102 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Real Bitcoin address derivation: P2TR (Taproot, BIP-341 key-path) and P2WPKH.
4
+ // Validated against the official BIP-341 test vector in test/vectors.mjs.
5
+
6
+ import { secp256k1, schnorr } from "@noble/curves/secp256k1.js";
7
+ import { sha256 } from "@noble/hashes/sha2.js";
8
+ import { ripemd160 } from "@noble/hashes/legacy.js";
9
+ import { segwitEncode, segwitDecode } from "./bech32.mjs";
10
+
11
+ const Point = secp256k1.Point;
12
+ // secp256k1 group order n (BIP-340/341 scalar modulus).
13
+ const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
14
+
15
+ function bytesToBigInt(b) {
16
+ let x = 0n;
17
+ for (const byte of b) x = (x << 8n) | BigInt(byte);
18
+ return x;
19
+ }
20
+ function bigIntTo32(x) {
21
+ const out = new Uint8Array(32);
22
+ for (let i = 31; i >= 0; i--) { out[i] = Number(x & 0xffn); x >>= 8n; }
23
+ return out;
24
+ }
25
+
26
+ /**
27
+ * BIP-341 key-path taproot tweak (no script tree): Q = P + int(tagged_hash("TapTweak", P))·G.
28
+ * @param {Uint8Array} internalXOnly 32-byte x-only internal public key
29
+ * @returns {Uint8Array} 32-byte x-only tweaked output key
30
+ */
31
+ export function taprootTweakOutputKey(internalXOnly) {
32
+ if (!(internalXOnly instanceof Uint8Array) || internalXOnly.length !== 32) {
33
+ throw new Error("internal key must be a 32-byte x-only public key");
34
+ }
35
+ // BIP-341: t = int(hashTapTweak(P)); fail if t >= n (don't silently reduce).
36
+ const t = bytesToBigInt(schnorr.utils.taggedHash("TapTweak", internalXOnly));
37
+ if (t === 0n || t >= N) throw new Error("taproot tweak out of range");
38
+ const P = schnorr.utils.lift_x(bytesToBigInt(internalXOnly)); // even-Y internal point
39
+ const Q = P.add(Point.BASE.multiply(t));
40
+ if (Q.is0()) throw new Error("taproot: output key is the point at infinity");
41
+ return bigIntTo32(Q.toAffine().x);
42
+ }
43
+
44
+ const VALID_HRP = new Set(["bc", "tb", "bcrt"]);
45
+ function assertHrp(hrp) {
46
+ if (!VALID_HRP.has(hrp)) throw new Error(`unknown network HRP '${hrp}' (expected bc | tb | bcrt)`);
47
+ }
48
+
49
+ /**
50
+ * Derive a P2TR (Taproot) address `bc1p…` / `tb1p…` from an x-only internal key.
51
+ * @param {Uint8Array} internalXOnly 32-byte x-only internal public key
52
+ * @param {"bc"|"tb"|"bcrt"} [hrp="bc"] network prefix
53
+ */
54
+ export function p2trAddress(internalXOnly, hrp = "bc") {
55
+ assertHrp(hrp);
56
+ const outputKey = taprootTweakOutputKey(internalXOnly);
57
+ const addr = segwitEncode(hrp, 1, outputKey);
58
+ if (!addr) throw new Error("failed to encode P2TR address");
59
+ return addr;
60
+ }
61
+
62
+ /**
63
+ * Decode a bech32/bech32m SegWit address into its scriptPubKey bytes, so we can pay it.
64
+ * witver 0 → OP_0 PUSH<20|32> program (P2WPKH / P2WSH)
65
+ * witver 1+ → OP_n PUSH32 program (P2TR at v1)
66
+ * @param {string} addr the address
67
+ * @param {"bc"|"tb"|"bcrt"} [hrp="bc"] expected network
68
+ * @returns {Uint8Array} scriptPubKey
69
+ */
70
+ export function addressToScriptPubKey(addr, hrp = "bc") {
71
+ assertHrp(hrp);
72
+ const dec = segwitDecode(hrp, addr);
73
+ if (!dec) throw new Error(`not a valid ${hrp} SegWit address: ${addr}`);
74
+ const op = dec.version === 0 ? 0x00 : 0x50 + dec.version; // OP_0 or OP_1..OP_16
75
+ const out = new Uint8Array(2 + dec.program.length);
76
+ out[0] = op; out[1] = dec.program.length; out.set(dec.program, 2);
77
+ return out;
78
+ }
79
+
80
+ /** HASH160 = RIPEMD160(SHA256(x)). */
81
+ export function hash160(bytes) {
82
+ return ripemd160(sha256(bytes));
83
+ }
84
+
85
+ /**
86
+ * Derive a native-SegWit P2WPKH address `bc1q…` from a 33-byte compressed pubkey.
87
+ * @param {Uint8Array} compressedPub 33-byte compressed secp256k1 public key
88
+ * @param {"bc"|"tb"|"bcrt"} [hrp="bc"]
89
+ */
90
+ export function p2wpkhAddress(compressedPub, hrp = "bc") {
91
+ assertHrp(hrp);
92
+ // Reject anything but a 33-byte compressed secp256k1 pubkey — a P2WPKH over an
93
+ // uncompressed/malformed key is non-standard and unspendable; returning it as a valid
94
+ // address is a fund-loss foot-gun.
95
+ if (!(compressedPub instanceof Uint8Array) || compressedPub.length !== 33 ||
96
+ (compressedPub[0] !== 0x02 && compressedPub[0] !== 0x03)) {
97
+ throw new Error("P2WPKH requires a 33-byte compressed public key (0x02/0x03 prefix)");
98
+ }
99
+ const addr = segwitEncode(hrp, 0, hash160(compressedPub));
100
+ if (!addr) throw new Error("failed to encode P2WPKH address");
101
+ return addr;
102
+ }