From 1e6d3da2660ab778e4ee04bfc0e65411657fcf4c Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Sat, 27 Jun 2026 14:28:51 +0200 Subject: [PATCH 1/6] test: simulate a quantum theft and verify an aRsm ECDL-break proof Add a functional test and helper module for Anthony Towns' "aRsm" ECDL-break proof (the quantum tripwire), from the bitcoindev thread https://gnusha.org/pi/bitcoindev/aj9SkwXqdRbuVZxH@erisian.com.au/ A 128-byte proof a || R || s || m is valid for NUMS point N if (R, s) is a valid BIP-340 signature of m under P = N + a*G; a valid signature proves knowledge of dlog(N), which is only possible by breaking ECDL. The test uses a fake NUMS point with a known discrete log so it can simulate a quantum attacker: a victim creates a script-path-only Taproot output with N as its internal key, a thief key-path spends it, and the proof is assembled from the public tweak and the thief's on-chain signature and verified in Python. Co-authored-by: Claude (Opus 4.8) --- test/functional/feature_quantum_proof.py | 129 ++++++++++++++++++++++ test/functional/test_framework/quantum.py | 104 +++++++++++++++++ test/functional/test_runner.py | 1 + 3 files changed, 234 insertions(+) create mode 100755 test/functional/feature_quantum_proof.py create mode 100644 test/functional/test_framework/quantum.py diff --git a/test/functional/feature_quantum_proof.py b/test/functional/feature_quantum_proof.py new file mode 100755 index 000000000000..e416c56edf34 --- /dev/null +++ b/test/functional/feature_quantum_proof.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Simulate a post-quantum theft and build the resulting aRsm ECDL-break proof. + +See test_framework/quantum.py for the cryptographic background. This test plays +out the scenario end to end: + + 1. Pick a *fake* NUMS point N = n*G whose discrete log n we know, standing in + for the real BIP-341 NUMS point (whose discrete log nobody knows). Knowing + n is what lets us simulate a quantum attacker that has "broken ECDL". + 2. A "victim" creates an ordinary script-path-only Taproot output that uses N + as its internal key: scriptPubKey = OP_1

, with P = N + a*G. + 3. A "quantum thief" key-path spends that output, broadcasting a BIP-340 + signature under P -- only possible if you know dlog(P) = dlog(N) + a. + 4. Anyone assembles the proof a || R || s || m from the publicly known tweak a + and the thief's on-chain signature (R, s) over sighash m, and verifies it. +""" +import hashlib + +from test_framework.key import ( + compute_xonly_pubkey, + sign_schnorr, + tweak_add_privkey, +) +from test_framework.messages import ( + COIN, + COutPoint, + CTransaction, + CTxIn, + CTxInWitness, + CTxOut, +) +from test_framework.quantum import ( + FAKE_NUMS_SECKEY, + FAKE_NUMS_XONLY, + NUMS_H, + make_proof, + split_proof, + verify_proof, +) +from test_framework.script import ( + CScript, + OP_CHECKSIG, + taproot_construct, + TaprootSignatureHash, +) +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal +from test_framework.wallet import MiniWallet + + +class QuantumProofTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + self.setup_clean_chain = True + + def simulate_theft(self): + """Carry out the theft on-chain and return the assembled proof.""" + node = self.nodes[0] + + self.log.info("Victim creates a script-path-only Taproot output with NUMS internal key") + # A throwaway script leaf so the output commits to a non-empty merkle + # root; its key is irrelevant because the coin is taken via the key path. + decoy_key, _ = compute_xonly_pubkey(hashlib.sha256(b"victim decoy leaf").digest()) + leaf_script = CScript([decoy_key, OP_CHECKSIG]) + tap = taproot_construct(FAKE_NUMS_XONLY, [("decoy", leaf_script)]) + tweak = tap.tweak # the scalar a = hash_TapTweak(N_x || merkle_root) + + amount = 50 * COIN // 100 # 0.5 BTC + victim_utxo = self.wallet.send_to(from_node=node, scriptPubKey=tap.scriptPubKey, amount=amount) + self.generate(self.wallet, 1) + self.log.info(f"Victim output P = {tap.output_pubkey.hex()} funded in {victim_utxo['txid']}") + + self.log.info("Quantum thief key-path spends the victim output") + thief = MiniWallet(node) + theft = CTransaction() + theft.version = 2 + theft.vin = [CTxIn(COutPoint(int(victim_utxo["txid"], 16), victim_utxo["sent_vout"]))] + theft.vout = [CTxOut(amount - 1000, thief.get_output_script())] + theft.wit.vtxinwit = [CTxInWitness()] + + # The spent output, needed for the BIP-341 sighash. + spent_outputs = [CTxOut(amount, tap.scriptPubKey)] + # m: the message the thief signs (the Taproot key-path sighash). + msg = TaprootSignatureHash(theft, spent_outputs, hash_type=0, input_index=0) + # The thief, having "broken ECDL", derives dlog(P) = dlog(N) + a and signs. + output_seckey = tweak_add_privkey(FAKE_NUMS_SECKEY, tweak) + sig = sign_schnorr(output_seckey, msg) # (R || s) + theft.wit.vtxinwit[0].scriptWitness.stack = [sig] + + # The theft is a valid spend: the network accepts and mines it. + node.sendrawtransaction(theft.serialize().hex()) + self.generate(self.wallet, 1) + assert_equal(node.getmempoolinfo()["size"], 0) + self.log.info(f"Coins stolen in {theft.txid_hex}") + + # a is public (anyone who knows the address construction can recompute + # it); (R, s) is read off the thief's witness; m is the sighash. + return make_proof(tweak, sig, msg) + + def check_proof_crypto(self, proof): + self.log.info("Assemble the aRsm proof from public data and verify it (in Python)") + assert_equal(len(proof), 128) + split_proof(proof) + # The proof verifies against our (fake) NUMS point: it demonstrates + # knowledge of dlog(N), i.e. that ECDL was broken. + assert verify_proof(proof, nums=FAKE_NUMS_XONLY) + # A tampered proof, or one checked against a different NUMS point, fails. + bad = bytearray(proof) + bad[80] ^= 1 + assert not verify_proof(bytes(bad), nums=FAKE_NUMS_XONLY) + assert not verify_proof(proof, nums=NUMS_H) + assert not verify_proof(proof[:-1], nums=FAKE_NUMS_XONLY) + + def run_test(self): + node = self.nodes[0] + self.wallet = MiniWallet(node) + self.generate(self.wallet, 101) # mature a coinbase to fund the victim + + proof = self.simulate_theft() + self.check_proof_crypto(proof) + + self.log.info("Quantum theft simulated and aRsm proof verified") + + +if __name__ == '__main__': + QuantumProofTest(__file__).main() diff --git a/test/functional/test_framework/quantum.py b/test/functional/test_framework/quantum.py new file mode 100644 index 000000000000..390098a0e63b --- /dev/null +++ b/test/functional/test_framework/quantum.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Helpers for the "aRsm" ECDL-break proof (the quantum tripwire). + +Concept (Anthony Towns' variant, bitcoindev thread +https://gnusha.org/pi/bitcoindev/aj9SkwXqdRbuVZxH@erisian.com.au/): + + N the BIP-341 NUMS ("Nothing Up My Sleeve") point, as an x-only + pubkey. Because its x-coordinate falls out of a hash, nobody is + supposed to know its discrete log. + a a scalar; in practice the BIP-341 Taproot tweak + a = hash_TapTweak(N_x || merkle_root) + which the output's owner knows because it is a hash of public + data (the internal key and the script tree). + P = N + a*G the Taproot output key that appears (x-only) in the scriptPubKey. + (R, s) a BIP-340 Schnorr signature on a 32-byte message m, valid under P. + +The 128-byte proof is the concatenation a || R || s || m. It is verified by +recomputing P = N + a*G and checking that (R, s) is a valid BIP-340 signature +of m under P. A valid signature can only be produced by someone who knows +dlog(P) = dlog(N) + a; since a is revealed in the proof, that is equivalent to +knowing dlog(N), which is only possible by breaking the elliptic-curve discrete +log problem (ECDL) on secp256k1. + +In a real deployment the verifier uses the real BIP-341 NUMS point as N (see +NUMS_H below), whose discrete log is unknown. Tests substitute a *fake* NUMS +point whose discrete log is known, so an actual theft can be simulated and the +resulting proof made to verify end to end. +""" +import hashlib + +from .key import ( + H_POINT, + compute_xonly_pubkey, + sign_schnorr, + tweak_add_privkey, + tweak_add_pubkey, + verify_schnorr, +) + +# The real BIP-341 NUMS point (x-only). In production this is the N the verifier +# uses; its discrete log is unknown to anyone. +NUMS_H = bytes.fromhex(H_POINT) + +# A fake NUMS point whose discrete log we know, for tests. It stands in for the +# real NUMS point N so a theft -- and therefore a verifying proof -- can be +# simulated end to end. Tests start the node with -quantumnums=FAKE_NUMS_XONLY. +FAKE_NUMS_SECKEY = hashlib.sha256(b"bitcoin-quantum fake NUMS point").digest() +FAKE_NUMS_XONLY, _ = compute_xonly_pubkey(FAKE_NUMS_SECKEY) +assert FAKE_NUMS_XONLY != NUMS_H + +# Byte layout of a serialized proof: a (32) || R (32) || s (32) || m (32). +PROOF_SIZE = 128 + + +def make_proof(tweak, sig, msg): + """Assemble a 128-byte aRsm proof from its parts. + + tweak: the 32-byte scalar a + sig: the 64-byte BIP-340 signature (R || s) + msg: the 32-byte message m that was signed + """ + assert len(tweak) == 32 + assert len(sig) == 64 + assert len(msg) == 32 + return tweak + sig + msg + + +def split_proof(proof): + """Split a 128-byte aRsm proof into (a, R, s, m).""" + assert len(proof) == PROOF_SIZE + return proof[0:32], proof[32:64], proof[64:96], proof[96:128] + + +def build_proof(nums_seckey, tweak, msg): + """Construct a valid aRsm proof, simulating a quantum attacker. + + Signs `msg` under the output key P = N + tweak*G, where N = nums_seckey*G is + the (fake) NUMS point whose discrete log nums_seckey we know. The result + verifies via verify_proof() against the x-only form of N. + """ + assert len(tweak) == 32 + assert len(msg) == 32 + output_seckey = tweak_add_privkey(nums_seckey, tweak) + sig = sign_schnorr(output_seckey, msg) + return make_proof(tweak, sig, msg) + + +def verify_proof(proof, nums=NUMS_H): + """Return True if `proof` is a valid aRsm ECDL-break proof for NUMS point `nums`. + + This mirrors the verification the node performs: recompute P = N + a*G and + check that (R, s) is a valid BIP-340 signature of m under P. + """ + if len(proof) != PROOF_SIZE: + return False + a, R, s, m = split_proof(proof) + res = tweak_add_pubkey(nums, a) # P = N + a*G, returned x-only + if res is None: + return False + p_xonly, _ = res + return verify_schnorr(p_xonly, R + s, m) diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 5bfc7d86239b..bcf1a8cd461c 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -103,6 +103,7 @@ # vv Tests less than 5m vv 'feature_fee_estimation.py', 'feature_taproot.py', + 'feature_quantum_proof.py', 'feature_block.py', 'mempool_ephemeral_dust.py', 'wallet_conflicts.py', From 69e0100203d4e5cbbf597f1f2994054cbcd93f26 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Sat, 27 Jun 2026 14:29:41 +0200 Subject: [PATCH 2/6] node: verify and store aRsm quantum-tripwire proofs Add an in-memory store for the single "aRsm" ECDL-break proof the node holds, verified against a configurable NUMS point N (default the BIP-341 NUMS point; -test=fakenums selects a point with a known discrete log so a theft can be simulated). Verification computes P = N + a*G via a new XOnlyPubKey::AddTweak helper and checks the BIP-340 signature. Expose submitquantumproof and getquantumproof RPCs, and extend the functional test to submit a proof, reject a bogus one, and confirm only one is held. Co-authored-by: Claude (Opus 4.8) --- src/CMakeLists.txt | 1 + src/common/args.cpp | 1 + src/init.cpp | 10 +++ src/node/quantum.cpp | 103 +++++++++++++++++++++++ src/node/quantum.h | 76 +++++++++++++++++ src/pubkey.cpp | 16 ++++ src/pubkey.h | 6 ++ src/rpc/mining.cpp | 76 +++++++++++++++++ src/test/fuzz/rpc.cpp | 2 + test/functional/feature_quantum_proof.py | 18 +++- 10 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 src/node/quantum.cpp create mode 100644 src/node/quantum.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8c0977f08823..d1448691fdb6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -237,6 +237,7 @@ add_library(bitcoin_node STATIC EXCLUDE_FROM_ALL node/minisketchwrapper.cpp node/peerman_args.cpp node/psbt.cpp + node/quantum.cpp node/timeoffsets.cpp node/transaction.cpp node/txdownloadman_impl.cpp diff --git a/src/common/args.cpp b/src/common/args.cpp index cfd36e1f6a5a..a26c96b1ce0d 100644 --- a/src/common/args.cpp +++ b/src/common/args.cpp @@ -848,6 +848,7 @@ const std::vector TEST_OPTIONS_DOC{ "addrman (use deterministic addrman)", "reindex_after_failure_noninteractive_yes (When asked for a reindex after failure interactively, simulate as-if answered with 'yes')", "bip94 (enforce BIP94 consensus rules)", + "fakenums (use a NUMS point with a known discrete log for the quantum tripwire, so a theft and proof can be simulated)", }; bool HasTestOption(const ArgsManager& args, const std::string& test_option) diff --git a/src/init.cpp b/src/init.cpp index 0e72443cc338..4b39f9d9e758 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -65,6 +65,7 @@ #include #include #include +#include #include #include #include @@ -1461,6 +1462,15 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) LogInfo("Using at most %i automatic connections (%i file descriptors available)", nMaxConnections, available_fds); + // The quantum tripwire normally verifies proofs against the real BIP-341 + // NUMS point (unknown discrete log). On regtest, -test=fakenums switches to a + // NUMS point whose discrete log is known, so a theft -- and a verifying proof + // -- can be simulated end to end. + if (HasTestOption(args, "fakenums")) { + node::GetQuantumProofStore().UseFakeNumsPoint(); + LogInfo("Quantum tripwire using fake NUMS point (known discrete log) for testing"); + } + // Warn about relative -datadir path. if (args.IsArgSet("-datadir") && !args.GetPathArg("-datadir").is_absolute()) { LogWarning("Relative datadir option '%s' specified, which will be interpreted relative to the " diff --git a/src/node/quantum.cpp b/src/node/quantum.cpp new file mode 100644 index 000000000000..94c559f08a45 --- /dev/null +++ b/src/node/quantum.cpp @@ -0,0 +1,103 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include