diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9f99e62..165dd45 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -22,9 +22,6 @@ jobs: python-version: ${{ matrix.python-version }} - run: python -m pip install --upgrade pip - run: python -m pip install --require-hashes -r requirements/cli-build-dependencies.txt - - run: >- - python -m pip install --no-build-isolation --require-hashes - -r requirements/test-wallet-dependencies.txt - run: python -m pip install --no-build-isolation -e '.[dev]' - run: python -m pip check - run: python -m pytest -q @@ -32,6 +29,5 @@ jobs: - run: python -m ruff check . - run: python -m ruff format --check . - run: python tools/differential_correction.py --verify - - run: python tools/differential_wallet.py --verify - run: python -m build --no-isolation - run: python tools/verify_wheel_environment.py diff --git a/docs/security/model.md b/docs/security/model.md index 0ce515c..6f96d80 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -248,5 +248,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 [`differential_wallet.py`](../../tools/differential_wallet.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) | | CLI channels and input | [`test_cli.py`](../../tests/test_cli.py) | diff --git a/requirements/test-wallet-dependencies.txt b/requirements/test-wallet-dependencies.txt deleted file mode 100644 index cff377f..0000000 --- a/requirements/test-wallet-dependencies.txt +++ /dev/null @@ -1,30 +0,0 @@ ---require-hashes ---only-binary=coincurve - -# Test-only independent wallet oracle; never installed by the documented CLI path. -# Owner-authored upstream PR #53 changes only coincurve's upper bound and its CI matrix. -bip32 @ https://github.com/darosior/python-bip32/archive/45db547bdf5a5bc19a8c55ef447dbf9169928792.tar.gz \ - --hash=sha256:a25be30641b381eed9a5249bbc4a73124905223c9c50f93140f519bcf259e415 - -# Every published CPython 3.12 and 3.13 wheel for the supported OS families. -coincurve==21.0.0 \ - --hash=sha256:f60ad56113f08e8c540bb89f4f35f44d434311433195ffff22893ccfa335070c \ - --hash=sha256:1cb1cd19fb0be22e68ecb60ad950b41f18b9b02eebeffaac9391dc31f74f08f2 \ - --hash=sha256:05d7e255a697b3475d7ae7640d3bdef3d5bc98ce9ce08dd387f780696606c33b \ - --hash=sha256:5a366c314df7217e3357bb8c7d2cda540b0bce180705f7a0ce2d1d9e28f62ad4 \ - --hash=sha256:1b04778b75339c6e46deb9ae3bcfc2250fbe48d1324153e4310fc4996e135715 \ - --hash=sha256:8efcbdcd50cc219989a2662e6c6552f455efc000a15dd6ab3ebf4f9b187f41a3 \ - --hash=sha256:6df44b4e3b7acdc1453ade52a52e3f8a5b53ecdd5a06bd200f1ec4b4e250f7d9 \ - --hash=sha256:bcc0831f07cb75b91c35c13b1362e7b9dc76c376b27d01ff577bec52005e22a8 \ - --hash=sha256:5dd7b66b83b143f3ad3861a68fc0279167a0bae44fe3931547400b7a200e90b1 \ - --hash=sha256:78dbe439e8cb22389956a4f2f2312813b4bd0531a0b691d4f8e868c7b366555d \ - --hash=sha256:9df5ceb5de603b9caf270629996710cf5ed1d43346887bc3895a11258644b65b \ - --hash=sha256:154467858d23c48f9e5ab380433bc2625027b50617400e2984cc16f5799ab601 \ - --hash=sha256:f57f07c44d14d939bed289cdeaba4acb986bba9f729a796b6a341eab1661eedc \ - --hash=sha256:3fb03e3a388a93d31ed56a442bdec7983ea404490e21e12af76fb1dbf097082a \ - --hash=sha256:d09ba4fd9d26b00b06645fcd768c5ad44832a1fa847ebe8fb44970d3204c3cb7 \ - --hash=sha256:1a1e7ee73bc1b3bcf14c7b0d1f44e6485785d3b53ef7b16173c36d3cefa57f93 \ - --hash=sha256:ad05952b6edc593a874df61f1bc79db99d716ec48ba4302d699e14a419fe6f51 \ - --hash=sha256:4d2bf350ced38b73db9efa1ff8fd16a67a1cb35abb2dda50d89661b531f03fd3 \ - --hash=sha256:54d9500c56d5499375e579c3917472ffcf804c3584dd79052a79974280985c74 \ - --hash=sha256:773917f075ec4b94a7a742637d303a3a082616a115c36568eb6c873a8d950d18 diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a2157e..42304ae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -37,7 +37,7 @@ from codex32.cli import main, ms_main from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import SEED_BYTE_LENGTHS -from tools._wallet_reference import fingerprint_seed +from tools._wallet_test_vectors import stub_fingerprint @dataclass(frozen=True) @@ -98,7 +98,7 @@ class _FakeBitcoinCore: timestamp: int | str | None = None def fingerprint_seed(self, seed: bytes) -> bytes: - return fingerprint_seed(seed) + return stub_fingerprint(seed) def fingerprint(self, secret: MasterSeed) -> bytes: return self.fingerprint_seed(secret.seed_bytes) @@ -1224,7 +1224,7 @@ def test_create_defaults_to_an_unshared_128_bit_master_seed() -> None: secret = artifacts[0] assert isinstance(secret, MasterSeed) and len(secret.seed_bytes) == 16 assert secret.header.threshold == 0 - assert secret.header.identifier == _fingerprint_identifier(fingerprint_seed(secret.seed_bytes)) + assert secret.header.identifier == _fingerprint_identifier(stub_fingerprint(secret.seed_bytes)) def test_fresh_bitcoin_terminal_and_core_preflight_precede_entropy() -> None: @@ -1360,7 +1360,7 @@ def answer(prompt: str) -> str: assert ms_main(["create"]) == 0 artifact = parse_codex32(emitted[0]) assert isinstance(artifact, MasterSeed) - assert artifact.header.identifier == _fingerprint_identifier(fingerprint_seed(artifact.seed_bytes)) + assert artifact.header.identifier == _fingerprint_identifier(stub_fingerprint(artifact.seed_bytes)) def test_fresh_shared_create_confirms_each_card_on_a_terminal( @@ -1549,7 +1549,7 @@ def test_create_accepts_positional_headers_and_preserves_index_order() -> None: shares = _output_artifacts(shared) assert isinstance(fingerprinted_secret, MasterSeed) assert fingerprinted_secret.header.identifier == _fingerprint_identifier( - fingerprint_seed(fingerprinted_secret.seed_bytes) + stub_fingerprint(fingerprinted_secret.seed_bytes) ) assert unshared_secret.header.identifier == "test" assert len(automatic) == 3 diff --git a/tests/test_correction_indel.py b/tests/test_correction_indel.py index 9dcb99a..05d6d97 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -32,7 +32,7 @@ _search_target, ) from codex32.profiles.ms32 import TEXT_LENGTHS -from tools._wallet_reference import fingerprint_seed +from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint from tools.correction_capture import cross_length_classes SOURCE = VECTOR_1["secret_s"] @@ -410,15 +410,15 @@ def test_duplicate_reconstruction_keeps_lower_hamming_path() -> None: def test_cli_tie_breaks_follow_hamming_crc_then_fingerprint() -> None: - seed = bytes(range(16)) - fingerprint = MasterSeed.from_seed(seed, identifier=_fingerprint_identifier(fingerprint_seed(seed))) + seed = FIXTURE_SEED[16] + fingerprint = MasterSeed.from_seed(seed, identifier=_fingerprint_identifier(core_fingerprint(seed))) mismatch = MasterSeed.from_seed(seed, identifier="test") high_hamming = CorrectionCandidate(mismatch, (), 10, 0, 3, True) crc = CorrectionCandidate(mismatch, (), 10, 0, 2, True) fingerprint_match = CorrectionCandidate(fingerprint, (), 10, 0, 2, True) assert len(_primary((high_hamming, crc, fingerprint_match))) == 3 - matcher = _fingerprint_matcher(lambda secret: fingerprint_seed(secret.seed_bytes)) + matcher = _fingerprint_matcher(lambda secret: core_fingerprint(secret.seed_bytes)) assert _best((high_hamming, crc, fingerprint_match), fingerprint_match=matcher) == (fingerprint_match,) diff --git a/tests/test_generation.py b/tests/test_generation.py index 44cd688..18ff9a2 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -6,7 +6,7 @@ import pytest from _codex32_oracle import oracle_encode -from data.bip93_vectors import VECTOR_1, VECTOR_2, VECTOR_3, VECTOR_4, VECTOR_6 +from data.bip93_vectors import VECTOR_2, VECTOR_4, VECTOR_6 from data.sharing_vectors import SHARING_VECTORS from hypothesis import given, settings from hypothesis import strategies as st @@ -33,7 +33,7 @@ ) from codex32.generation import ORDINARY_INDICES, _fingerprint_identifier from codex32.profiles.ms32 import SEED_BYTE_LENGTHS, _has_generation_padding -from tools._wallet_reference import fingerprint_seed +from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint, stub_fingerprint def _complete(ceremony: CreationCeremony) -> tuple[MasterSeed | CoreLightningSecret, tuple[Share, ...]]: @@ -59,13 +59,22 @@ def _seed(byte_length: int) -> bytes: def test_fresh_unshared_ms_supports_every_bip93_size() -> None: for byte_length in SEED_BYTE_LENGTHS: - secret = generate_master_seed(byte_length=byte_length, fingerprint=fingerprint_seed) + secret = generate_master_seed(byte_length=byte_length, fingerprint=stub_fingerprint) assert len(secret.seed_bytes) == byte_length assert secret.header.threshold == 0 - assert secret.header.identifier == _fingerprint_identifier(fingerprint_seed(secret.seed_bytes)) + assert secret.header.identifier == _fingerprint_identifier(stub_fingerprint(secret.seed_bytes)) assert _has_generation_padding(secret) +@pytest.mark.parametrize( + ("byte_length", "expected"), + ((16, "3mga"), (20, "ex5l"), (24, "fhrx"), (28, "5hu7"), (32, "jef7"), (64, "48dx")), +) +def test_unshared_identifier_from_core_fingerprint(byte_length: int, expected: str) -> None: + seed = FIXTURE_SEED[byte_length] + assert _fingerprint_identifier(core_fingerprint(seed)) == expected + + def test_fresh_shared_ms_supports_every_bip93_size() -> None: for byte_length in SEED_BYTE_LENGTHS: secret, shares = _complete( @@ -395,11 +404,3 @@ def test_from_secret_rejects_non_secret_artifacts() -> None: indices="ac", identifier="test", # type: ignore[arg-type] ) - - -@pytest.mark.parametrize( - ("vector", "expected"), - ((VECTOR_1, "8u6j"), (VECTOR_2, "l2mg"), (VECTOR_3, "regv")), -) -def test_unshared_fingerprint_identifier_vectors(vector: dict[str, str], expected: str) -> None: - assert _fingerprint_identifier(fingerprint_seed(bytes.fromhex(vector["secret_hex"]))) == expected diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 341e504..c4dc3c4 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -19,6 +19,7 @@ ) from codex32.cli import main, ms_main from codex32.errors import MismatchedHrp, MismatchedProfile +from tools._wallet_test_vectors import STUB_FINGERPRINT UNKNOWN = { "short": { @@ -167,9 +168,12 @@ def test_cli_split_and_unknown_neutral_summary() -> None: def test_cli_share_is_generic_and_ms32_share_is_scoped(monkeypatch) -> None: - from tools._wallet_reference import ReferenceCore + class _FakeCore: + @staticmethod + def fingerprint(_secret: object) -> bytes: + return STUB_FINGERPRINT - monkeypatch.setattr("codex32.cli.BitcoinCore.connect", lambda *args, **kwargs: ReferenceCore()) + monkeypatch.setattr("codex32.cli.BitcoinCore.connect", lambda *args, **kwargs: _FakeCore()) basis = UNKNOWN["short"]["S"] + "\n" + UNKNOWN["short"]["A"] + "\n" status, output, error = _invoke(main, ["share", "d", "--plain"], basis) assert (status, output.strip(), error) == (0, UNKNOWN["short"]["D"], "") diff --git a/tools/_wallet_reference.py b/tools/_wallet_reference.py deleted file mode 100644 index 796efa8..0000000 --- a/tools/_wallet_reference.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Test-only Core descriptor oracle backed by the independent bip32 package. - -Never use this adapter with real wallet material. Runtime code delegates public -derivation to Bitcoin Core; this oracle keeps offline vectors reproducible. -""" - -import re -from typing import Literal - -from bip32 import BIP32 - -from codex32._bitcoin_core import BitcoinCore -from codex32.profiles.ms32 import MasterSeed -from codex32.wallet import _with_checksum, core_descriptors - - -def fingerprint_seed(seed: bytes) -> bytes: - return BIP32.from_seed(seed).get_fingerprint() - - -def descriptor_info(descriptor: str) -> dict[str, object]: - descriptor = descriptor.strip().split("#", 1)[0] - - def public(match: re.Match[str]) -> str: - root = BIP32.from_xpriv(match.group(1)) - path = match.group(2) - return f"[{root.get_fingerprint().hex()}{path}]{root.get_xpub_from_path('m' + path)}" - - normalized = re.sub(r"([xt]prv[1-9A-HJ-NP-Za-km-z]+)((?:/\d+h)*)", public, descriptor) - return { - "descriptor": _with_checksum(normalized), - "multipath_expansion": [ - _with_checksum(normalized.replace("<0;1>", str(branch))) for branch in (0, 1) - ], - } - - -class ReferenceCore(BitcoinCore): - def __init__(self, *, testnet: bool = False) -> None: - super().__init__("test-only", "test" if testnet else "main", 320000) - - def fingerprint(self, secret: MasterSeed) -> bytes: - return fingerprint_seed(secret.seed_bytes) - - def public_descriptors( - self, - secret: MasterSeed, - *, - wallet: str, - account: int = 0, - timestamp: int | Literal["now"] = 0, - ) -> tuple[dict[str, object], ...]: - del wallet - private = core_descriptors( - secret, - account=account, - testnet=self.chain != "main", - private=True, - timestamp=timestamp, - ) - return tuple( - { - "desc": descriptor_info(str(record["desc"]))["descriptor"], - "active": True, - "timestamp": timestamp, - } - for record in private - ) - - def _rpc(self, *arguments: str, wallet: str | None = None, stdin: str | None = None) -> object: - assert arguments == ("getdescriptorinfo",) and wallet is None and stdin is not None - return descriptor_info(stdin) diff --git a/tools/_wallet_test_vectors.py b/tools/_wallet_test_vectors.py new file mode 100644 index 0000000..aa54914 --- /dev/null +++ b/tools/_wallet_test_vectors.py @@ -0,0 +1,59 @@ +"""Bitcoin Core-derived public wallet fixtures used by tests and integration checks. + +Everything here is a BIP32 master fingerprint, which is a wallet property and not a +codex32 one. Some seeds below also appear as BIP93 test vectors, but only as convenient +public seed material: BIP93 assigns those vectors identifiers of its own (``test``, +``name``, ``cash``) that have nothing to do with these fingerprints. An identifier +derived from a fingerprint is therefore never a BIP93-specified value, so assert such +identifiers against ``FIXTURE_SEED`` rather than against vector material. + +``tools/bitcoin_core_regtest.py`` verifies every fingerprint below against real Core. +""" + +import hashlib + +# BIP93 vector seeds, reused here only as wallet fixtures. +_VECTOR_FINGERPRINTS = { + bytes.fromhex("318c6318c6318c6318c6318c6318c631"): bytes.fromhex("3f3521a6"), + bytes.fromhex("d1808e096b35b209ca12132b264662a5"): bytes.fromhex("fab6868a"), + bytes.fromhex("ffeeddccbbaa99887766554433221100"): bytes.fromhex("1e50c111"), +} + +# Arbitrary fixtures, one per BIP93 seed length, carrying no test-vector meaning. +_FIXTURE_FINGERPRINTS = { + bytes.fromhex("107dea57c4319e0b78e552bf2c990673"): bytes.fromhex("8ed1dab8"), + bytes.fromhex("1481ee5bc835a20f7ce956c3309d0a77e451be2b"): bytes.fromhex("c9a9f9c8"), + bytes.fromhex("1885f25fcc39a61380ed5ac734a10e7be855c22f9c0976e3"): bytes.fromhex("4dc66f0e"), + bytes.fromhex("1c89f663d03daa1784f15ecb38a5127fec59c633a00d7ae754c12e9b"): bytes.fromhex("a5f9e972"), + bytes.fromhex("208dfa67d441ae1b88f562cf3ca91683f05dca37a4117eeb58c5329f0c79e653"): bytes.fromhex( + "9653e4ec" + ), + bytes.fromhex( + "40ad1a87f461ce3ba81582ef5cc936a3107dea57c4319e0b78e552bf2c990673" + "e04dba2794016edb48b5228ffc69d643b01d8af764d13eab1885f25fcc39a613" + ): bytes.fromhex("a9da6294"), +} + +CORE_FINGERPRINTS = _VECTOR_FINGERPRINTS | _FIXTURE_FINGERPRINTS + +# Arbitrary fixture seed by byte length; excludes vector material by construction. +FIXTURE_SEED = {len(seed): seed for seed in _FIXTURE_FINGERPRINTS} + +# Stands in where the fingerprint value itself is irrelevant to the test. +STUB_FINGERPRINT = b"\x00\x00\x00\x01" + + +def core_fingerprint(seed: bytes) -> bytes: + """Return a frozen fingerprint that the real-Core regtest verifies.""" + try: + return CORE_FINGERPRINTS[seed] + except KeyError as error: + raise AssertionError("missing Bitcoin Core fingerprint fixture") from error + + +def stub_fingerprint(seed: bytes) -> bytes: + """Return real fixture data when known, otherwise a seed-sensitive test double.""" + fixture = CORE_FINGERPRINTS.get(seed) + if fixture is not None: + return fixture + return hashlib.sha256(b"codex32 test fingerprint\0" + seed).digest()[:4] diff --git a/tools/bitcoin_core_regtest.py b/tools/bitcoin_core_regtest.py index 4057e0e..d9a70d2 100644 --- a/tools/bitcoin_core_regtest.py +++ b/tools/bitcoin_core_regtest.py @@ -12,6 +12,8 @@ from pathlib import Path from typing import Any +from _wallet_test_vectors import CORE_FINGERPRINTS + from codex32._bitcoin_core import BitcoinCore from codex32.bip93 import parse_codex32 from codex32.profiles.ms32 import MasterSeed @@ -123,6 +125,9 @@ def rpc(*rpc_arguments: str, wallet: str | None = None, stdin: str | None = None if not isinstance(secret, MasterSeed): raise TypeError("synthetic fixture was not a master seed") client = BitcoinCore.connect() + for seed, expected_fingerprint in CORE_FINGERPRINTS.items(): + if client.fingerprint_seed(seed) != expected_fingerprint: + raise RuntimeError("Bitcoin Core fingerprint fixture mismatch") answers = iter(("yes",)) if ( client.initialize( diff --git a/tools/differential_wallet.py b/tools/differential_wallet.py deleted file mode 100644 index f8f8f23..0000000 --- a/tools/differential_wallet.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Emit a deterministic digest of the wallet interoperability boundary.""" - -from __future__ import annotations - -import argparse -import hashlib -import json - -from _wallet_reference import ReferenceCore - -from codex32 import MasterSeed, core_descriptors, master_xprv - -_DOMAIN = b"python-codex32 differential wallet corpus v2" -_SEED_LENGTHS = (16, 20, 24, 28, 32, 64) -_EXPECTED_CASES = 64 -_EXPECTED_DIGEST = "9b3342af401765e4ec73acb3d17142060c1844fc70fda4d87e77f1191f6aec40" - - -def _seed(case: int, length: int) -> bytes: - material = _DOMAIN + case.to_bytes(4, "big") + bytes([length]) - return hashlib.sha512(material).digest()[:length] - - -def _record(case: int, length: int, testnet: bool) -> dict[str, object]: - seed = _seed(case, length) - account = int.from_bytes(hashlib.sha256(seed).digest()[:4], "big") % 2**31 - secret = MasterSeed.from_seed(seed, identifier="test") - core = ReferenceCore(testnet=testnet) - return { - "case": case, - "length": length, - "testnet": testnet, - "account": account, - "xprv": master_xprv(secret, testnet=testnet), - "public": core_descriptors( - secret, - integration=core, - wallet="test-only", - account=account, - testnet=testnet, - timestamp=case, - ), - "private": core_descriptors( - secret, - account=account, - testnet=testnet, - private=True, - timestamp=case, - ), - } - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--cases", type=int, default=_EXPECTED_CASES) - parser.add_argument("--verify", action="store_true") - arguments = parser.parse_args() - if arguments.cases < 1: - raise SystemExit("--cases must be positive") - - digest = hashlib.sha256() - records = 0 - for case in range(arguments.cases): - for length in _SEED_LENGTHS: - for testnet in (False, True): - encoded = json.dumps( - _record(case, length, testnet), - sort_keys=True, - separators=(",", ":"), - ).encode() - digest.update(len(encoded).to_bytes(4, "big")) - digest.update(encoded) - records += 1 - - actual = digest.hexdigest() - print(json.dumps({"records": records, "sha256": actual})) - if arguments.verify: - if arguments.cases != _EXPECTED_CASES: - raise SystemExit(f"--verify requires --cases {_EXPECTED_CASES}") - if not _EXPECTED_DIGEST or actual != _EXPECTED_DIGEST: - raise SystemExit("wallet differential digest mismatch") - - -if __name__ == "__main__": - main()