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 +21 -0
- package/README.md +163 -0
- package/bin/cli.mjs +220 -0
- package/package.json +51 -0
- package/src/address.mjs +102 -0
- package/src/bech32.mjs +123 -0
- package/src/bitcoin.mjs +134 -0
- package/src/broadcast.mjs +117 -0
- package/src/fees.mjs +84 -0
- package/src/index.mjs +59 -0
- package/src/migration-envelope.mjs +206 -0
- package/src/psbt.mjs +149 -0
- package/src/tx.mjs +152 -0
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
|
+
}
|
package/src/address.mjs
ADDED
|
@@ -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
|
+
}
|