diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1fe15af5..ba10150c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -37,7 +37,12 @@ jobs: - run: python -m ruff format --check . - run: python tools/differential_correction.py --verify - run: python -m build --no-isolation - - run: python tools/verify_wheel_environment.py + - name: Test the built wheel + shell: bash + run: | + python -m pip install --force-reinstall --no-deps dist/codex32-*.whl + python -c "import codex32, pathlib; assert 'site-packages' in pathlib.Path(codex32.__file__).parts" + python -m pytest -q compatibility: runs-on: ubuntu-latest diff --git a/docs/developer/api.md b/docs/developer/api.md index 51c9fb5c..5511db3f 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -664,9 +664,7 @@ secret-root warning. Bitcoin Core 32 or newer and exercises direct wallet restoration, account-0 and timestamp handling, Core-created public descriptors, balance discovery, sign/broadcast behavior on regtest, relocking, and mainnet/test-network root -serialization. `tools/bitcoin_core_main_smoke.py` repeats the descriptor, -account-0, timestamp, and relocking checks against an isolated main-chain Core -instance without connecting to peers. +serialization. ## Deliberate divergences and non-goals diff --git a/docs/security/model.md b/docs/security/model.md index 6eabc340..662d01cd 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -258,5 +258,5 @@ initialization. | Parsing and profiles | [`test_bech32.py`](../../tests/test_bech32.py), [`test_bip93.py`](../../tests/test_bip93.py), and [`test_profiles.py`](../../tests/test_profiles.py) | | Creation, sharing, and recovery | [`test_generation.py`](../../tests/test_generation.py), [`test_sharing.py`](../../tests/test_sharing.py), and the BIP93 vectors under `tests/data/` | | Correction | [`test_correction_bch.py`](../../tests/test_correction_bch.py), [`test_correction_indel.py`](../../tests/test_correction_indel.py), [`correction_capture.py`](../../tools/correction_capture.py), and [`differential_correction.py --verify`](../../tools/differential_correction.py) | -| Bitcoin Core and wallets | [`test_bitcoin_core.py`](../../tests/test_bitcoin_core.py), [`test_wallet.py`](../../tests/test_wallet.py), [`bitcoin_core_regtest.py`](../../tools/bitcoin_core_regtest.py), and [`bitcoin_core_main_smoke.py`](../../tools/bitcoin_core_main_smoke.py) | +| Bitcoin Core and wallets | [`test_bitcoin_core.py`](../../tests/test_bitcoin_core.py), [`test_wallet.py`](../../tests/test_wallet.py), and [`bitcoin_core_regtest.py`](../../tools/bitcoin_core_regtest.py) | | CLI channels and input | [`test_cli.py`](../../tests/test_cli.py) | diff --git a/tools/bitcoin_core_main_smoke.py b/tools/bitcoin_core_main_smoke.py deleted file mode 100644 index 63147209..00000000 --- a/tools/bitcoin_core_main_smoke.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Exercise codex32 wallet integration against an isolated Bitcoin Core 32 main chain.""" - -from __future__ import annotations - -import argparse -import json -import os -import shlex -import shutil -import subprocess -import tempfile -from pathlib import Path -from typing import Any - -from codex32._bitcoin_core import BitcoinCore -from codex32.bip93 import parse_codex32 -from codex32.profiles.ms32 import MasterSeed - -# Frozen public BIP93 vector material; it has never controlled a funded wallet. -_SEED = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" - - -def _run(command: list[str], *, stdin: str | None = None) -> subprocess.CompletedProcess[str]: - return subprocess.run(command, input=stdin, text=True, capture_output=True, check=False) - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--bitcoind", default="bitcoind") - parser.add_argument("--bitcoin-cli", default="bitcoin-cli") - arguments = parser.parse_args() - - with tempfile.TemporaryDirectory(prefix="codex32-core-main-") as temporary: - datadir = Path(temporary) - real_cli = shutil.which(arguments.bitcoin_cli) - if real_cli is None: - raise RuntimeError("bitcoin-cli was not found") - daemon = subprocess.Popen( - [ - arguments.bitcoind, - "-chain=main", - f"-datadir={datadir}", - "-server=1", - "-listen=0", - "-discover=0", - "-dnsseed=0", - "-fixedseeds=0", - "-connect=0", - "-printtoconsole=0", - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True, - ) - base = [real_cli, "-chain=main", f"-datadir={datadir}", "-rpcconnect=127.0.0.1"] - wrapper_directory = datadir / "wrapper" - wrapper_directory.mkdir() - wrapper = wrapper_directory / "bitcoin-cli" - wrapper.write_text( - f'#!/bin/sh\nexec {shlex.quote(real_cli)} {shlex.quote(f"-datadir={datadir}")} "$@"\n' - ) - wrapper.chmod(0o700) - old_path = os.environ.get("PATH", "") - os.environ["PATH"] = str(wrapper_directory) + os.pathsep + old_path - - def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None) -> Any: - command = [*base, "-rpcwait", "-rpcwaittimeout=30"] - if wallet is not None: - command.append(f"-rpcwallet={wallet}") - if stdin is not None: - command.append("-stdin") - result = _run([*command, *rpc_arguments], stdin=stdin) - if result.returncode: - raise RuntimeError(result.stderr.strip()) - output = result.stdout.strip() - if not output: - return None - try: - return json.loads(output) - except json.JSONDecodeError: - return output - - try: - blockchain = rpc("getblockchaininfo") - network = rpc("getnetworkinfo") - if not isinstance(blockchain, dict) or blockchain.get("chain") != "main": - raise RuntimeError("Bitcoin Core did not start on mainnet") - if not isinstance(network, dict) or network.get("version", 0) < 320000: - raise RuntimeError("Bitcoin Core 32 or newer is required") - - rpc( - "-named", - "createwallet", - "wallet_name=signer", - "disable_private_keys=false", - "blank=true", - "descriptors=true", - ) - passphrase = "main-smoke-only-passphrase" - rpc("encryptwallet", wallet="signer", stdin=passphrase + "\n") - unlocked = _run( - [ - *base, - "-rpcwallet=signer", - "-stdinwalletpassphrase", - "walletpassphrase", - "120", - ], - stdin=passphrase + "\n", - ) - if unlocked.returncode: - raise RuntimeError(unlocked.stderr.strip()) - - secret = parse_codex32(_SEED) - if not isinstance(secret, MasterSeed): - raise TypeError("synthetic fixture was not a master seed") - client = BitcoinCore.connect() - expected_fingerprint = client.fingerprint(secret) - answers = iter(("yes",)) - if ( - client.initialize( - secret, - lambda _prompt: next(answers), - lambda _message: None, - expected_fingerprint=expected_fingerprint, - account=0, - timestamp=0, - ) - != "signer" - ): - raise RuntimeError("automatic initialization selected the wrong wallet") - if rpc("getwalletinfo", wallet="signer")["unlocked_until"] != 0: - raise RuntimeError("automatic initialization did not relock the wallet") - _verify_origins(rpc("listdescriptors", wallet="signer"), account=0) - - print( - json.dumps( - { - "bitcoin_core": network["subversion"], - "chain": blockchain["chain"], - "account": 0, - "status": "pass", - } - ) - ) - finally: - os.environ["PATH"] = old_path - _run([*base, "stop"]) - try: - daemon.wait(timeout=30) - except subprocess.TimeoutExpired: - daemon.terminate() - daemon.wait(timeout=10) - - -def _verify_origins(listing: Any, *, account: int) -> None: - if not isinstance(listing, dict): - raise TypeError("Core did not return a descriptor listing") - active = [item for item in listing.get("descriptors", ()) if item.get("active")] - if len(active) != 8 or any("xprv" in item.get("desc", "") for item in active): - raise RuntimeError("Core did not create eight public active descriptors") - for purpose in (44, 49, 84, 86): - if sum(f"/{purpose}h/0h/{account}h]" in item["desc"] for item in active) != 2: - raise RuntimeError(f"Core did not create the expected BIP{purpose} account-{account} origins") - - -if __name__ == "__main__": - main() diff --git a/tools/verify_installed_wheel.py b/tools/verify_installed_wheel.py deleted file mode 100644 index 0a4fbef7..00000000 --- a/tools/verify_installed_wheel.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Smoke-test backup/recovery and root-wallet primitives from an installed wheel.""" - -from __future__ import annotations - -import importlib.util -import sys - -from codex32 import ( - CorrectionContext, - CreationCeremony, - MasterSeed, - Profile, - correct, - derive_share, - master_xprv, - parse_codex32, - recover_secret, -) - -_SECRET = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" -_XPRV = ( - "xprv9s21ZrQH143K3taPNekMd9oV5K6szJ8ND7vVh6fxicRUMDcChr3bFFzuxY8qP3" - "xFFBL6DWc2uEYCfBFZ2nFWbAqKPhtCLRjgv78EZJDEfpL" -) - - -def main() -> None: - assert importlib.util.find_spec("bip32") is None - assert importlib.util.find_spec("coincurve") is None - - ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") - shares = [] - for _ in range(2): - share = ceremony.next_share() - assert ceremony.confirm(share.text).accepted - shares.append(share) - generated = ceremony.finish() - assert parse_codex32(generated.text) == generated - assert recover_secret(shares) == generated - assert derive_share(shares, "d").header.index == "d" - damaged = shares[0].text[:-1] + "?" - assert correct(CorrectionContext(Profile.MS), damaged) - - secret = parse_codex32(_SECRET) - assert isinstance(secret, MasterSeed) - assert master_xprv(secret) == _XPRV - assert "bip32" not in sys.modules - assert "coincurve" not in sys.modules - - -if __name__ == "__main__": - main() diff --git a/tools/verify_wheel_environment.py b/tools/verify_wheel_environment.py deleted file mode 100644 index 689e1ee0..00000000 --- a/tools/verify_wheel_environment.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Install the built wheel without dependencies and run the installed-wheel verifier.""" - -from __future__ import annotations - -import argparse -import os -import subprocess -import tempfile -import venv -from pathlib import Path - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--wheel", type=Path) - arguments = parser.parse_args() - root = Path(__file__).resolve().parents[1] - if arguments.wheel is None: - wheels = tuple((root / "dist").glob("codex32-*.whl")) - if len(wheels) != 1: - raise RuntimeError(f"expected exactly one codex32 wheel, found {len(wheels)}") - wheel = wheels[0] - else: - wheel = arguments.wheel.resolve() - if not wheel.is_file(): - raise FileNotFoundError(wheel) - - with tempfile.TemporaryDirectory(prefix="codex32-wheel-") as temporary: - environment = Path(temporary) - venv.EnvBuilder(with_pip=True).create(environment) - python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - subprocess.run( - [ - str(python), - "-m", - "pip", - "install", - "--disable-pip-version-check", - "--no-deps", - str(wheel), - ], - check=True, - ) - subprocess.run( - [str(python), str(root / "tools" / "verify_installed_wheel.py")], - check=True, - cwd=temporary, - ) - - -if __name__ == "__main__": - main()