diff --git a/docs/security/invariants.md b/docs/security/invariants.md index 42adce52..61340058 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -14,9 +14,9 @@ and evidence. Restore authenticates the recovered seed before any wallet is listed, unlocked, or imported into: normally with the master fingerprint typed from the wallet record, or by an explicit no-record choice made after seeing the - recovered fingerprint and whether the backup identifier was derived from the - seed. Fresh `ms32 create` ceremonies do not authenticate against a - pre-existing wallet; they require the operator to record the new fingerprint. + recovered fingerprint and whether the backup identifier matches this + project's fingerprint-derived default. This check is not authentication. Fresh + `ms32 create` ceremonies require the operator to record the new fingerprint. 5. Correction shares one mass bound and deadline across target lengths. The public API fails closed on incomplete required work; CLI searches may return one primary-best-so-far eligible candidate at the deadline. Incomplete diff --git a/docs/security/model.md b/docs/security/model.md index 6eabc340..8cd9fc23 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -233,8 +233,8 @@ signing setup belong to Bitcoin Core's maintained v32 workflow. | Control | Required behavior | |---|---| | Preflight | Before entropy or recovery input, explicit chain arguments probe the five standard local networks for Bitcoin Core 32 or newer. One response is selected automatically; multiple responses require operator selection. | -| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the backup identifier was derived from the seed (the codex32 fingerprint rule, Bails' RIPEMD-160 rule, or its mid-2023 alpha's SHA-256 rule), and a warning, and then chooses. Fresh `ms32 create` has no pre-existing wallet to authenticate: it shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. These checks catch mistakes such as wrong or mixed cards; anyone able to replace a threshold of cards could already read them. | -| Process boundary | codex32 invokes the reviewed `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | +| Recovery identity | `ms32 wallet` and `ms32 create --existing` authenticate a recovered seed before any wallet is listed. Core derives the recovered fingerprint statelessly, and a mismatch raises `FingerprintMismatch` before any wallet RPC. The restore prompt does not show the recovered value, so the operator compares by typing the fingerprint from the wallet record. Without a record, the operator is shown the recovered fingerprint, whether the identifier matches this project's 20-bit fingerprint-derived default, and a warning, and then chooses. Matching is useful evidence for an unshared default-identifier secret, not wallet authentication; a custom identifier or shared backup may differ without indicating a recovery error. Fresh `ms32 create` shows the newly created seed's fingerprint and requires the operator to acknowledge recording it. Anyone able to replace a threshold of cards could already read them. | +| Process boundary | codex32 invokes `bitcoin-cli` from `PATH` as a child without a shell, direct RPC socket, wallet database, or wallet-creation operation. Every call uses loopback and the selected chain. | | Destination | Only an empty descriptor wallet with private keys enabled, no external signer, transactions, descriptors, keypool entries, or active scan is eligible. One eligible wallet is offered directly; multiple wallets are selected by number. New wallets are detected by polling, and rejection returns to every eligible wallet. The escaped name is confirmed exactly. | | Seed source | The original ceremony result or validated recovered master seed supplies a root xprv for Core's reported chain. Core v32 creates BIP44, BIP49, BIP84, and BIP86 account-0 descriptors from that key. | | Secret channel | The master xprv is sent only through the child's standard input. A timestamped rescan obtains one private descriptor from Core's captured stdout and returns it through stdin; neither value is printed or passed in arguments or diagnostics. codex32 has no passphrase channel and suppresses raw Core errors. | diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index d7d1be90..84a73e1b 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import json import shutil import string @@ -11,7 +10,6 @@ from typing import Literal from codex32._bip32 import _master_xprv_from_seed -from codex32.bech32 import _u5_to_chars, convertbits from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import MasterSeed @@ -55,33 +53,21 @@ def parse_fingerprint(text: str) -> bytes: def identifier_note(origin: str | None) -> str: - # Say what `identifier_origin` found, for an operator restoring without a record. + # A 20-bit default-identifier match is useful evidence, not authentication. if origin is None: return ( - "The backup identifier was not made from this seed. That can be normal for codex32 backups " - "made from split shares, supplied seed bytes or an explicit identifier. Bails made every " - "identifier from its seed, so for a Bails backup these are the wrong or mixed-up cards." + "The backup identifier does not match the fingerprint-derived default. " + "Custom identifiers and shared backups can still be correct; this is not proof of a mismatch." ) return ( - f"The backup identifier matches this seed ({origin} rule). That rules out most mixed-up cards, " - "but not cards replaced on purpose." + "The backup identifier matches the fingerprint-derived default (20 bits). " + "This supports an unshared default-identifier recovery, but does not authenticate the wallet." ) def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: - """Check codex32's fingerprint or Bails' three-character seed-digest identifier.""" - identifier = secret.header.identifier - if identifier == _fingerprint_identifier(fingerprint): - return "codex32" - for name, digest in (("Bails", "ripemd160"), ("Bails alpha", "sha256")): - try: - hashed = hashlib.new(digest, secret.seed_bytes).digest() - except ValueError: - continue - derived = convertbits(hashed, 8, 5, pad=True) - if identifier[:3] == _u5_to_chars(tuple(derived[:3])): - return name - return None + """Recognize only this project's fingerprint-derived default identifier.""" + return "codex32" if secret.header.identifier == _fingerprint_identifier(fingerprint) else None @dataclass(frozen=True) diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 74204a00..5b801890 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -2,7 +2,6 @@ from __future__ import annotations -import hashlib import json import re import subprocess @@ -826,42 +825,22 @@ def unused(_client: BitcoinCore, _secret: MasterSeed) -> bytes: BitcoinCore("bitcoin-cli", "main", 300000).verify_identity(_SEED, None) -# Frozen from Bails' own ms32.seed_identifier for this seed: master (RIPEMD-160) and the -# June 2023 alpha (SHA-256). Bails checked three characters and kept the fourth for re-sharing. -_BAILS_SEED = bytes(range(16)) - - @pytest.mark.parametrize( ("identifier", "origin"), ( (_fingerprint_identifier(_FINGERPRINT), "codex32"), - ("d9k8", "Bails"), - ("d9kq", "Bails"), - ("hezu", "Bails alpha"), + ("d9k8", None), ("test", None), ), ) -def test_identifier_origin_names_the_rule_that_made_it(identifier: str, origin: str | None) -> None: - secret = MasterSeed.from_seed(_BAILS_SEED, identifier=identifier) +def test_identifier_origin_only_recognizes_the_default(identifier: str, origin: str | None) -> None: + secret = MasterSeed.from_seed(bytes(range(16)), identifier=identifier) assert identifier_origin(secret, _FINGERPRINT) == origin - assert ("matches this seed" in identifier_note(origin)) is (origin is not None) - - -def test_identifier_origin_still_checks_alpha_without_ripemd160(monkeypatch: pytest.MonkeyPatch) -> None: - original_new = hashlib.new - - def without_ripemd160(name: str, data: bytes = b"") -> object: - if name == "ripemd160": - raise ValueError("unsupported hash type ripemd160") - return original_new(name, data) - - monkeypatch.setattr(hashlib, "new", without_ripemd160) - secret = MasterSeed.from_seed(_BAILS_SEED, identifier="hezu") - assert identifier_origin(secret, _FINGERPRINT) == "Bails alpha" + assert ("matches the fingerprint-derived default" in identifier_note(origin)) is (origin is not None) def test_identifier_note_allows_supported_nonderived_codex32_identifiers() -> None: note = identifier_note(None) - assert "split shares" in note - assert "supplied seed bytes" in note - assert "explicit identifier" in note + assert "Custom identifiers" in note + assert "shared backups" in note + assert "not proof of a mismatch" in note diff --git a/tests/test_cli.py b/tests/test_cli.py index 57bf50d1..3e95d439 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3010,7 +3010,7 @@ def test_restore_without_a_record_shows_what_the_cards_say_and_asks( assert prompts[1] == "Restore without a wallet record? [y/N]" shown = capsys.readouterr().err assert shown.count(f"Master fingerprint: {fingerprint.hex().upper()}") == 1 - assert "was not made from this seed" in shown and "nothing can prove" in shown + assert "does not match the fingerprint-derived default" in shown and "nothing can prove" in shown prompts = _record_answers(monkeypatch, "", "y") assert _RECORDED_FINGERPRINT(core, secret) is None @@ -3019,7 +3019,7 @@ def test_restore_without_a_record_shows_what_the_cards_say_and_asks( derived = MasterSeed.from_seed(secret.seed_bytes, identifier=_fingerprint_identifier(fingerprint)) _record_answers(monkeypatch, "", "yes") assert _RECORDED_FINGERPRINT(core, derived) is None - assert "matches this seed (codex32 rule)" in capsys.readouterr().err + assert "matches the fingerprint-derived default (20 bits)" in capsys.readouterr().err def test_wallet_restore_hides_fingerprint_until_the_record_gate(monkeypatch: pytest.MonkeyPatch) -> None: