fractal-pqc
v0.12.0
Published
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 s
Maintainers
Readme
fractal-pqc
A small, runnable, test-vector-verified reference for quantum-safe migration of a Bitcoin-style key. It binds a classical secp256k1 / Taproot key to a post-quantum ML-DSA-65 (NIST FIPS-204) key and requires a post-quantum signature to authorize — the core mechanism of any Bitcoin quantum-readiness transition.
Built from the same crypto-agility discipline FractalAI already runs in production on
its own post-quantum L1 (verify-both-during-migration, no-downgrade), distilled to the
secp256k1 → ML-DSA-65 case relevant to Bitcoin.
Why this exists
A large quantum computer can recover a secp256k1 private key from an exposed public
key (Shor's algorithm), so any Bitcoin output whose pubkey is on-chain becomes
spendable by an attacker. The migration problem: let a holder bind their existing key
to a post-quantum successor key and, from then on, require a PQC signature to spend —
without invalidating already-signed history. This kit is a concrete, honest first step.
What is REAL here (verified, not aspirational)
The security layer (v0.7.0):
transparency.mjs(PQ-ANCHOR-v1 append-only log: inclusion + consistency proofs, ML-DSA-65 signed heads, equivocation detection),primacy.mjs(first-seen proven by complete enumeration + a Bitcoin-anchored temporal frontier),policy.mjs(fail-closed custodian gate),tapscript.mjs(BIP-341 script-path, asserted against the official wallet vectors) andots.mjs(independent OpenTimestamps codec). All are exported:fractal-pqc/primacy,/policy,/transparency,/tapscript,/ots.The limit we have not closed, stated up front: a holder who never registered a dual-signed commitment before the verifier's cutoff height cannot be rescued by any of this.
test/primacy.mjsasserts that out loud rather than leaving it to be discovered.
Everything below is exercised by npm test with real keys — 304 checks pass:
- secp256k1 commitment + spend authorization (
@noble/curves). - Taproot BIP-340 Schnorr sign/verify, asserted against the official BIP-340 test vector — i.e. byte-for-byte consensus-correct with Bitcoin, not an approximation.
- ML-DSA-65 (FIPS-204, Cat-3) signing/verification (
@noble/post-quantum): 1952-byte public key, 4032-byte secret, 3309-byte signature. - Dual-signature migration commitment: classical + PQC both sign the same canonical, domain-separated binding; a verifier reconstructs identical bytes.
- Taproot → PQC recovery commitment (P2QRH-style): binds a 32-byte x-only Taproot output key to an ML-DSA-65 key, dual-signed.
- Real Bitcoin address derivation: bech32/bech32m (official BIP-173/350 vectors),
P2TR
bc1p…/tb1p…via the BIP-341 taproot tweak (official BIP-341 vector →bc1p2wsldez…), and P2WPKHbc1q…. - Real Bitcoin transactions: segwit/legacy serialization + txid (validated against the
Bitcoin genesis coinbase txid), and a BIP-341 key-path sighash that matches the
OFFICIAL
bip-0341/wallet-test-vectors.jsonbyte-for-byte (a permanent test in the suite) — i.e. a key-path SIGHASH_DEFAULT spend signs the Bitcoin-consensus message and will not be sighash-rejected by the network. Key-path signing produces a Schnorr sig that also verifies against the Taproot output key. - Fees + coin selection: exact segwit vsize (1-in/1-out P2TR = 111 vB), accumulative coin selection with change/dust handling and a fee-rate (sat/vB) → conserves value.
- BIP-174 PSBT (P2TR key-path):
createPsbt→signPsbtTaprootKeyPath→finalizePsbtinto a broadcastable tx; serialize↔parse round-trips, base64 carries thepsbt\xffmagic, and the finalized witness signature verifies against the Taproot output key. The signer refuses any input the key doesn't control (custodian-safe). - Testnet broadcaster: fetches your address's UTXOs + the live fee rate (mempool.space
Esplora API), builds+signs the spend, and publishes it — dry-run by default,
--broadcastto send.addressToScriptPubKeylets you pay any SegWit address. - The quantum property under test (correctly modeled):
verifySpendrequires the verifier to pin the holder's anchored commitment (anchoredFactHash). Given that pin, an attacker who recovered the classical key post-Shor — and who can forge a well-formed cert re-binding the victim's classical key to the attacker's OWN PQC key — is still rejected (anchor-mismatch), because the forged cert's factHash differs from the anchored one. Without the anchor,verifySpendfails closed (anchor-required).
Run it:
cd integrations/pqc-migration-kit
npm install # or reuse the workspace's @noble packages
npm testSDK
import {
generateMigrationIdentity, createMigrationCommitment, verifyMigrationCommitment,
authorizeSpend, verifySpend,
generateTaprootKey, taprootSign, taprootVerify, verifiesBip340OfficialVector,
createTaprootPqcCommitment, verifyTaprootPqcCommitment,
} from "fractal-pqc";
const id = generateMigrationIdentity(); // secp256k1 + ML-DSA-65
const cert = createMigrationCommitment(id); // dual-signed, anchorable
verifyMigrationCommitment(cert).valid; // trueCLI
npx fractal-pqc verify-vector # assert the official BIP-340 test vector
npx fractal-pqc selftest # run the full real test suite (304 checks)
npx fractal-pqc keygen > key.json # generate a migration identity (testnet/experimental)
npx fractal-pqc keygen --taproot > tk.json # a Taproot key
npx fractal-pqc address --key tk.json --network tb # derive a real tb1p… Taproot address
npx fractal-pqc commit --key key.json > commit.json
npx fractal-pqc verify commit.json # exit 0 = valid, 1 = invalid/tampered
npx fractal-pqc taproot-commit --key <taproot+pq key.json> # Taproot -> PQC recovery commitmentverify/taproot-verify/verify-vector exit non-zero on failure, so they compose into
CI and custody scripts.
Send a real spend on testnet
npx fractal-pqc keygen --taproot > tk.json # your Taproot key
npx fractal-pqc receive --key tk.json --network tb # → tb1p… funding address + explorer link
# fund that address from a testnet faucet, then:
npx fractal-pqc send-testnet --key tk.json --to <dest tb1…> --amount 20000 --network tb
# ↑ DRY-RUN: fetches your UTXOs + live fee rate, builds+signs, prints hex + txid (does NOT send)
npx fractal-pqc send-testnet --key tk.json --to <dest tb1…> --amount 20000 --network tb --broadcast
# ↑ actually publishes via mempool.space and returns the txid + explorer URLThe SDK equivalent is sendP2trKeyPath({ internalPriv, to, amountSats, network, broadcast }).
Security model — the anchor is load-bearing
The quantum protection does not come from a cert verifying in isolation (a cert only
proves whoever built it held both secret keys). It comes from anchoring: the holder
publishes their commitment first-seen and immutably in an append-only transparency log
(RFC 6962) anchored into Bitcoin (OpenTimestamps), and every verifier pins that anchored
factHash. verifySpend/verifyMigrationCommitment take anchoredFactHash and reject any
cert that doesn't match it. Skip the anchor and the kit provides no quantum protection —
verifySpend fails closed rather than pretend otherwise. The anchor registry itself
(first-seen, no-duplicate, immutable — primacy.mjs + ots.mjs + verify-anchor) is
delivered and published (see the Roadmap below); this reference makes the anchor a
required verifier input so the property is never silently over-claimed. One honest
residual: this package can verify a .ots you already obtained — it has no code to
submit a new digest to an OpenTimestamps calendar server or poll it to Bitcoin
confirmation. Creating a brand-new anchor today requires an external tool (the reference
ots CLI or the python-opentimestamps library); we deliberately don't vendor one, per
ots.mjs's own "no network" scope.
What this is NOT
NEVER SIGN A DIGEST YOU DID NOT COMPUTE YOURSELF. Until 0.9.0
authorizeAndSignread a 32-bytespendDigestchosen by the requester, and round 7 of our own siege used that to have one legitimate client of a custodian obtain a policy-key signature over another client's sighash. The round-7 fix compared the presented scriptPubKey against one recomputed from the requester's fact hash — and round 8 walked through it, because both sides of that comparison were the requester's.An engine that signs a digest it was handed is not a policy engine, it is a signing oracle, and no guard around it helps: every check then compares values the caller controls. So the caller no longer supplies a digest. It supplies the transaction:
authorizeAndSign({ cert, pqSignatureHex, anchorEvidence, expectedLogId, policyKey, cutoffBlockHeight, blockMerkleRoots, spendContext: { tx, inputIndex, prevoutValues, prevoutScriptPubKeys } })
spendContextis mandatory and a request carryingspendDigestis refused at the door. The engine recomputes the Taproot output key from the holder's anchored first-seen fact hash, requires the prevout atinputIndexto be that coin, and then derives the BIP-341 sighash itself. UsebuildSpendContext()— the same helper a custodian uses — so the holder signs exactly the bytes the policy key will sign.THE TWO RESIDUALS, stated rather than hidden. (1) The engine imposes nothing on the outputs: it proves whose coin is spent and who authorised it, never where the value goes. (2)
buildSpendContextbuilds a single-input transaction while the engine accepts multi-input ones, so for those the holder cannot re-derive the digest with the shipped helper and would be signing a digest the custodian computed — the very mistake this section is about. The holder must inspect the transaction, not merely sign the digest they were handed. (honest scope — do not overstate)
- Not yet confirmed in a live mempool. The sighash is proven consensus-correct against the official BIP-341 vector, and both the key-path and script-path (M2) broadcasters are built + dry-run-tested — but an actual funded testnet broadcast (which needs tBTC from a faucet) is the operator's final step for either path.
p2trAddress's signer is key-path-only.taprootTweakPrivateKey/signTaprootKeyPathhandle key-path-only outputs — exactly what this kit'sp2trAddressderives. Pointing the signer at a foreign Taproot output that commits to a script tree would produce an invalid signature. Script-path (tapscript) construction — the policy leaf, control blocks, and the BIP-341 script-path sighash the M2 policy engine signs — IS implemented and asserted against the official BIP-341 wallet test vectors (7/7 cases, 12/12 control blocks, byte for byte); what's missing is the step after the signature, per the point above.- Secrets are not zeroized. Private-key
Uint8Arrays are not wiped after use (best-effort only in JS);keygenprints secrets by design (testnet/experimental). - Not a BIP and not consensus. The "recovery commitment" is an application-layer construction, not a Bitcoin output type. A real quantum-resistant output requires a soft fork (e.g. a P2QRH / BIP-360-style commitment) — out of scope for this reference.
- Not audited. Reference code; the underlying
@noblelibraries are audited, this composition is not.
Roadmap — from reference to real Bitcoin custody tooling (the grant-funded work)
Milestones below match the tranche structure in our funding ask (letter/galaxy-2026-08-31.md)
exactly, so this table and that letter can never quietly drift apart the way this section once
did. Each milestone is independently verifiable, open-source, and shippable on its own.
Bitcoin address + tx layer — ✅ done + vector-verified: bech32/bech32m (BIP-173/350), P2TR
bc1p…derivation (BIP-341), segwit tx + txid (genesis-checked), key-path AND script-path BIP-341 sighashes matching the official vectors byte-for-byte (7/7 scriptPubKey cases, 12/12 control blocks), fees + coin selection, BIP-174 PSBT create→sign→finalize (key-path), and a testnet broadcaster (fetch UTXOs/fees → build+sign → publish; dry-run tested offline, never yet run against a live network) — all permanent tests.M1 — Bitcoin-anchored first-seen registry — ✅ DELIVERED, published, $0 (see the letter).
primacy.mjs+ots.mjs+verify-anchor: an append-only transparency log (RFC 6962), ML-DSA-65-signed heads, first-seen resolution by complete enumeration, and a temporal frontier confirmed against a real Bitcoin block header the verifier supplies — run it yourself, offline, withnpx fractal-pqc verify-anchor. Honest residual: this delivers verification of an anchor; it does not deliver creation of one. Submitting a new digest to an OpenTimestamps calendar and polling it to Bitcoin confirmation needs an external tool today (the referenceotsCLI orpython-opentimestamps) — not vendored here, and not yet documented anywhere outside this README.M2 — Exposure-relative primacy, in a real custodian's signing flow (Tranche 1, $120,000/90 days — see the letter for the full scope). The primitive is shipped and enforced in
[email protected]:policy.mjs'sauthorizeAndSigncomputes the BIP-341 sighash itself, requires the prevout to be the coin the holder's anchor governs, and (opt-in viaexposureHeight) additionally requires the commitment predate the chain first revealing the classical key. What remains, concretely, is engineering a custodian around that primitive — not the primitive itself: (a) ✅ done,m2-broadcast.mjs— rebuilds the witness anauthorizeAndSigndecision actually authorised (never a caller-supplied leaf, which would let someone splice in a different script) and serializes/dry-run-broadcasts it; the Schnorr signature is re-verified independently against a freshly recomputed BIP-342 sighash, not against this module's own bookkeeping; (b) ✅ already done (round 9, before this roadmap section was last reconciled) —buildSpendContexttakesotherInputs, so a custodian consolidating more than one deposit per holder does not recreate the round-8 signing-oracle bug; the holder's independently-derived digest for a real 2-input spend matches the one the engine signs, proven byR9-holder-can-reproduce-any-digest; (c) ✅ partially done —custodian-log.mjsgives structured audit logging (hash-chained, so an edited/reordered/deleted past decision is detectable — including refusals, not just successes) and idempotent request handling (a retried request under the same key never re-signs, proven byR12-custodian-log-is-idempotent-and-tamper-evident), with an explicit design note on why the rawpolicyKeyis never persisted. Not done: an actual HTTP/RPC layer — this is a library a custodian's service calls, not a service itself; (d) ✅ partially done (round 13) —policy-key-registry.mjsenforces thesinglePolicyKeyPerHolderconvention instead of trusting callers to uphold it: a policy key stays bound to whichever classical key first authorised under it, and a different holder is refused before a signature is ever computed, proven byR13-policy-key-registry-refuses-cross-holder-reuseagainst a genuine two-holder attack. Not done: the wrapper is opt-in (a caller can still callauthorizeAndSigndirectly and skip it), and HSM/multisig custody of the raw Schnorr secret itself — losing or leaking that secret is still custodian-wide, not just holder-wide; (e) the funded testnet broadcast itself.A custodian using (c) and (d) together should compose them with the audit log OUTERMOST and the registry INNERMOST —
custodianAuthorize(log, idempotencyKey, req, ts, (r) => registry.authorize(r))— round 15 found that the other order (registry outermost) silently drops the audit trail for a refused cross-holder attempt, which is exactly the attempt most worth recording. Proven byR15-log-and-registry-compose-without-losing-either-guaranteeandtest/composed-custody.mjs, the only place either wrapper is exercised alongside the other rather than in isolation.Tranche 2, $80,000 — independent external review, multisig PSBT, key rotation. External review: scope not yet defined here, tracked separately from the two engineering items. Multisig PSBT: 0% built.
psbt.mjsis single-signer P2TR key-path only; no BIP-174 multisig fields, no k-of-N Tapscript threshold primitive, no multi-leafpolicyLeafScript— not even listed in this package's ownUNCLAIMED_GUARDShonesty ledger yet. Key rotation: confirmed NOT IMPLEMENTED (npx fractal-pqc claims --gapssays so verbatim) — a holder who loses their ML-DSA-65 secret is locked out permanently, by the same first-seen-primacy logic that makes the rest of the design sound. A real fix needs a pre-registered guardian quorum, a distinct anchored entry type for a rotation event, a challenge window measured in confirmed blocks, and a new "current active key" resolver inpolicy.mjs— comparable in scope to the original primacy/policy engine.Tranche 3, $120,000 — public specification, wallet interoperability, long-term maintenance of the vectors. Not started; success is measured by independent adopters running the CC0 vectors, not by our own claims.
License
MIT. Uses @noble/curves, @noble/hashes, @noble/post-quantum (audited, MIT).
