From bbcfb56176bb27e5d762daa0f69b48c6f9e5d9dd Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 21 Sep 2026 02:03:33 -0500 Subject: [PATCH 01/23] Validate Bitcoin Core state types Require exact nonnegative integer types for wallet counts and unlock state, including final relock verification. This prevents booleans and malformed RPC values from passing numeric equality checks. Security: fail closed on untrusted Bitcoin Core state while preserving valid encrypted and unencrypted wallet flows. Validation: python -m pytest -q; python -O -m pytest -q; Ruff check and format; strict mypy; differential_wallet.py --verify. --- src/codex32/_bitcoin_core.py | 21 +++++++++---- tests/test_bitcoin_core.py | 59 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 51712e46..4fc85e51 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -304,19 +304,27 @@ def _target(self, name: str) -> tuple[bool, bool] | None: listing, info = self._rpc("listdescriptors", wallet=name), self._rpc("getwalletinfo", wallet=name) if not isinstance(info, dict) or not isinstance(listing, dict): raise BitcoinCoreError("Unexpected Bitcoin Core wallet information.") + txcount = info.get("txcount") + keypoolsize = info.get("keypoolsize") + internal_keypool = info.get("keypoolsize_hd_internal", 0) + has_unlock_state = "unlocked_until" in info + unlocked = info.get("unlocked_until") eligible = ( info.get("descriptors") is True and info.get("private_keys_enabled") is True and info.get("external_signer", False) is False - and info.get("txcount") == 0 - and info.get("keypoolsize") == 0 - and info.get("keypoolsize_hd_internal", 0) == 0 + and type(txcount) is int + and txcount == 0 + and type(keypoolsize) is int + and keypoolsize == 0 + and type(internal_keypool) is int + and internal_keypool == 0 and info.get("scanning") is False and listing.get("descriptors") == [] and name.isprintable() + and (not has_unlock_state or type(unlocked) is int and unlocked >= 0) ) - unlocked = info.get("unlocked_until") - return (unlocked is not None, unlocked == 0) if eligible else None + return (has_unlock_state, unlocked == 0) if eligible else None def _create_account_zero(self, secret: MasterSeed, wallet: str) -> None: root = _master_xprv_from_seed(secret.seed_bytes, testnet=self.chain != "main") @@ -510,7 +518,8 @@ def initialize( continue except BitcoinCoreError as error: raise BitcoinCoreError(warning) from error - if not isinstance(info, dict) or info.get("unlocked_until") != 0: + unlocked_until = info.get("unlocked_until") if isinstance(info, dict) else None + if type(unlocked_until) is not int or unlocked_until != 0: raise BitcoinCoreError(warning) relock = False if waited: diff --git a/tests/test_bitcoin_core.py b/tests/test_bitcoin_core.py index 9737203b..3eaf3228 100644 --- a/tests/test_bitcoin_core.py +++ b/tests/test_bitcoin_core.py @@ -214,6 +214,11 @@ def test_candidate_filter_rejects_every_unsafe_wallet_property( "transactions": (_empty_info(txcount=1), []), "keys": (_empty_info(keypoolsize=1), []), "change": (_empty_info(keypoolsize_hd_internal=1), []), + "boolean transactions": (_empty_info(txcount=False), []), + "boolean keys": (_empty_info(keypoolsize=False), []), + "boolean change": (_empty_info(keypoolsize_hd_internal=False), []), + "invalid unlock": (_empty_info(unlocked_until="unlocked"), []), + "null unlock": (_empty_info(unlocked_until=None), []), "scanning": (_empty_info(scanning={"duration": 1}), []), "descriptors": (_empty_info(), [{"desc": "public"}]), "bad\x1bname": (_empty_info(), []), @@ -721,6 +726,60 @@ def interrupt_once( assert rpc.locked and lock_calls == 2 +def test_walletlock_failure_requires_manual_lock_confirmation(monkeypatch: pytest.MonkeyPatch) -> None: + rpc = _ImportRPC(locked=False) + original = rpc.__call__ + + def fail_lock( + client: BitcoinCore, *arguments: str, wallet: str | None = None, stdin: str | None = None + ) -> object: + if arguments == ("walletlock",): + raise BitcoinCoreError("suppressed lock failure") + return original(client, *arguments, wallet=wallet, stdin=stdin) + + monkeypatch.setattr(BitcoinCore, "_rpc", fail_lock) + with pytest.raises( + BitcoinCoreError, match="Confirm immediately in Bitcoin Core that the wallet is locked" + ): + BitcoinCore("bitcoin-cli", "main", 300000).initialize( + _SEED, + lambda _prompt: "yes", + lambda _message: None, + expected_fingerprint=_FINGERPRINT, + ) + + +@pytest.mark.parametrize("unlocked_until", (100, False, "locked")) +def test_failed_lock_verification_requires_manual_confirmation( + monkeypatch: pytest.MonkeyPatch, unlocked_until: object +) -> None: + rpc = _ImportRPC(locked=False) + original = rpc.__call__ + lock_requested = False + + def remain_unlocked( + client: BitcoinCore, *arguments: str, wallet: str | None = None, stdin: str | None = None + ) -> object: + nonlocal lock_requested + if arguments == ("walletlock",): + lock_requested = True + return None + if lock_requested and arguments == ("getwalletinfo",): + return _empty_info(unlocked_until=unlocked_until) + return original(client, *arguments, wallet=wallet, stdin=stdin) + + monkeypatch.setattr(BitcoinCore, "_rpc", remain_unlocked) + with pytest.raises( + BitcoinCoreError, match="Confirm immediately in Bitcoin Core that the wallet is locked" + ): + BitcoinCore("bitcoin-cli", "main", 300000).initialize( + _SEED, + lambda _prompt: "yes", + lambda _message: None, + expected_fingerprint=_FINGERPRINT, + ) + + def test_unencrypted_wallet_imports_without_a_lock_call(monkeypatch: pytest.MonkeyPatch) -> None: rpc = _ImportRPC(encrypted=False, locked=False) monkeypatch.setattr( From 7ff08a6ab1386d2bc7c87b68dcbb8b65b28746d2 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 21 Sep 2026 02:03:33 -0500 Subject: [PATCH 02/23] Reject non-ASCII normalized input Reject non-ASCII identifiers, indices, and raw CLI creation headers before lowercasing so Unicode lookalikes cannot normalize into valid Bech32 symbols. Make whole-card confirmation fold ASCII case only. Non-ASCII lookalikes now stay visibly mismatched and enter the normal retry flow for shared, unshared, and derived cards instead of being accepted or reaching the ceremony callback as an apparent match. Document the ASCII-only confirmation boundary in the API and security model. Validation: 867 tests passed normally and under python -O; Ruff check and format passed; strict mypy passed; git diff --check passed. --- docs/developer/api.md | 5 +++-- docs/security/model.md | 2 +- src/codex32/_cli_input.py | 17 ++++++++++++----- src/codex32/bip93.py | 6 ++++++ src/codex32/cli.py | 9 ++++++--- src/codex32/generation.py | 8 +++++++- tests/test_cli.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_generation.py | 15 +++++++++++++++ tests/test_public_api.py | 2 ++ tests/test_sharing.py | 2 +- 10 files changed, 88 insertions(+), 13 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 9dbdc32a..328597d5 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -77,8 +77,9 @@ generic parse-length failure. - `_cli_input.py` retains at most nine artifacts and delegates partial-set compatibility to `bip93.py`. Card confirmation clears the terminal and saved scrollback where supported, then displays only entered text after a mismatch. - Canonical text removes whitespace for comparison; presentation state retains - entered spacing and case. Grouped alignment preserves entered ownership; + Canonical text removes whitespace and folds ASCII case for comparison; + non-ASCII lookalikes remain mismatches. Presentation state retains entered + spacing and case. Grouped alignment preserves entered ownership; unspaced alignment minimizes character edits before disturbed groups. Codex32 entry uses a separate `> ` line; correction candidates use ordinary card formatting without a prompt marker; fixed prefixes follow that marker. The `xprv` and wallet recovery paths begin diff --git a/docs/security/model.md b/docs/security/model.md index e025fb38..2c75ca3c 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -126,7 +126,7 @@ available through `codex32`. Creation retries show only entered text in contiguous regions: bold red means review the card, with reverse video added for the active region. Original card formatting is display-only; editable prefills retain entered case and spacing. Complete matching canonical groups freeze; local alignment preserves entered group ownership before edit minimization and proceeds without crossing frozen boundaries (see the API alignment rules). -Empty retries fail; retries are unlimited. Correct full-string retries confirm; incorrect recognizable full-string retries preserve progress and clarify the active region. Only complete case/whitespace-normalized equality confirms, with no expected characters, error classifications, prescribed edits, or repairs. +Empty retries fail; retries are unlimited. Correct full-string retries confirm; incorrect recognizable full-string retries preserve progress and clarify the active region. Only complete ASCII-case/whitespace-normalized equality confirms; non-ASCII lookalikes remain mismatches and must be re-entered. No expected characters, error classifications, prescribed edits, or repairs are supplied. Progressive group-level correctness feedback is explicitly accepted and does not change the confirmation boundary. Confirmation shows that the operator can produce the correct recovery string during setup. It cannot prove that the physical backup was corrected rather than reconstructed using confirmation feedback. diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index eb13b4f5..6090352b 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -66,6 +66,11 @@ class InteractiveConfirmationRequired(Exception): pass +def _ascii_lower(value: str) -> str: + """Lowercase ASCII letters without normalizing Unicode lookalikes.""" + return "".join(character.lower() if character.isascii() else character for character in value) + + def _confirmation_input(prompt: str) -> str: if sys.stdin.isatty(): return _editable_input(prompt) @@ -239,13 +244,13 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: compact = "".join(observed.split()) expected = "".join(expected.split()).lower() tokens = observed.split() - grouped = len(tokens) > 1 and compact.lower() != expected + grouped = len(tokens) > 1 and _ascii_lower(compact) != expected boundaries = {0} splits: set[tuple[int, int]] = set() position = 0 for token in tokens: for offset in range(0, len(expected), 4): - if expected[offset : offset + len(token)] == token.lower() and ( + if expected[offset : offset + len(token)] == _ascii_lower(token) and ( len(token) % 4 == 0 or offset + len(token) == len(expected) ): splits.update((position + i, offset + i) for i in range(4, len(token), 4)) @@ -261,7 +266,7 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: if end > start: current = [(end - start, b"\x02" * (end - start))] for j, char in enumerate(canonical, 1): - edit = compact[end - 1].lower() != char + edit = _ascii_lower(compact[end - 1]) != char current.append( min( (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), @@ -274,7 +279,7 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: continue score = ( edits + row[-1][0], - disturbed + (not grouped and compact[start:end].lower() != canonical), + disturbed + (not grouped and _ascii_lower(compact[start:end]) != canonical), trace + row[-1][1], (*ends, end), ) @@ -288,7 +293,9 @@ def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: groups.append(observed[start:stop]) start = stop changed = { - i for i, value in enumerate(groups) if "".join(value.split()).lower() != expected[i * 4 : i * 4 + 4] + i + for i, value in enumerate(groups) + if _ascii_lower("".join(value.split())) != expected[i * 4 : i * 4 + 4] } return groups, changed diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 9e3757c3..05924340 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -55,11 +55,15 @@ def __post_init__(self) -> None: raise InvalidThreshold("threshold must be 0 or an integer from 2 through 9") if not isinstance(self.identifier, str): raise InvalidIdentifier("identifier must be str") + if not self.identifier.isascii(): + raise InvalidIdentifier("identifier must contain only ASCII Bech32 symbols") identifier = self.identifier.lower() if len(identifier) != 4 or any(character not in CHARSET for character in identifier): raise InvalidIdentifier("identifier must be exactly four Bech32 symbols") if not isinstance(self.index, str): raise InvalidShareIndex("share index must be str") + if not self.index.isascii(): + raise InvalidShareIndex("share index must be an ASCII Bech32 symbol") index = self.index.lower() if len(index) != 1 or index not in CHARSET: raise InvalidShareIndex("share index must be one Bech32 symbol") @@ -352,6 +356,8 @@ def recover_secret(shares: Sequence[Share]) -> Secret: def _normalize_target(value: object, *, label: str) -> str: if not isinstance(value, str): raise InvalidTargetIndex(f"{label} must be one Bech32 symbol") + if not value.isascii(): + raise InvalidTargetIndex(f"{label} must be an ASCII Bech32 symbol") normalized = value.lower() if len(normalized) != 1 or normalized not in CHARSET or normalized == "s": raise InvalidTargetIndex(f"{label} must be one of {IDX_SORT[1:].upper()}") diff --git a/src/codex32/cli.py b/src/codex32/cli.py index dc6d3be1..68ca4f12 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -20,6 +20,7 @@ from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, + _ascii_lower, _card_text, _case_interpretation, _confirm_correction, @@ -203,6 +204,8 @@ def _share_command(index: str, plain: bool, context: _CliContext, core: BitcoinC def _creation_header(value: str | None) -> tuple[Profile, int | None, str | None]: if value is None: return Profile.MS, None, None + if not value.isascii(): + raise _UsageError("The set header must contain only ASCII characters.") lowered = value.lower() if lowered != value and value.upper() != value: raise _UsageError("The set header must use either uppercase or lowercase.") @@ -310,10 +313,10 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul prefill=prefill, ) compact = "".join(replacement.split()) - if compact.lower() == expected.lower(): + if _ascii_lower(compact) == expected.lower(): groups = [replacement] break - if len(compact) > len(expected[start * 4 : end * 4]) and compact.lower().startswith( + if len(compact) > len(expected[start * 4 : end * 4]) and _ascii_lower(compact).startswith( (expected.split("1", 1)[0] + "1").lower() ): _print( @@ -327,7 +330,7 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul changed.difference_update(range(start, end)) changed.update(start + i for i in remaining) entered = "".join(groups) - if "".join(entered.split()).lower() != expected.lower(): + if _ascii_lower("".join(entered.split())) != expected.lower(): raise RuntimeError("Confirmation mismatch.") if confirm is not None and not confirm(entered).accepted: raise RuntimeError("Confirmation rejected.") diff --git a/src/codex32/generation.py b/src/codex32/generation.py index f135d191..4cd94694 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -69,6 +69,8 @@ def _threshold(value: object, *, allow_zero: bool = True) -> int: def _identifier(value: object) -> str: if not isinstance(value, str): raise InvalidIdentifier("identifier must be str") + if not value.isascii(): + raise InvalidIdentifier("identifier must contain only ASCII Bech32 symbols") value = value.lower() if len(value) != 4 or any(character not in CHARSET for character in value): raise InvalidIdentifier("identifier must be four Bech32 symbols") @@ -78,6 +80,8 @@ def _identifier(value: object) -> str: def _index(value: object) -> str: if not isinstance(value, str) or len(value) != 1: raise InvalidShareSelection("each output index must be one Bech32 symbol") + if not value.isascii(): + raise InvalidShareSelection("each output index must be an ASCII Bech32 symbol") value = value.lower() if value not in ORDINARY_INDICES: raise InvalidShareSelection("output indices must be ordinary non-S symbols") @@ -355,7 +359,9 @@ def confirm(self, text: str) -> ConfirmationResult: raise CeremonyStateError("request a card before confirming it") if not isinstance(text, str): raise TypeError("confirmation text must be str") - observed = "".join(text.split()).lower() + observed = "".join(text.split()) + if observed.isascii(): + observed = observed.lower() expected = self._pending.text.lower() mismatched = tuple( group + 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index b1ba660a..7ae0dc42 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1467,6 +1467,34 @@ def answer(prompt: str, **options: object) -> str: assert "\x1b[3J\x1b[2J\x1b[H" in prompts[1][0] +def test_creation_confirmation_retries_unicode_aliases_before_accepting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cli_module = importlib.import_module("codex32.cli") + + class Card: + text = "MS10TESK" + + answers = iter(("", "MS10TESK", "TESK")) + prefills: list[str] = [] + confirmed: list[str] = [] + + def answer(_prompt: str, **options: object) -> str: + if "prefill" in options: + prefills.append(str(options["prefill"])) + return next(answers) + + def confirm(value: str) -> ConfirmationResult: + confirmed.append(value) + return ConfirmationResult(True) + + monkeypatch.setattr(cli_module, "_text", answer) + cli_module._confirm_card(Card(), confirm) + + assert prefills == ["TESK"] + assert confirmed == [Card.text] + + @pytest.mark.parametrize( ("observed", "shown", "red"), ( @@ -1596,6 +1624,13 @@ def test_create_accepts_positional_headers_and_preserves_index_order() -> None: assert recover_secret(basis).header.identifier == "cash" +def test_create_rejects_unicode_header_aliases_before_normalizing() -> None: + result = _invoke(["create", "MS12TESK"]) + + assert result.exit_code == 2 + assert "ASCII" in result.stderr + + @pytest.mark.parametrize(("threshold", "count"), ((2, 3), (3, 5))) def test_create_has_reviewed_share_count_presets(threshold: int, count: int) -> None: result = _invoke_confirmed_create(["create", f"{threshold}test"]) diff --git a/tests/test_generation.py b/tests/test_generation.py index 878e795b..f54dc425 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -147,6 +147,20 @@ def test_raw_bytes_accept_random_or_explicit_identifiers() -> None: secret = generate_master_seed(raw, identifier="TEST") assert len(random_secret.header.identifier) == 4 assert secret.header.identifier == "test" + with pytest.raises(InvalidIdentifier): + generate_master_seed(raw, identifier="tesK") + + +def test_confirmation_rejects_unicode_that_lowercases_to_bech32( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = bytes([generation_module.CHARSET.index("k")]) * 26 + monkeypatch.setattr(generation_module.secrets, "token_bytes", lambda _length: value) + ceremony = CreationCeremony.master_seed(threshold=2, indices="ac", identifier="test") + pending = ceremony.next_share() + invalid = pending.text.replace("k", "K", 1) + assert invalid != pending.text + assert not ceremony.confirm(invalid).accepted def test_supplied_seed_must_form_a_valid_bip32_root(monkeypatch: pytest.MonkeyPatch) -> None: @@ -178,6 +192,7 @@ def test_explicit_and_random_output_order_contracts() -> None: {"threshold": 2, "share_count": 32}, {"threshold": 2, "indices": "a"}, {"threshold": 2, "indices": "aa"}, + {"threshold": 2, "indices": "aK"}, {"threshold": 2, "indices": "sa"}, {"threshold": 2, "indices": "ia"}, ), diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 91fcd892..6bf14a1b 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -122,7 +122,9 @@ def test_master_seed_factory_can_only_construct_index_s() -> None: ((1, "test", "s"), InvalidThreshold), ((2.0, "test", "a"), InvalidThreshold), ((2, "bad", "a"), InvalidIdentifier), + ((2, "tesK", "a"), InvalidIdentifier), ((0, "test", "a"), InvalidShareIndex), + ((2, "test", "K"), InvalidShareIndex), ), ) def test_header_invariants(arguments: tuple[object, ...], error: type[Exception]) -> None: diff --git a/tests/test_sharing.py b/tests/test_sharing.py index 6d54c9ba..567342bc 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -153,7 +153,7 @@ def __getitem__(self, _position: int) -> Share: recover_secret(OversizedSequence()) # type: ignore[arg-type] -@pytest.mark.parametrize("target", ("s", "i", "b", "?", "aa", "", 3)) +@pytest.mark.parametrize("target", ("s", "i", "b", "?", "aa", "", "K", 3)) def test_invalid_targets_are_rejected(target: object) -> None: secret, masks = _ms_basis() with pytest.raises(InvalidTargetIndex): From 4f0156170801e3ed0cc5757270c74964af0917d1 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 23 Sep 2026 11:03:14 -0500 Subject: [PATCH 03/23] generation: Fold ASCII case per character --- src/codex32/generation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 4cd94694..6da02033 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -360,8 +360,7 @@ def confirm(self, text: str) -> ConfirmationResult: if not isinstance(text, str): raise TypeError("confirmation text must be str") observed = "".join(text.split()) - if observed.isascii(): - observed = observed.lower() + observed = "".join(char.lower() if char.isascii() else char for char in observed) expected = self._pending.text.lower() mismatched = tuple( group + 1 From 9f42ecea6e4d644e1031209f6ac7126ab7be9c9c Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 23 Sep 2026 07:12:22 -0500 Subject: [PATCH 04/23] bip93: Enforce the BIP173 HRP limit Reject human-readable parts longer than 83 characters at the generic Bech32 decoding boundary and in correction contexts. Keep checksum-period coverage at the checksum layer so it does not depend on invalid Bech32 HRPs. Fixes #32. --- src/codex32/bech32.py | 2 ++ src/codex32/correction.py | 2 ++ tests/data/bip93_vectors.py | 5 +++-- tests/test_correction_bch.py | 1 + tests/test_generic_hrp.py | 17 ++++++++++++++++- tests/test_profiles.py | 11 +++++++++++ 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/codex32/bech32.py b/src/codex32/bech32.py index 59befc70..3cb14a89 100644 --- a/src/codex32/bech32.py +++ b/src/codex32/bech32.py @@ -88,6 +88,8 @@ def bech32_decode(value: str, spec: _Checksum | None = None) -> tuple[str, list[ raise MissingSeparator("No separator (1) was found.") if separator == 0: raise MissingSeparator("The application prefix before 1 is missing.") + if separator > 83: + raise InvalidLength(f"human-readable part exceeds 83 characters ({separator})") lowered = value.lower() hrp = lowered[:separator] data = _chars_to_u5(lowered[separator + 1 :], separator + 2) diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 9abd345b..63fe3484 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -768,6 +768,8 @@ def _validate_context(context: CorrectionContext) -> None: _validate_single_case_ascii(context.hrp) if context.hrp.lower() != context.hrp: raise ValueError("hrp must be a normalized application prefix") + if len(context.hrp) > 83: + raise ValueError(f"human-readable part exceeds 83 characters ({len(context.hrp)})") length = context.expected_length if length is not None: if isinstance(length, bool) or not isinstance(length, int): diff --git a/tests/data/bip93_vectors.py b/tests/data/bip93_vectors.py index d27d9b06..c944a424 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -93,7 +93,6 @@ VALID_CODEX32_LONG = [ "A12UEL5LQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQV3RR8ZLCK96GTC3", "a12uel5lqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqv3rr8zlck96gtc3", - "a1002characterlonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~15ttgtscr3gvktxamm8mzt", "abcdef12l7aum6echk45nj3s0wdvt2fg8x9yrzpql7aum6echk45nj3s0wdvt2fg8x9yrzpql7aum6echk45nj3s0wdvt2fg8x9yrzpqp9evrmhc52umqew", "1177777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777fn0jxg9gc35xwa8", "split13checkupstagehandshakeupstreamerranterredcaperredscatteredsusurrantplunderedqsp5ws8r2klm66l", @@ -117,7 +116,9 @@ + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx836hdd09mhkhkhx", # HRP character out of range "\x80" + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxlhf5ywnkmk4r3tc", # HRP character out of range - # overall max length exceeded + # HRP exceeds 83 characters + "a1002characterlonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~15ttgtscr3gvktxamm8mzt", + # HRP exceeds 83 characters (also exceeds the overall max length) "a1003characterslonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~12fauxxru38cppmlpu0t6l", "y12bfauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxt3y5fewy4gnw2hs", # Invalid data character "lt12ifauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxjxd0ehq868vm3zl", # Invalid data character diff --git a/tests/test_correction_bch.py b/tests/test_correction_bch.py index a8edc801..cd099616 100644 --- a/tests/test_correction_bch.py +++ b/tests/test_correction_bch.py @@ -217,6 +217,7 @@ def test_public_context_constrains_length_prefix_and_used_indices() -> None: CorrectionContext(Profile.MS, excluded_indices=["a"]), # type: ignore[arg-type] CorrectionContext(Profile.MS, excluded_indices=("s",)), CorrectionContext(Profile.MS, excluded_indices=("a", "A")), + CorrectionContext("z" * 84, expected_length=106), ), ) def test_malformed_public_context_is_rejected(context: CorrectionContext) -> None: diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 9111a7d1..9a5b913d 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -18,7 +18,7 @@ recover_secret, ) from codex32.cli import main, ms_main -from codex32.errors import MismatchedHrp, MismatchedProfile +from codex32.errors import InvalidLength, MismatchedHrp, MismatchedProfile from tools._wallet_test_vectors import STUB_FINGERPRINT UNKNOWN = { @@ -178,3 +178,18 @@ def fingerprint(_secret: object) -> bytes: status, output, error = _invoke(main, ["share", "d", "--plain"], basis) assert (status, output.strip(), error) == (0, UNKNOWN["short"]["D"], "") assert _invoke(ms_main, ["share", "d", "--plain"], basis)[0] == 2 + + +def test_83_character_hrp_may_span_printable_ascii() -> None: + pool = [chr(code) for code in range(33, 127) if not chr(code).isupper()] + hrp = "".join(pool[index % len(pool)] for index in range(83)) + assert "1" in hrp # the separator is the last "1", so an HRP may contain one + assert parse_codex32(oracle_encode(hrp, "0testsq")).hrp == hrp + + +def test_hrp_over_83_characters_is_rejected_at_the_bip173_limit() -> None: + within_limit = oracle_encode("z" * 83, "0testsq") + over_limit = oracle_encode("z" * 84, "0testsq") + assert parse_codex32(within_limit).hrp == "z" * 83 + with pytest.raises(InvalidLength): + parse_codex32(over_limit) diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 3fbaca94..953951d9 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -118,6 +118,17 @@ def test_expanded_codeword_upper_bound() -> None: _checksum_for_encoded_length("ms", len(bech32_decode(oversized)[1])) +def test_long_checksum_residue_stays_correct_past_its_period() -> None: + # Probes the polynomial itself with u5 values, not a codex32 string: the + # arithmetic still closes beyond the code's 1023-symbol period, so length is + # what verify() has to enforce, not the residue. + body = bech32_hrp_expand("ms") + [0] * 1100 + codeword = body + _CODEX32_LONG.create(body) + assert len(codeword) > 1023 + assert _CODEX32_LONG.polymod(codeword) == _CODEX32_LONG.constant + assert _CODEX32_LONG.verify(codeword) is False + + def test_checksum_is_verified_before_unknown_hrp_dispatch() -> None: valid_generic = _oracle_encode("zz", "0tests" + "q" * 26) artifact = parse_codex32(valid_generic) From 9a05bb353cee2dc2ffb794d57a8085576e4df740 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 23 Sep 2026 11:26:10 -0500 Subject: [PATCH 05/23] bip93: Drop oversized frozen vector --- tests/data/bip93_vectors.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/data/bip93_vectors.py b/tests/data/bip93_vectors.py index c944a424..843a05a4 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -116,8 +116,6 @@ + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx836hdd09mhkhkhx", # HRP character out of range "\x80" + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxlhf5ywnkmk4r3tc", # HRP character out of range - # HRP exceeds 83 characters - "a1002characterlonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~15ttgtscr3gvktxamm8mzt", # HRP exceeds 83 characters (also exceeds the overall max length) "a1003characterslonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~12fauxxru38cppmlpu0t6l", "y12bfauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxt3y5fewy4gnw2hs", # Invalid data character From e397fccc85cf6853319a414e54ba2267134edd8f Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 24 Sep 2026 13:08:09 -0500 Subject: [PATCH 06/23] bip93: use generalized-HRP draft vectors --- tests/data/bip93_vectors.py | 27 ++++++++++++++++++++++++-- tests/test_generic_hrp.py | 38 ++++++++++++++++++++++++++----------- tests/test_profiles.py | 11 ----------- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/tests/data/bip93_vectors.py b/tests/data/bip93_vectors.py index 843a05a4..c24ce10b 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -79,6 +79,31 @@ "codex32_peev": "cl10peevst6cqh0wu7p5ssjyf4z4ez42ks9jlt3zneju9uuypr2hddak6tlqsjhsks4laxts8q", } +# BIP-93 generalized-HRP draft, bitcoin/bips bip93-generalize-hrp @ 01374bf. +BIP93_GENERAL_HRP = { + "share_a": "test_vector12spana320zyxwvutsrqpnmlkjhgfedca320zyxwvutsrqpnmlkjhgfedca304ppsqh4l7v3dh", + "share_c": "test_vector12spancacdefghjklmnpqrstuvwxyz023acdefghjklmnpqrstuvwxyz023jxmjy7q9xl7d3ul", + "derived_d": "test_vector12spandll4f8jlh4e5vdvuldlfxu2jhdnll4f8jlh4e5vdvuldlfxu2jhdnx3pe3yqzdl7p30y", + "secret_s": "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5kdf37v5qm2l7l3az", + "valid_83": ( + "a83characterlongcodex32humanreadablepartforsecretsharewiththedigit1andthelettersbio" + "10keyss9mfjjk25y05e3nq" + ), + "invalid_gap_regular": ( + "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5kp0f0tlr2cxh0t" + ), + "invalid_gap_long": ( + "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5c2m7mxmk8s5qecp" + ), + "invalid_uppercase_hrp_checksum": ( + "CL10PEEVST6CQH0WU7P5SSJYF4Z4EZ42KS9JLT3ZNEJU9UUYPR2HDDAK6TLQS3J5AYX4Y08079" + ), + "invalid_84": ( + "a84characterslongcodex32humanreadablepartforsecretsharewiththedigit1andthelettersbio" + "10keyss9hqxu3pxsnkdkth" + ), +} + VALID_CODEX32 = [ "A12UEL5LLGCHJ4UJCQVHG", @@ -116,8 +141,6 @@ + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx836hdd09mhkhkhx", # HRP character out of range "\x80" + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxlhf5ywnkmk4r3tc", # HRP character out of range - # HRP exceeds 83 characters (also exceeds the overall max length) - "a1003characterslonghumanreadablepartthatcontainsthenumber1,theexcludedcharactersbio,andeveryus-asciicharacterin[33-126]!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~!\"#$%&'()*+,-./0123456789:;<=>?@[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~12fauxxru38cppmlpu0t6l", "y12bfauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxt3y5fewy4gnw2hs", # Invalid data character "lt12ifauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxjxd0ehq868vm3zl", # Invalid data character "in12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxvgegljrsvs5w9q", # Too short checksum diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 9a5b913d..0cf4f801 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -7,6 +7,7 @@ import pytest from _codex32_oracle import oracle_encode +from data.bip93_vectors import BIP93_GENERAL_HRP from codex32 import ( CorrectionContext, @@ -18,7 +19,7 @@ recover_secret, ) from codex32.cli import main, ms_main -from codex32.errors import InvalidLength, MismatchedHrp, MismatchedProfile +from codex32.errors import InvalidChecksum, InvalidLength, MismatchedHrp, MismatchedProfile from tools._wallet_test_vectors import STUB_FINGERPRINT UNKNOWN = { @@ -180,16 +181,31 @@ def fingerprint(_secret: object) -> bytes: assert _invoke(ms_main, ["share", "d", "--plain"], basis)[0] == 2 -def test_83_character_hrp_may_span_printable_ascii() -> None: - pool = [chr(code) for code in range(33, 127) if not chr(code).isupper()] - hrp = "".join(pool[index % len(pool)] for index in range(83)) - assert "1" in hrp # the separator is the last "1", so an HRP may contain one - assert parse_codex32(oracle_encode(hrp, "0testsq")).hrp == hrp +def test_bip93_generalized_hrp_share_vectors() -> None: + share_a = parse_codex32(BIP93_GENERAL_HRP["share_a"]) + share_c = parse_codex32(BIP93_GENERAL_HRP["share_c"]) + assert share_a.hrp == share_c.hrp == "test_vector" + assert recover_secret([share_a, share_c]).text == BIP93_GENERAL_HRP["secret_s"] + assert derive_share([share_a, share_c], "d").text == BIP93_GENERAL_HRP["derived_d"] -def test_hrp_over_83_characters_is_rejected_at_the_bip173_limit() -> None: - within_limit = oracle_encode("z" * 83, "0testsq") - over_limit = oracle_encode("z" * 84, "0testsq") - assert parse_codex32(within_limit).hrp == "z" * 83 +def test_bip93_generalized_hrp_boundary_vectors() -> None: + valid = BIP93_GENERAL_HRP["valid_83"] + invalid = BIP93_GENERAL_HRP["invalid_84"] + artifact = parse_codex32(valid) + + assert len(artifact.hrp) == 83 + assert artifact.hrp == valid.rsplit("1", 1)[0] + with pytest.raises(InvalidLength): + parse_codex32(invalid) + + +@pytest.mark.parametrize("key", ("invalid_gap_regular", "invalid_gap_long")) +def test_bip93_generalized_hrp_gap_vectors_are_invalid(key: str) -> None: with pytest.raises(InvalidLength): - parse_codex32(over_limit) + parse_codex32(BIP93_GENERAL_HRP[key]) + + +def test_bip93_generalized_hrp_checksum_uses_lowercase_hrp() -> None: + with pytest.raises(InvalidChecksum): + parse_codex32(BIP93_GENERAL_HRP["invalid_uppercase_hrp_checksum"]) diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 953951d9..3fbaca94 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -118,17 +118,6 @@ def test_expanded_codeword_upper_bound() -> None: _checksum_for_encoded_length("ms", len(bech32_decode(oversized)[1])) -def test_long_checksum_residue_stays_correct_past_its_period() -> None: - # Probes the polynomial itself with u5 values, not a codex32 string: the - # arithmetic still closes beyond the code's 1023-symbol period, so length is - # what verify() has to enforce, not the residue. - body = bech32_hrp_expand("ms") + [0] * 1100 - codeword = body + _CODEX32_LONG.create(body) - assert len(codeword) > 1023 - assert _CODEX32_LONG.polymod(codeword) == _CODEX32_LONG.constant - assert _CODEX32_LONG.verify(codeword) is False - - def test_checksum_is_verified_before_unknown_hrp_dispatch() -> None: valid_generic = _oracle_encode("zz", "0tests" + "q" * 26) artifact = parse_codex32(valid_generic) From b1b1b27d149c3adac3dae56ea2edee278dd84fcf Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sun, 27 Sep 2026 09:47:02 -0500 Subject: [PATCH 07/23] tests: Correct HRP vector provenance Name BenWestgate/bips PR #2 as the source of the generalized-HRP draft vectors. No vector data or parser behavior changes. --- tests/data/bip93_vectors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/data/bip93_vectors.py b/tests/data/bip93_vectors.py index c24ce10b..0ce348f4 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -79,7 +79,7 @@ "codex32_peev": "cl10peevst6cqh0wu7p5ssjyf4z4ez42ks9jlt3zneju9uuypr2hddak6tlqsjhsks4laxts8q", } -# BIP-93 generalized-HRP draft, bitcoin/bips bip93-generalize-hrp @ 01374bf. +# BIP-93 generalized-HRP draft, BenWestgate/bips PR #2 @ 01374bf. BIP93_GENERAL_HRP = { "share_a": "test_vector12spana320zyxwvutsrqpnmlkjhgfedca320zyxwvutsrqpnmlkjhgfedca304ppsqh4l7v3dh", "share_c": "test_vector12spancacdefghjklmnpqrstuvwxyz023acdefghjklmnpqrstuvwxyz023jxmjy7q9xl7d3ul", From 12019448703e1df8de6f4898ae3aa0dc797305fd Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 23 Sep 2026 07:11:14 -0500 Subject: [PATCH 08/23] docs: Record security audit verdict Keep local planning state ignored and publish only the finished audit result in a dedicated audit directory. This narrows the review surface to stable findings and their tracking references. Refs #20. --- docs/audits/security-2026-09-22.md | 184 +++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/audits/security-2026-09-22.md diff --git a/docs/audits/security-2026-09-22.md b/docs/audits/security-2026-09-22.md new file mode 100644 index 00000000..aad97040 --- /dev/null +++ b/docs/audits/security-2026-09-22.md @@ -0,0 +1,184 @@ +# Security audit verdict — 2026-09-22 + +Scope: python-codex32 at `c118a834fc9cce95ead3c05b3a2532e1320980c5`, +the graphical wallet flow at `78f4a3b4e99b02bf012ff179aaa7f3f8a96683f2` +and its current descendant `8f1001f`, and the BIP93/Codex32 assumptions those +paths rely on. + +This document records the security-review result. It is not a replacement for +the normative [security model](../security/model.md) or +[security invariants](../security/invariants.md). + +## Verdict + +The core implementation is careful about parsing, checksum validation, +reconstruction, Bitcoin Core process boundaries, and secret transfer. No break +of the Codex32 checksum, finite-field reconstruction, OS-CSPRNG use, or BIP32 +root validation was found. + +The audit did find two medium library implementation defects, one low +interoperability/specification-divergence defect, and two wallet recovery +design/ordering defects. It also confirmed two properties of BIP93 +that applications must not mistake for authentication: the 20-bit set +identifier does not prevent cross-set mixing, and checksum-valid shares do not +authenticate the intended reconstructed secret against active substitution. + +The practical rule for callers is therefore stricter than “valid Codex32 +shares reconstruct a valid seed.” Recovery must also be bound to an independent +wallet identity before any recovered key material mutates a live wallet. + +## Confirmed implementation findings + +### Medium — unbounded correction alignment cache + +Tracking: [#21](https://github.com/BenWestgate/python-codex32/issues/21). + +- Affected component: `src/codex32/correction.py`, `_syndrome_alignment`. +- Failure scenario: caller-controlled HRP/length combinations create distinct + entries in a process-global unbounded cache. A long-lived GUI or service can + retain alignment tables monotonically. +- Property violated: bounded resource use for untrusted correction input. +- Exploitability: practical for long-lived processes. A local reproduction with + 50 attacker-distinct HRP keys retained about 5.66 MiB, approximately 113 KiB + per key in that run. +- Remediation: remove attacker-specific global caching or use a small bounded + LRU with lifecycle clearing; add churn tests that assert a hard bound. +- Confidence: high. + +### Medium — secret-bearing default string representations + +Tracking: [#22](https://github.com/BenWestgate/python-codex32/issues/22). + +- Affected component: artifact string/repr paths and nested correction + candidates. +- Failure scenario: `_Artifact.__str__` returns the complete Codex32 + secret/share text, while default object representations can retain or expose + secret-bearing fields. Generic logging, notebooks, REPLs, assertion output, + or exception context can persist recovery material unexpectedly. +- Property violated: secrets stay out of ordinary output and logs unless an + explicit reveal/export operation is requested. +- Exploitability: practical accidental disclosure; no attacker is required once + a secret-bearing object reaches generic diagnostics. +- Remediation: make normal `str`/`repr` redacted and require an explicit + reveal/export operation for the full Codex32 string; test nested containers + and correction candidates. +- Confidence: high. + +### Low — overlong BIP173 human-readable parts are accepted + +Tracking: [#32](https://github.com/BenWestgate/python-codex32/issues/32). + +- Affected component: `src/codex32/bip93.py` decoder and public correction + contexts. +- Failure scenario: an otherwise checksum-valid Codex32 string with an HRP of + 84 or more characters is accepted even though BIP173 limits the HRP to 83 + characters. The same invalid namespace can be admitted to correction setup. +- Property violated: accepted encodings must remain inside the normative + BIP173/BIP93 grammar so independent implementations agree on validity. +- Exploitability: practical interoperability failure, not a secret compromise. + A concrete 84-character-HRP string was accepted during the audit. +- Remediation: reject HRPs longer than 83 characters in decoding and correction + context validation; cover the 83/84-character boundary in tests. +- Confidence: high. + +### Medium — wallet restore verifies recovered identity after mutation + +Tracking: GUI issue #26; CLI issue #30. + +- Affected component: GUI recovery in `src/codex32_gui/pages.py`, CLI recovery + in `src/codex32/cli.py`, and the shared mutating wallet initialization path. +- Failure scenario: both supported restore front ends mutate Core before the + recovered wallet identity is checked. The GUI calls `wallet_setup.fill(...)` + before it computes and displays the identity. `ms32 wallet` calls + `core.initialize(...)` before displaying the recovered fingerprint. A wrong + but checksum-valid recovered seed can therefore import private descriptors + into the selected empty Bitcoin Core wallet before the operator can detect + the mismatch. +- Property violated: recovery identity must be authenticated before recovered + key material changes durable wallet state. +- Exploitability: practical. It can be triggered by operator mixing of distinct + share sets, malicious share substitution, or any other path yielding a valid + but unintended seed. +- Remediation: on every restore path, derive a recovery identity without + importing descriptors, require the operator to verify it against an + independent wallet record, and only then call the mutating wallet + initialization path. +- Confidence: high. Reconfirmed against the Bails-pinned GUI commit, the current + GUI descendant, and the supported `ms32 wallet` CLI path. + +### Medium design concern — 32-bit fingerprint is too weak as an active recovery gate + +Tracking: issue #27. + +- Affected component: wallet record/recovery identity design across restore + front ends. +- Failure scenario: even if the ordering above is fixed, the current wallet + identity is only the four-byte BIP32 master fingerprint. An active adversary + able to search candidate seeds can target a 32-bit value; accidental + collisions also remain possible over sufficiently large populations. +- Property violated: a recovery authentication gate should have enough binding + strength that deliberate collision search is infeasible. +- Exploitability: the weakness is practical as a design limit, although a + targeted seed search still has real compute cost. It is not a break of BIP32. +- Remediation: store and verify an independent public commitment with at least + 128 bits of collision resistance before import. Keep the BIP32 fingerprint as + a human-friendly diagnostic, not the sole authorization value. +- Confidence: high on the weakness; medium on the final record format because + the product UX still needs a concrete commitment encoding. + +## Confirmed BIP93/Codex32 application hazards + +These are protocol properties, not evidence that python-codex32 implemented the +specified arithmetic incorrectly. + +The current BIP93 text already states that identifiers are for disambiguation +and that the checksum does not protect against maliciously constructed errors. +These findings therefore require application-side enforcement and documentation, +not an upstream BIP93 vulnerability report. + +### Medium — 20-bit set identifier does not prevent cross-set mixing + +- Affected specification: BIP93 set identifier and threshold recovery model. +- Failure scenario: shares from two valid sets with the same compatible header + can be combined into the required threshold and interpolate a third seed that + is checksum-valid. The audit reproduced this behavior. +- Property violated: if an application assumes the identifier authenticates + set membership, that assumption fails. +- Exploitability: practical once shares from compatible colliding/matched sets + are available. The 20-bit identifier is a typo/mix-up aid, not a + cryptographic set commitment. +- Remediation: applications must bind recovery to an independent wallet/public + commitment and reject a recovered secret that does not match it. +- Confidence: high. + +### Medium — valid-share substitution can force an attacker-chosen reconstruction + +- Affected specification: unauthenticated threshold-share model. +- Failure scenario: an attacker who can replace enough shares can construct + checksum-valid replacement shares that make the victim reconstruct a chosen + target secret. The audit reproduced this for a 2-of-2 set. +- Property violated: share integrity/provenance and intended-secret + authentication. +- Exploitability: practical when the attacker can replace the required share + material. This is not a secrecy break; it is an authenticity failure if the + application treats checksum validity as authorization. +- Remediation: verify an independent public commitment before using recovered + key material; protect physical shares against substitution where that threat + is in scope. +- Confidence: high. + +## Falsified candidate + +The earlier “opaque-HRP deadline bypass” candidate was falsified during review +and is not a security finding. It must not be cited or tracked as one. + +## Required recovery invariant + +For every wallet restore path built on python-codex32: + +> Parsing, checksum validity, threshold reconstruction, and the BIP32 +> fingerprint are not authorization to mutate a wallet. A recovered seed must +> first match an independent, sufficiently strong public wallet commitment. + +That invariant is the boundary needed to contain both accidental share-set +mixing and active share substitution. From 654039bfc31ec13e61c9351018d6b2cbe5b168db Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 23 Sep 2026 11:05:15 -0500 Subject: [PATCH 09/23] docs: Link audited GUI revisions --- docs/audits/security-2026-09-22.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/audits/security-2026-09-22.md b/docs/audits/security-2026-09-22.md index aad97040..cb8a41e2 100644 --- a/docs/audits/security-2026-09-22.md +++ b/docs/audits/security-2026-09-22.md @@ -1,9 +1,12 @@ # Security audit verdict — 2026-09-22 -Scope: python-codex32 at `c118a834fc9cce95ead3c05b3a2532e1320980c5`, -the graphical wallet flow at `78f4a3b4e99b02bf012ff179aaa7f3f8a96683f2` -and its current descendant `8f1001f`, and the BIP93/Codex32 assumptions those -paths rely on. +Scope: python-codex32 at +[`c118a834fc9cce95ead3c05b3a2532e1320980c5`](https://github.com/BenWestgate/python-codex32/commit/c118a834fc9cce95ead3c05b3a2532e1320980c5), +the graphical wallet flow at +[`78f4a3b4e99b02bf012ff179aaa7f3f8a96683f2`](https://github.com/BenWestgate/python-codex32/commit/78f4a3b4e99b02bf012ff179aaa7f3f8a96683f2) +and its current descendant +[`8f1001f61983cf60023120af4b01bbc99d6f3a84`](https://github.com/BenWestgate/python-codex32/commit/8f1001f61983cf60023120af4b01bbc99d6f3a84), +and the BIP93/Codex32 assumptions those paths rely on. This document records the security-review result. It is not a replacement for the normative [security model](../security/model.md) or From 31598f0a6482dbfbdc55ac703925aba6afed5b07 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Fri, 25 Sep 2026 08:28:45 -0500 Subject: [PATCH 10/23] docs: Separate recovery accident and tampering checks --- docs/audits/security-2026-09-22.md | 111 +++++++++++++++++------------ 1 file changed, 65 insertions(+), 46 deletions(-) diff --git a/docs/audits/security-2026-09-22.md b/docs/audits/security-2026-09-22.md index cb8a41e2..bf3a52ca 100644 --- a/docs/audits/security-2026-09-22.md +++ b/docs/audits/security-2026-09-22.md @@ -20,15 +20,20 @@ of the Codex32 checksum, finite-field reconstruction, OS-CSPRNG use, or BIP32 root validation was found. The audit did find two medium library implementation defects, one low -interoperability/specification-divergence defect, and two wallet recovery -design/ordering defects. It also confirmed two properties of BIP93 -that applications must not mistake for authentication: the 20-bit set -identifier does not prevent cross-set mixing, and checksum-valid shares do not -authenticate the intended reconstructed secret against active substitution. - -The practical rule for callers is therefore stricter than “valid Codex32 -shares reconstruct a valid seed.” Recovery must also be bound to an independent -wallet identity before any recovered key material mutates a live wallet. +interoperability/specification-divergence defect, and a wallet recovery ordering +defect. It also confirmed two properties of BIP93 that applications must not +mistake for cryptographic authentication: the 20-bit set identifier does not +prevent cross-set mixing, and checksum-valid shares do not authenticate the +intended reconstructed secret against active substitution. + +The release-gate rule for callers is therefore stricter than “valid Codex32 +shares reconstruct a valid seed”: before recovered key material mutates a live +wallet, the operator must authenticate that the recovered seed is the wallet +they intended. For the current single-sig restore flow this is an accident-safety +check: a typed wallet-record fingerprint, or an explicit visual confirmation +when no record exists. Stronger resistance to malicious share/seed replacement +is a separate problem, tracked as an authenticated encrypted descriptor backup +in issue #55. ## Confirmed implementation findings @@ -97,37 +102,45 @@ Tracking: GUI issue #26; CLI issue #30. but checksum-valid recovered seed can therefore import private descriptors into the selected empty Bitcoin Core wallet before the operator can detect the mismatch. -- Property violated: recovery identity must be authenticated before recovered - key material changes durable wallet state. -- Exploitability: practical. It can be triggered by operator mixing of distinct - share sets, malicious share substitution, or any other path yielding a valid - but unintended seed. -- Remediation: on every restore path, derive a recovery identity without - importing descriptors, require the operator to verify it against an - independent wallet record, and only then call the mutating wallet - initialization path. +- Property violated: the operator must identify the intended wallet before + recovered key material changes durable wallet state. +- Exploitability: practical for accidental wrong/mixed cards, miscorrection, or + another valid-but-unintended recovery. Deliberate threshold-share replacement + is a separate threat because an attacker who can replace a threshold can + already learn the seed; issue #55 tracks authenticated descriptor evidence for + that stronger threat model. +- Remediation: on every restore path, derive wallet identity without importing + descriptors. Prefer a verified encrypted descriptor backup when available; + otherwise require a matching wallet-record fingerprint (and later optional + single-sig descriptor checksum), or make the no-record visual confirmation an + explicit operator choice. Only then call the mutating wallet initialization + path. - Confidence: high. Reconfirmed against the Bails-pinned GUI commit, the current GUI descendant, and the supported `ms32 wallet` CLI path. -### Medium design concern — 32-bit fingerprint is too weak as an active recovery gate +### Threat-model disposition — 32-bit fingerprint is not a malicious-tampering defense -Tracking: issue #27. +Tracking: closed issue #27; stronger descriptor authentication is issue #55. - Affected component: wallet record/recovery identity design across restore front ends. -- Failure scenario: even if the ordering above is fixed, the current wallet - identity is only the four-byte BIP32 master fingerprint. An active adversary - able to search candidate seeds can target a 32-bit value; accidental - collisions also remain possible over sufficiently large populations. -- Property violated: a recovery authentication gate should have enough binding - strength that deliberate collision search is infeasible. -- Exploitability: the weakness is practical as a design limit, although a - targeted seed search still has real compute cost. It is not a break of BIP32. -- Remediation: store and verify an independent public commitment with at least - 128 bits of collision resistance before import. Keep the BIP32 fingerprint as - a human-friendly diagnostic, not the sole authorization value. -- Confidence: high on the weakness; medium on the final record format because - the product UX still needs a concrete commitment encoding. +- Observation: the four-byte BIP32 master fingerprint is intentionally a + human-scale identifier. A party choosing substitute seeds can grind a matching + value; the 20-bit backup identifier is weaker still. +- Release-gate disposition: this does not invalidate the fingerprint as an + accident-safety gate. Its job in #28/#57 is to catch wrong/mixed cards, + miscorrection and transcription mistakes before Core is changed. It is not + represented as authorization against a malicious party able to replace a + threshold of shares. +- Stronger design: issue #55 authenticates the full single-sig descriptor backup + with material derived from the recovered seed, verifies that the recovered + seed reproduces those descriptors before wallet mutation, and relies on + independently stored copies of that backup for tamper resistance. +- Human record follow-up: #43 adds checksummed/type-back record fields and may + accept a canonical single-sig descriptor checksum as another second-class + accident check. +- Confidence: high on the distinction between the two threat models; the exact + encrypted-backup format remains design work in #55. ## Confirmed BIP93/Codex32 application hazards @@ -150,8 +163,9 @@ not an upstream BIP93 vulnerability report. - Exploitability: practical once shares from compatible colliding/matched sets are available. The 20-bit identifier is a typo/mix-up aid, not a cryptographic set commitment. -- Remediation: applications must bind recovery to an independent wallet/public - commitment and reject a recovered secret that does not match it. +- Remediation: for accidental mix-ups, require the operator to identify the + intended wallet before import using the strongest evidence they actually + possess. Do not describe the identifier itself as authentication. - Confidence: high. ### Medium — valid-share substitution can force an attacker-chosen reconstruction @@ -161,13 +175,14 @@ not an upstream BIP93 vulnerability report. checksum-valid replacement shares that make the victim reconstruct a chosen target secret. The audit reproduced this for a 2-of-2 set. - Property violated: share integrity/provenance and intended-secret - authentication. + authentication when active substitution is in scope. - Exploitability: practical when the attacker can replace the required share - material. This is not a secrecy break; it is an authenticity failure if the - application treats checksum validity as authorization. -- Remediation: verify an independent public commitment before using recovered - key material; protect physical shares against substitution where that threat - is in scope. + material. This is not a secrecy break; such an attacker can already recover + the original seed from the threshold material they control. +- Remediation: keep the release-gate fingerprint/no-record UX scoped to accident + safety. Where malicious substitution is in scope, authenticate the complete + descriptor backup independently as designed in #55 and keep copies apart from + the recovery cards. - Confidence: high. ## Falsified candidate @@ -179,9 +194,13 @@ and is not a security finding. It must not be cited or tracked as one. For every wallet restore path built on python-codex32: -> Parsing, checksum validity, threshold reconstruction, and the BIP32 -> fingerprint are not authorization to mutate a wallet. A recovered seed must -> first match an independent, sufficiently strong public wallet commitment. +> Before any recovered key material mutates a wallet, the operator must +> authenticate that the recovered seed is the wallet they intend using the +> strongest evidence they actually have: a verified seed-keyed descriptor +> backup when available; otherwise a matching wallet-record fingerprint and/or +> canonical single-sig descriptor checksum; otherwise an explicit visual +> confirmation of the recovered fingerprint and codex32/Bails identifier result. -That invariant is the boundary needed to contain both accidental share-set -mixing and active share substitution. +The fingerprint, descriptor-checksum and no-record paths are accident-safety +controls. Malicious share/seed replacement is a separate threat model and uses +the authenticated encrypted descriptor backup tracked in #55. \ No newline at end of file From d07024a79c708b9e7928b79b7cc3b720411f78c0 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 29 Sep 2026 01:39:47 -0500 Subject: [PATCH 11/23] docs: Clarify no-record restore authorization --- docs/audits/security-2026-09-22.md | 39 ++++++++++++++++++------------ 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/audits/security-2026-09-22.md b/docs/audits/security-2026-09-22.md index bf3a52ca..2b48cdf9 100644 --- a/docs/audits/security-2026-09-22.md +++ b/docs/audits/security-2026-09-22.md @@ -28,12 +28,15 @@ intended reconstructed secret against active substitution. The release-gate rule for callers is therefore stricter than “valid Codex32 shares reconstruct a valid seed”: before recovered key material mutates a live -wallet, the operator must authenticate that the recovered seed is the wallet -they intended. For the current single-sig restore flow this is an accident-safety -check: a typed wallet-record fingerprint, or an explicit visual confirmation -when no record exists. Stronger resistance to malicious share/seed replacement -is a separate problem, tracked as an authenticated encrypted descriptor backup -in issue #55. +wallet, the operator must establish that the recovered seed is the wallet they +intend using the strongest independent evidence they actually have. For the +current single-sig restore flow this is an accident-safety check: normally a +typed wallet-record fingerprint; when no independent record exists, the product +instead makes that absence explicit and requires a last-resort visual +confirmation before the operator authorizes the restore. That no-record path is +not independent authentication of the recovered seed. Stronger resistance to +malicious share/seed replacement is a separate problem, tracked as an +authenticated encrypted descriptor backup in issue #55. ## Confirmed implementation findings @@ -194,13 +197,17 @@ and is not a security finding. It must not be cited or tracked as one. For every wallet restore path built on python-codex32: -> Before any recovered key material mutates a wallet, the operator must -> authenticate that the recovered seed is the wallet they intend using the -> strongest evidence they actually have: a verified seed-keyed descriptor -> backup when available; otherwise a matching wallet-record fingerprint and/or -> canonical single-sig descriptor checksum; otherwise an explicit visual -> confirmation of the recovered fingerprint and codex32/Bails identifier result. - -The fingerprint, descriptor-checksum and no-record paths are accident-safety -controls. Malicious share/seed replacement is a separate threat model and uses -the authenticated encrypted descriptor backup tracked in #55. \ No newline at end of file +> Before any recovered key material mutates a wallet, the operator must establish +> the intended wallet using the strongest independent evidence they actually +> have: a verified seed-keyed descriptor backup when available; otherwise a +> matching wallet-record fingerprint and/or canonical single-sig descriptor +> checksum. If no independent wallet record exists, the restore must state that +> identity cannot be authenticated and require an explicit last-resort visual +> confirmation of the recovered fingerprint and codex32/Bails identifier result +> before the operator authorizes mutation. + +The fingerprint and descriptor-checksum paths are human-scale accident-safety +checks. The no-record path is an explicit accident-safety waiver/authorization, +not independent authentication evidence. Malicious share/seed replacement is a +separate threat model and uses the authenticated encrypted descriptor backup +tracked in #55. \ No newline at end of file From 5d17ed78432306944ca29be66da03eb0329cdff5 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Fri, 25 Sep 2026 11:21:52 -0500 Subject: [PATCH 12/23] docs: Define trusted computer boundary Make the secret-output invariant literal about intentional recovery/export output, define the trusted-computer boundary consistently, and warn that shell command text can be retained even when stdin is safe from argv exposure. Update the shared CLI safety footer and its exact-help regression. Validation: focused help regression; Ruff check/format; strict mypy for the parser; git diff --check. The full generic-HRP module still reaches the pre-existing bip32 test dependency tracked by #3/#6 and fixed by #7. fixes #4 --- SECURITY.md | 26 +++++++++++++++++++------- docs/security/invariants.md | 12 ++++++++---- docs/security/model.md | 22 +++++++++++++++++++--- docs/user/guide.md | 20 ++++++++++++++------ src/codex32/_cli_parser.py | 5 +++-- tests/test_generic_hrp.py | 5 +++-- 6 files changed, 66 insertions(+), 24 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 7514e1b7..c540b668 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,9 +1,15 @@ # Security -codex32 handles wallet recovery material. Use a trusted computer and have the +codex32 handles wallet recovery material. A **trusted computer** is under your +exclusive control, is not known or suspected to be compromised, and runs an +operating system and other software you trust for the operation. For wallet +work, that trusted software includes Python, codex32, the terminal, +`bitcoin-cli`, Bitcoin Core, and their relevant configuration. Wallet encryption, +application permissions, and RPC authentication do not make a compromised +computer trusted. Have the software and recovery plan reviewed before relying on it with funds. For -stronger isolation, use codex32 and the Bitcoin Core signing wallet only on a -computer kept permanently offline. +stronger isolation, keep the trusted computer disconnected from every network +before, while, and after it handles private recovery or signing material. A valid checksum detects many copying mistakes. It does not prove that a backup belongs to your wallet. A correction is only a suggestion; compare it with the @@ -11,10 +17,16 @@ physical backup and wallet information kept elsewhere. Python, your terminal, and your operating system may retain secret text in memory or scrollback. codex32 does not intentionally save secrets and keeps -them out of command arguments and normal machine output, but it cannot -guarantee that every copy is erased from swap, hibernation data, or crash -dumps. Using Tails and shutting down when finished helps mitigate this Python -limitation. +them out of command arguments, logs, and unrelated output. Commands that create, +recover, derive, correct, or explicitly export recovery material intentionally +display it when that is their purpose. codex32 cannot guarantee that every copy +is erased from swap, hibernation data, or crash dumps. Using Tails and shutting +down when finished helps mitigate this Python limitation. + +Run a command first and enter recovery material at its prompt, or redirect its +standard input from a separately protected source. Do not embed recovery text in +shell command text: shell history, terminal logging, wrappers, and process +tooling may retain it even though codex32 never receives it as an argument. ## Report a security problem diff --git a/docs/security/invariants.md b/docs/security/invariants.md index be5ed482..51aa9c34 100644 --- a/docs/security/invariants.md +++ b/docs/security/invariants.md @@ -31,8 +31,12 @@ and evidence. warning and literal `YES` confirmation for every HRP. The bound includes all admitted classes ranked equal to or better than the candidate, independently of execution order. -6. Secrets stay out of arguments, logs, ordinary output, and public transfers. - During wallet setup, codex32 transfers the master xprv only through child stdin. +6. Secrets stay out of command arguments, logs, unrelated output, and public + transfers. Commands whose purpose is to create, recover, derive, correct, or + explicitly export recovery material may intentionally display that material + to the operator. During wallet setup, codex32 transfers the master xprv only + through child stdin. Private descriptors exist only in Python memory and child + stdin. 7. Bitcoin Core chains are discovered before entropy or recovery input. The operator confirms an eligible descriptor wallet by exact name. 8. Wallet state is revalidated before handing Core the master key. Core must @@ -41,8 +45,8 @@ and evidence. and verified on every exit path. 10. External text, Core output, public wallet data, and PSBTs are untrusted. 11. Only Bitcoin Core descriptor wallets sign with codex32-derived keys. - Sensitive operations use only codex32 or Core on malware-free computers - with trusted software. + Sensitive operations use only codex32 or Core on a trusted computer as + defined by the security model. 12. Offline hosts disable every network path, including Ethernet, internet, Tor, Wi-Fi, Bluetooth, and cellular. Online Core nodes synchronize before their balances or history are trusted. diff --git a/docs/security/model.md b/docs/security/model.md index 2c75ca3c..8a07c3d5 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -31,13 +31,21 @@ fingerprints, and wallet history cannot spend funds but remain privacy-sensitive ## Operator assumptions +A **trusted computer** is under the operator's exclusive control, is not known +or suspected to be compromised, and runs an operating system and other software +the operator trusts for the operation. For codex32 wallet work, that trusted +software includes the Python environment, codex32, the terminal, `bitcoin-cli`, +Bitcoin Core, and their relevant configuration. An offline trusted computer +remains disconnected from every network before, while, and after it handles +private recovery or signing material. Wallet encryption, application +permissions, and RPC authentication do not make a compromised computer trusted. + The operator must: - use only Bitcoin Core descriptor wallets to sign with keys derived from a codex32 master seed; -- use computers believed malware-free and whose other software is trusted for - all codex32 operations and for wallet initialization and signing with Bitcoin - Core; +- use only trusted computers as defined above for codex32 operations, wallet + initialization, and signing with Bitcoin Core; - disconnect every computer used for offline codex32 or signing work from all network paths, including Ethernet, internet, Tor, Wi-Fi, Bluetooth, and cellular; @@ -53,6 +61,14 @@ The operator must: - never put recovery text in command arguments or transfer a master seed, share, xprv, or private descriptor through QR or a network service. +`create`, `secret`, `share`, `correct`, and `xprv` can intentionally display +secret-bearing recovery material because producing or exporting that material is +their purpose. This is distinct from accidental disclosure: unrelated status, +diagnostic, logging, and wallet-integration output must not reveal secrets. +Prompted input and standard-input redirection keep recovery text out of process +arguments, but the operator must also keep the text out of shell command text; +shell history, terminal logging, wrappers, or process tooling may retain it. + ## Security limitations - Python cannot guarantee zeroization, constant-time execution, locked memory, diff --git a/docs/user/guide.md b/docs/user/guide.md index 422ec998..edcef9a9 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -87,16 +87,24 @@ codex32 detects and reports the local Bitcoin Core network. You do not need to change `bitcoin.conf` when using the standard local data directory and RPC port. -Use a computer you believe is malware-free and whose other software you trust. -Only codex32 and Bitcoin Core should perform recovery, derivation, wallet -initialization, or signing. The QR tools below transport only public -descriptors or PSBTs. +Use a trusted computer: one under your exclusive control, not known or suspected +to be compromised, with its operating system, Python environment, terminal, +codex32, `bitcoin-cli`, Bitcoin Core, and relevant configuration trusted for the +operation. Wallet encryption or RPC authentication does not make a compromised +computer safe. An offline trusted computer stays disconnected from every network +before, while, and after it handles private recovery or signing material. Only +codex32 and Bitcoin Core should perform recovery, derivation, wallet +initialization, or signing. The QR tools below transport only public descriptors +or PSBTs. Bitcoin Core wallet encryption is strongly recommended. Bitcoin Core owns the passphrase and its prompts; codex32 never asks for, reads, or forwards it. -Do not type recovery text on the same line as a command. Run the command first, -then enter a master seed or shares on the separate `>` line when prompted. +Do not type recovery text on the same line as a command: shell history, terminal +logging, wrappers, or process tooling may retain the command text. Run the +command first, then enter a master seed or shares on the separate `>` line when +prompted. Standard-input redirection also keeps recovery text out of process +arguments, but protect the redirected source separately. This keeps a 48-character string grouped in fours within an 80-column terminal. Later share prompts may show a fixed common header after `>`. Never photograph recovery text or put it in a website, chat, cloud clipboard, or online QR diff --git a/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index c2472109..39a9415b 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -86,8 +86,9 @@ def parser(prog: str = "codex32", *, master_seed: bool = False) -> argparse.Argu if master_seed else "Check, correct, recover, and derive shares from codex32 backups." ), - epilog="Never include a secret or share in command arguments.\n" - "Enter it when prompted. Some commands also accept piped input.", + epilog="Never put a secret or share in command arguments or shell command text.\n" + "Enter it when prompted; some commands also accept redirected standard input.\n" + "Protect redirected sources separately: shells, terminals, and wrappers may retain text.", formatter_class=argparse.RawDescriptionHelpFormatter, allow_abbrev=False, ) diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index 0cf4f801..b384ae63 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -125,8 +125,9 @@ def test_cli_split_and_unknown_neutral_summary() -> None: " create create or confirm a backup, or split an existing secret\n" " wallet restore a Bitcoin Core wallet\n" " xprv export the root extended private key\n\n" - "Never include a secret or share in command arguments.\n" - "Enter it when prompted. Some commands also accept piped input.\n" + "Never put a secret or share in command arguments or shell command text.\n" + "Enter it when prompted; some commands also accept redirected standard input.\n" + "Protect redirected sources separately: shells, terminals, and wrappers may retain text.\n" ) for command in ("check", "correct", "secret", "share"): assert command in generic_help and command in ms_help From 973083522166900dcb92ecae4758a1c04122a3d5 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 26 Sep 2026 21:51:37 -0500 Subject: [PATCH 13/23] docs: keep CLI safety footer within 80 columns --- src/codex32/_cli_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/codex32/_cli_parser.py b/src/codex32/_cli_parser.py index 39a9415b..12178945 100644 --- a/src/codex32/_cli_parser.py +++ b/src/codex32/_cli_parser.py @@ -88,7 +88,7 @@ def parser(prog: str = "codex32", *, master_seed: bool = False) -> argparse.Argu ), epilog="Never put a secret or share in command arguments or shell command text.\n" "Enter it when prompted; some commands also accept redirected standard input.\n" - "Protect redirected sources separately: shells, terminals, and wrappers may retain text.", + "Protect redirected files; shells and terminals may keep text.", formatter_class=argparse.RawDescriptionHelpFormatter, allow_abbrev=False, ) From ede98a8e072ba8ec6d9ca175324659426acd6e95 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 26 Sep 2026 21:52:10 -0500 Subject: [PATCH 14/23] test: match shortened CLI safety footer --- tests/test_generic_hrp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_generic_hrp.py b/tests/test_generic_hrp.py index b384ae63..41c5b2f6 100644 --- a/tests/test_generic_hrp.py +++ b/tests/test_generic_hrp.py @@ -127,7 +127,7 @@ def test_cli_split_and_unknown_neutral_summary() -> None: " xprv export the root extended private key\n\n" "Never put a secret or share in command arguments or shell command text.\n" "Enter it when prompted; some commands also accept redirected standard input.\n" - "Protect redirected sources separately: shells, terminals, and wrappers may retain text.\n" + "Protect redirected files; shells and terminals may keep text.\n" ) for command in ("check", "correct", "secret", "share"): assert command in generic_help and command in ms_help From 02bfc6a8fe1f39fd7b75e13a4d89cafbd4a9007a Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 24 Sep 2026 14:11:45 -0500 Subject: [PATCH 15/23] Gate release on exact artifacts Build distributions once, qualify the exact wheel and sdist across the supported Python/OS matrix, run optimized/static/differential and real-Core checks, and publish only after those jobs pass. Record source SHA, dependency environments, Bitcoin Core version, artifact hashes, and qualification results in a release provenance bundle. Scrub PYTHONPATH and user-site imports in installed-artifact checks so the source checkout cannot satisfy the test accidentally. Security: release qualification uses pinned Bitcoin Core v32.0rc2 bytes and read-only permissions until the final publication job. Validation: 865 normal and 865 optimized tests passed; mypy, Ruff check/format, build and Twine passed; exact wheel and sdist both passed isolated installed-package verification with PYTHONPATH deliberately contaminated; workflow YAML parses successfully. Fixes #5. --- .github/workflows/publish.yml | 222 ++++++++++++++++++++++++++++-- tools/verify_installed_wheel.py | 4 + tools/verify_wheel_environment.py | 41 +++++- 3 files changed, 248 insertions(+), 19 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7edcac9f..63708e7d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,21 +6,51 @@ on: env: SOURCE_DATE_EPOCH: "1763060600" + BITCOIN_CORE_ARCHIVE: bitcoin-32.0rc2-x86_64-linux-gnu.tar.gz + BITCOIN_CORE_SHA256: 0255103718033e6aee15fa944717fc277e047b845bff1e7408af0ea732d8d0c1 + BITCOIN_CORE_URL: https://bitcoincore.org/bin/bitcoin-core-32.0/test.rc2 + +permissions: + contents: read jobs: build: runs-on: ubuntu-latest permissions: - contents: write + contents: read + outputs: + python_versions: ${{ steps.metadata.outputs.python_versions }} + version: ${{ steps.metadata.outputs.version }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" - - run: >- - python -c "import os,tomllib; - version=tomllib.load(open('pyproject.toml','rb'))['project']['version']; - assert os.environ['GITHUB_REF_NAME'] == f'v{version}'" + - id: metadata + name: Check release metadata + run: | + python - <<'PY' + import json + import os + import tomllib + + with open("pyproject.toml", "rb") as file: + project = tomllib.load(file)["project"] + version = project["version"] + if os.environ["GITHUB_REF_NAME"] != f"v{version}": + raise SystemExit("release tag does not match package version") + prefix = "Programming Language :: Python :: " + versions = sorted( + classifier.removeprefix(prefix) + for classifier in project["classifiers"] + if classifier.startswith(prefix) and classifier.removeprefix(prefix).count(".") == 1 + ) + if not versions: + raise SystemExit("no supported Python versions are classified") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"version={version}\n") + output.write(f"python_versions={json.dumps(versions, separators=(',', ':'))}\n") + PY - run: >- python -m pip install --require-hashes -r requirements/release-build-dependencies.txt @@ -33,28 +63,196 @@ jobs: tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner \ -C normalized-sdist -cf - "codex32-${GITHUB_REF_NAME#v}" | gzip -n > "$archive.new" mv "$archive.new" "$archive" - - name: Attach distributions to the GitHub release - run: gh release upload "$GITHUB_REF_NAME" dist/* - env: - GH_TOKEN: ${{ github.token }} + - name: Record built artifact provenance + run: | + mkdir provenance + git rev-parse HEAD > provenance/source-commit.txt + sha256sum dist/* > provenance/artifact-sha256.txt + python -m pip freeze --all > provenance/build-environment.txt - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: distributions path: dist/ + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: build-provenance + path: provenance/ - publish: + supported-matrix: + needs: build + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ${{ fromJSON(needs.build.outputs.python_versions) }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + 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 + - name: Install legacy wallet-test oracle when present + shell: bash + run: | + if [[ -f requirements/test-wallet-dependencies.txt ]]; then + python -m pip install --no-build-isolation --require-hashes \ + -r requirements/test-wallet-dependencies.txt + fi + - run: python -m pip install --no-build-isolation -e '.[dev]' + - run: python -m pip check + - run: python -m pytest -q + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: distributions + path: dist/ + - name: Install and test the exact wheel and sdist + shell: bash + run: | + for artifact in dist/codex32-*.whl dist/codex32-*.tar.gz; do + python tools/verify_wheel_environment.py --artifact "$artifact" + done + - name: Record qualification dependencies + shell: bash + run: >- + python -m pip freeze --all > + "qualification-${RUNNER_OS}-${{ matrix.python-version }}.txt" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: qualification-${{ runner.os }}-${{ matrix.python-version }} + path: qualification-*.txt + + quality: needs: build runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + - run: python -m pip install --upgrade pip + - run: python -m pip install --require-hashes -r requirements/cli-build-dependencies.txt + - name: Install legacy wallet-test oracle when present + run: | + if [[ -f requirements/test-wallet-dependencies.txt ]]; then + python -m pip install --no-build-isolation --require-hashes \ + -r requirements/test-wallet-dependencies.txt + fi + - run: python -m pip install --no-build-isolation -e '.[dev]' + - run: python -O -m pytest -q + - run: python -m mypy src/codex32 + - run: python -m ruff check . + - run: python -m ruff format --check . + - run: python tools/differential_correction.py --verify + - name: Run legacy wallet differential when present + run: | + if [[ -f tools/differential_wallet.py ]]; then + python tools/differential_wallet.py --verify + fi + + bitcoin-core: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + - name: Download pinned Bitcoin Core + run: | + curl --fail --location --proto '=https' --tlsv1.2 \ + --output "$RUNNER_TEMP/$BITCOIN_CORE_ARCHIVE" \ + "$BITCOIN_CORE_URL/$BITCOIN_CORE_ARCHIVE" + echo "$BITCOIN_CORE_SHA256 $RUNNER_TEMP/$BITCOIN_CORE_ARCHIVE" | sha256sum --check + tar -xzf "$RUNNER_TEMP/$BITCOIN_CORE_ARCHIVE" -C "$RUNNER_TEMP" + - name: Run real-Core release integration + env: + PYTHONPATH: ${{ github.workspace }}/src + run: | + core="$RUNNER_TEMP/bitcoin-32.0rc2/bin" + "$core/bitcoind" --version | head -1 | tee bitcoin-core-version.txt + python tools/bitcoin_core_regtest.py \ + --bitcoind "$core/bitcoind" \ + --bitcoin-cli "$core/bitcoin-cli" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: bitcoin-core-provenance + path: bitcoin-core-version.txt + + publish: + needs: [build, supported-matrix, quality, bitcoin-core] + runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/codex32 permissions: - contents: read + contents: write id-token: write steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: distributions path: dist/ - - name: Publish distributions to PyPI + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: build-provenance + path: provenance/ + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: bitcoin-core-provenance + path: provenance/ + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: qualification-* + path: provenance/dependencies/ + merge-multiple: true + - name: Verify qualified artifact bytes + run: sha256sum --check provenance/artifact-sha256.txt + - name: Record release qualification + env: + RELEASE_VERSION: ${{ needs.build.outputs.version }} + MATRIX_RESULT: ${{ needs.supported-matrix.result }} + QUALITY_RESULT: ${{ needs.quality.result }} + CORE_RESULT: ${{ needs.bitcoin-core.result }} + run: | + python - <<'PY' + import json + import os + from pathlib import Path + + hashes = {} + for line in Path("provenance/artifact-sha256.txt").read_text().splitlines(): + digest, path = line.split(maxsplit=1) + hashes[Path(path).name] = digest + record = { + "source_commit": Path("provenance/source-commit.txt").read_text().strip(), + "tag": os.environ["GITHUB_REF_NAME"], + "version": os.environ["RELEASE_VERSION"], + "bitcoin_core": Path("provenance/bitcoin-core-version.txt").read_text().strip(), + "artifact_sha256": hashes, + "qualification": { + "supported_matrix": os.environ["MATRIX_RESULT"], + "quality": os.environ["QUALITY_RESULT"], + "bitcoin_core": os.environ["CORE_RESULT"], + }, + } + Path("provenance/release-qualification.json").write_text( + json.dumps(record, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + PY + - name: Bundle release provenance + run: >- + tar -czf "codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" + -C provenance . + - name: Attach qualified distributions and provenance + run: >- + gh release upload "$GITHUB_REF_NAME" dist/* + "codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" --clobber + env: + GH_TOKEN: ${{ github.token }} + - name: Publish qualified distributions to PyPI uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2 + with: + packages-dir: dist/ diff --git a/tools/verify_installed_wheel.py b/tools/verify_installed_wheel.py index 373b532d..b456759b 100644 --- a/tools/verify_installed_wheel.py +++ b/tools/verify_installed_wheel.py @@ -4,6 +4,7 @@ import importlib.util import sys +from pathlib import Path import codex32 from codex32 import ( @@ -26,6 +27,9 @@ def main() -> None: + source_package = Path(__file__).resolve().parents[1] / "src" / "codex32" + imported_package = Path(codex32.__file__).resolve().parent + assert imported_package != source_package assert importlib.util.find_spec("bip32") is None assert importlib.util.find_spec("coincurve") is None diff --git a/tools/verify_wheel_environment.py b/tools/verify_wheel_environment.py index 689e1ee0..50cc0f85 100644 --- a/tools/verify_wheel_environment.py +++ b/tools/verify_wheel_environment.py @@ -1,4 +1,4 @@ -"""Install the built wheel without dependencies and run the installed-wheel verifier.""" +"""Install a built distribution in isolation and run the installed-package verifier.""" from __future__ import annotations @@ -13,22 +13,46 @@ def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--wheel", type=Path) + parser.add_argument("--artifact", type=Path) arguments = parser.parse_args() root = Path(__file__).resolve().parents[1] - if arguments.wheel is None: + if arguments.wheel is not None and arguments.artifact is not None: + parser.error("choose only one of --wheel or --artifact") + requested = arguments.artifact or arguments.wheel + if requested 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] + artifact = wheels[0] else: - wheel = arguments.wheel.resolve() - if not wheel.is_file(): - raise FileNotFoundError(wheel) + artifact = requested.resolve() + if not artifact.is_file(): + raise FileNotFoundError(artifact) + if artifact.suffix != ".whl" and not artifact.name.endswith(".tar.gz"): + raise ValueError("artifact must be a wheel or .tar.gz source distribution") 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") + clean_env = os.environ.copy() + clean_env.pop("PYTHONPATH", None) + clean_env["PYTHONNOUSERSITE"] = "1" + if artifact.name.endswith(".tar.gz"): + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--require-hashes", + "-r", + str(root / "requirements" / "cli-build-dependencies.txt"), + ], + check=True, + env=clean_env, + ) subprocess.run( [ str(python), @@ -37,14 +61,17 @@ def main() -> None: "install", "--disable-pip-version-check", "--no-deps", - str(wheel), + "--no-build-isolation", + str(artifact), ], check=True, + env=clean_env, ) subprocess.run( [str(python), str(root / "tools" / "verify_installed_wheel.py")], check=True, cwd=temporary, + env=clean_env, ) From 9cf9f440daa5bc63ba22830eda55572dc9189b78 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 26 Sep 2026 19:48:54 -0500 Subject: [PATCH 16/23] release: Gate publication on qualified artifacts --- .github/workflows/publish.yml | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 63708e7d..675aa936 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,8 +1,9 @@ name: Publish release on: - release: - types: [published] + push: + tags: + - "v*" env: SOURCE_DATE_EPOCH: "1763060600" @@ -91,6 +92,7 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - run: python -m pip install --upgrade pip - run: python -m pip install --require-hashes -r requirements/cli-build-dependencies.txt - name: Install legacy wallet-test oracle when present @@ -159,6 +161,12 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: distributions + path: dist/ + - name: Install qualified wheel + run: python -m pip install --no-deps dist/codex32-*.whl - name: Download pinned Bitcoin Core run: | curl --fail --location --proto '=https' --tlsv1.2 \ @@ -168,7 +176,7 @@ jobs: tar -xzf "$RUNNER_TEMP/$BITCOIN_CORE_ARCHIVE" -C "$RUNNER_TEMP" - name: Run real-Core release integration env: - PYTHONPATH: ${{ github.workspace }}/src + PYTHONNOUSERSITE: "1" run: | core="$RUNNER_TEMP/bitcoin-32.0rc2/bin" "$core/bitcoind" --version | head -1 | tee bitcoin-core-version.txt @@ -246,13 +254,23 @@ jobs: run: >- tar -czf "codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" -C provenance . - - name: Attach qualified distributions and provenance - run: >- - gh release upload "$GITHUB_REF_NAME" dist/* - "codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" --clobber + - name: Stage draft GitHub release env: GH_TOKEN: ${{ github.token }} + run: | + artifact="codex32-${{ needs.build.outputs.version }}-provenance.tar.gz" + if draft="$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft 2>/dev/null)"; then + [ "$draft" = true ] || { echo "release is already public" >&2; exit 1; } + gh release upload "$GITHUB_REF_NAME" dist/* "$artifact" --clobber + else + gh release create "$GITHUB_REF_NAME" --draft --verify-tag \ + --title "$GITHUB_REF_NAME" --generate-notes dist/* "$artifact" + fi - name: Publish qualified distributions to PyPI uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2 with: packages-dir: dist/ + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "$GITHUB_REF_NAME" --draft=false From d6679ccb03ae47fb993c5eebee29f027840a4dce Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 24 Sep 2026 16:00:11 -0500 Subject: [PATCH 17/23] Expose vector construction helpers Promote the module-level checksum and u5 conversion interfaces needed by reference-vector authors while keeping the package-level API narrow. Document and test the supported vector workflow and justify the remaining private cross-module couplings.\n\nValidation: 866 normal and 866 optimized tests; mypy; Ruff; production size budget.\n\nfixes #49 --- docs/developer/api.md | 53 ++++++++++++++++++++++++++-- src/codex32/_cli_input.py | 4 +-- src/codex32/bech32.py | 20 ++++++----- src/codex32/bip93.py | 38 ++++++++++++-------- src/codex32/checksums.py | 20 ++++++----- src/codex32/correction.py | 22 ++++++------ src/codex32/generation.py | 6 ++-- src/codex32/indel.py | 4 +-- src/codex32/wallet.py | 4 +-- tests/test_bech32.py | 26 +++++++------- tests/test_cli.py | 8 ++--- tests/test_correction_bch.py | 6 ++-- tests/test_generation.py | 4 +-- tests/test_profiles.py | 52 ++++++++++++++++----------- tests/test_sharing.py | 8 ++--- tools/alignment_benchmark.py | 4 +-- tools/verify_correction_constants.py | 4 +-- 17 files changed, 179 insertions(+), 104 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 328597d5..614a5a1a 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -101,8 +101,57 @@ generic parse-length failure. hidden state. Private Python names are convention rather than access control. The supported -surface is the 23-name package `__all__`; direct use of private helpers is -unsupported but remains in the review scope. +top-level surface is the 23-name package `__all__`; direct use of private helpers +is unsupported but remains in the review scope. + +### Reference-vector construction API + +Reference-vector authors may use the following module-level interfaces without +depending on implementation-private names: + +- `codex32.bech32`: `CHARSET`, `bech32_decode`, `bech32_encode`, + `bech32_hrp_expand`, `chars_to_u5`, and `u5_to_chars`; +- `codex32.checksums`: `Checksum`, `CODEX32`, and `CODEX32_LONG`; +- `codex32.bip93`: `checksum_for_body_length` and + `checksum_for_encoded_length`. + +For example, a generic codex32 vector can be constructed without importing an +underscore-prefixed name: + +```python +from codex32.bech32 import bech32_encode, chars_to_u5 +from codex32.bip93 import checksum_for_body_length + +hrp = "zz" +body = chars_to_u5("0tests" + "q" * 26) +checksum = checksum_for_body_length(hrp, len(body)) +text = bech32_encode(hrp, body, checksum) +``` + +These are supported module interfaces for codec and vector work; they are not +added to the package-level `codex32.__all__`, whose backup/recovery surface stays +deliberately narrow. + +The remaining production cross-module private imports are intentional internal +couplings rather than user-facing APIs: + +- `_alignment.py`, `_competitors.py`, `correction.py`, and `indel.py` form one + bounded correction engine. Their underscored search state, views, and pruning + helpers are exchanged only inside that engine. +- `bip93.py` and `correction.py` use the shared private GF(32) arithmetic in + `gf32.py`; profile factories use `_from_parts` and profile-rule helpers to + preserve one artifact-validation path without publishing construction hooks. +- `generation.py`, `wallet.py`, and `_bitcoin_core.py` share the private BIP32 + root primitives. `_bitcoin_core.py` also consumes wallet descriptor records + while remaining the sole process/state adapter. +- `_cli_input.py` and `cli.py` share private correction/profile orchestration, + input state, and parser plumbing. Those names exist to compose the CLI, not as + a second domain API. + +Internal benchmark and verification tools may import those implementation names +when they are explicitly testing the implementation itself. Tools or examples +that model external vector construction use the supported module interfaces +above. ### Size budget diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 6090352b..b634e195 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -16,9 +16,9 @@ from codex32.bip93 import ( Secret, Share, - _checksum_for_encoded_length, _validate_basis_prefix, _validate_recovery_prefix, + checksum_for_encoded_length, parse_codex32, recover_secret, ) @@ -402,7 +402,7 @@ def _case_interpretation( candidate = None else: bits = ( - 5 * _checksum_for_encoded_length(artifact.hrp, len(artifact.text) - len(artifact.hrp) - 1).length + 5 * checksum_for_encoded_length(artifact.hrp, len(artifact.text) - len(artifact.hrp) - 1).length ) proposed = CorrectionCandidate(artifact, (), 1, 0, 0, None, capture_space_bits=bits) candidate = proposed if allowed is None or allowed(proposed) else None diff --git a/src/codex32/bech32.py b/src/codex32/bech32.py index 3cb14a89..46a072ad 100644 --- a/src/codex32/bech32.py +++ b/src/codex32/bech32.py @@ -1,6 +1,6 @@ """Bech32 character, container, and bit-conversion helpers.""" -from codex32.checksums import _Checksum +from codex32.checksums import Checksum from codex32.errors import ( InvalidCase, InvalidCharacter, @@ -19,14 +19,18 @@ def bech32_hrp_expand(hrp: str) -> list[int]: return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] -def _u5_to_chars(values: list[int] | tuple[int, ...]) -> str: +def u5_to_chars(values: list[int] | tuple[int, ...]) -> str: + """Convert 5-bit values to Bech32 characters.""" + for index, value in enumerate(values): if not 0 <= value < 32: raise InvalidCharacter(f"u5 value {value} at index {index} is outside 0..31") return "".join(CHARSET[value] for value in values) -def _chars_to_u5(value: str, first_position: int = 1) -> list[int]: +def chars_to_u5(value: str, first_position: int = 1) -> list[int]: + """Convert Bech32 characters to 5-bit values.""" + result: list[int] = [] for index, character in enumerate(value.lower()): position = CHARSET.find(character) @@ -69,18 +73,18 @@ def interpret_mixed_case(value: str, immutable_length: int) -> tuple[str, str, b return normalized, erased, uppercase -def bech32_encode(hrp: str, data: list[int], spec: _Checksum) -> str: +def bech32_encode(hrp: str, data: list[int], spec: Checksum) -> str: """Compute a Bech32 string given HRP and data values.""" checksum = spec.create(bech32_hrp_expand(hrp) + list(data)) - return f"{hrp}1{_u5_to_chars([*data, *checksum])}" + return f"{hrp}1{u5_to_chars([*data, *checksum])}" -def bech32_verify_checksum(hrp: str, data: list[int], spec: _Checksum) -> bool: +def bech32_verify_checksum(hrp: str, data: list[int], spec: Checksum) -> bool: """Verify the checksum selected by the calling application.""" return spec.verify(bech32_hrp_expand(hrp) + list(data)) -def bech32_decode(value: str, spec: _Checksum | None = None) -> tuple[str, list[int]]: +def bech32_decode(value: str, spec: Checksum | None = None) -> tuple[str, list[int]]: """Validate a Bech32 string, optionally including its checksum.""" _validate_single_case_ascii(value) separator = value.rfind("1") @@ -92,7 +96,7 @@ def bech32_decode(value: str, spec: _Checksum | None = None) -> tuple[str, list[ raise InvalidLength(f"human-readable part exceeds 83 characters ({separator})") lowered = value.lower() hrp = lowered[:separator] - data = _chars_to_u5(lowered[separator + 1 :], separator + 2) + data = chars_to_u5(lowered[separator + 1 :], separator + 2) if spec is None: return hrp, data if len(data) < spec.length or not bech32_verify_checksum(hrp, data, spec): diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 05924340..6d4837ba 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -6,13 +6,13 @@ from codex32.bech32 import ( CHARSET, - _chars_to_u5, - _u5_to_chars, bech32_decode, bech32_encode, bech32_verify_checksum, + chars_to_u5, + u5_to_chars, ) -from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum +from codex32.checksums import CODEX32, CODEX32_LONG, Checksum from codex32.errors import ( DuplicateShareIndex, ExistingTargetIndex, @@ -76,7 +76,7 @@ def __post_init__(self) -> None: def _from_symbols(cls, symbols: tuple[int, ...]) -> "Header": if len(symbols) != 6: raise InvalidLength("codex32 header must contain six symbols") - text = _u5_to_chars(symbols) + text = u5_to_chars(symbols) if text[0] not in "023456789": raise InvalidThreshold( f"The threshold must be 0 or a number from 2 through 9; found {text[0]!r}." @@ -85,25 +85,36 @@ def _from_symbols(cls, symbols: tuple[int, ...]) -> "Header": @property def _symbols(self) -> tuple[int, ...]: - return tuple(_chars_to_u5(f"{self.threshold}{self.identifier}{self.index}")) + return tuple(chars_to_u5(f"{self.threshold}{self.identifier}{self.index}")) -def _checksum_for_encoded_length(hrp: str, encoded_length: int) -> _Checksum: +def checksum_for_encoded_length(hrp: str, encoded_length: int) -> Checksum: + """Return the codex32 checksum required by an encoded HRP/data length.""" + expanded_length = 2 * len(hrp) + 1 + encoded_length if expanded_length <= 93: - return _CODEX32 + return CODEX32 if expanded_length < 96: raise InvalidLength("expanded codex32 lengths 94 and 95 are invalid") if expanded_length <= 1023: - return _CODEX32_LONG + return CODEX32_LONG raise InvalidLength("expanded codex32 codeword exceeds 1023 symbols") -def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, ...], _Checksum]: +def checksum_for_body_length(hrp: str, body_length: int) -> Checksum: + """Return the checksum required when constructing a codex32 body.""" + + expanded_body_length = 2 * len(hrp) + 1 + body_length + checksum = CODEX32 if expanded_body_length <= 80 else CODEX32_LONG + checksum_for_encoded_length(hrp, body_length + checksum.length) + return checksum + + +def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, ...], Checksum]: hrp, encoded = bech32_decode(text) if len(hrp) + 1 + len(encoded) < 21: raise InvalidLength("codex32 string must contain at least 21 characters") - checksum = _checksum_for_encoded_length(hrp, len(encoded)) + checksum = checksum_for_encoded_length(hrp, len(encoded)) body = tuple(encoded[: -checksum.length]) Header._from_symbols(body[:6]) if not bech32_verify_checksum(hrp, encoded, checksum): @@ -213,10 +224,7 @@ def _from_parts( if rules is not None: _validate_payload(rules.profile, header, payload) body = [*header._symbols, *payload] - expanded_body_length = 2 * len(normalized_hrp) + 1 + len(body) - checksum = _CODEX32 if expanded_body_length <= 80 else _CODEX32_LONG - # Validate the completed generic codeword, including the 94/95 gap. - _checksum_for_encoded_length(normalized_hrp, len(body) + checksum.length) + checksum = checksum_for_body_length(normalized_hrp, len(body)) text = bech32_encode(normalized_hrp, body, checksum) return parse_codex32(text.upper() if uppercase else text) @@ -338,7 +346,7 @@ def _interpolate_tail(share_set: _ShareSet, target: str) -> Share | Secret: value ^= _gf32_multiply(weight, row[column]) result.append(value) header = Header(share_set.threshold, share_set.identifier, target) - text = f"{share_set.hrp}1{_u5_to_chars((*header._symbols, *result))}" + text = f"{share_set.hrp}1{u5_to_chars((*header._symbols, *result))}" return parse_codex32(text.upper() if share_set.uppercase else text) diff --git a/src/codex32/checksums.py b/src/codex32/checksums.py index 19262d1a..75c0452b 100644 --- a/src/codex32/checksums.py +++ b/src/codex32/checksums.py @@ -20,7 +20,9 @@ @dataclass(frozen=True, slots=True) -class _Checksum: +class Checksum: + """Immutable checksum specification for reference-vector construction.""" + kind: str generators: tuple[int, ...] length: int @@ -49,21 +51,21 @@ def create(self, values: list[int] | tuple[int, ...]) -> list[int]: return [(residue >> (width * (self.length - 1 - index))) & mask for index in range(self.length)] -_CODEX32 = _Checksum("codex32", _CODEX32_GEN, 13, 0x10CE0795C2FD1E62A, 93) -_CODEX32_LONG = _Checksum("Long codex32", _CODEX32_LONG_GEN, 15, 0x43381E570BF4798AB26, 1023) +CODEX32 = Checksum("codex32", _CODEX32_GEN, 13, 0x10CE0795C2FD1E62A, 93) +CODEX32_LONG = Checksum("Long codex32", _CODEX32_LONG_GEN, 15, 0x43381E570BF4798AB26, 1023) # Descriptor checksum remains an independently specified, non-codex32 helper. -DESCSUM = _Checksum("Descriptor", _DESCSUM_GEN, 8, 1) +DESCSUM = Checksum("Descriptor", _DESCSUM_GEN, 8, 1) _CRC = ( None, - # ``_Checksum`` consumes input bits most-significant bit first. With its + # ``Checksum`` consumes input bits most-significant bit first. With its # implicit leading term, these generator values spell x+1, x^2+x+1, # x^3+x+1, and x^4+x+1 respectively. - _Checksum("CRC1", (1,), 1, 0), - _Checksum("CRC2", (3,), 2, 0), - _Checksum("CRC3", (3,), 3, 0), - _Checksum("CRC4", (3,), 4, 0), + Checksum("CRC1", (1,), 1, 0), + Checksum("CRC2", (3,), 2, 0), + Checksum("CRC3", (3,), 3, 0), + Checksum("CRC4", (3,), 4, 0), ) diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 63fe3484..9a902e76 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -30,21 +30,21 @@ from codex32.bech32 import ( CHARSET, - _chars_to_u5, - _u5_to_chars, _validate_single_case_ascii, bech32_hrp_expand, + chars_to_u5, interpret_mixed_case, + u5_to_chars, ) from codex32.bip93 import ( IDX_SORT, Header, Secret, Share, - _checksum_for_encoded_length, + checksum_for_encoded_length, parse_codex32, ) -from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum +from codex32.checksums import CODEX32, CODEX32_LONG, Checksum from codex32.errors import CodexError, InvalidCorrectionInput from codex32.gf32 import _inverse as _gf32_inverse from codex32.gf32 import _multiply as _gf32_multiply @@ -256,10 +256,10 @@ class _Spec: _ALIGNMENT_CACHE_SIZE = 11 -def _spec_for_checksum(checksum: _Checksum) -> _Spec: - if checksum is _CODEX32: +def _spec_for_checksum(checksum: Checksum) -> _Spec: + if checksum is CODEX32: return _SHORT_SPEC - if checksum is _CODEX32_LONG: + if checksum is CODEX32_LONG: return _LONG_SPEC raise AssertionError("registered profile selected a non-codex32 checksum") @@ -545,7 +545,7 @@ def __init__( ) -> None: normalized_hrp = hrp.value if isinstance(hrp, Profile) else hrp.lower() profile_rules = _optional_profile_rules(normalized_hrp) - checksum = _checksum_for_encoded_length(normalized_hrp, body_length) + checksum = checksum_for_encoded_length(normalized_hrp, body_length) if profile_rules is not None: profile_rules.validate_payload_length(body_length - checksum.length - 6) self.hrp = normalized_hrp @@ -599,7 +599,7 @@ def correct( return None for index, addend in result: corrected_reversed[index] ^= addend - corrected = self.prefix + _u5_to_chars(list(reversed(corrected_reversed))) + corrected = self.prefix + u5_to_chars(list(reversed(corrected_reversed))) corrected = corrected.upper() if self.uppercase else corrected try: artifact = parse_codex32(corrected) @@ -777,7 +777,7 @@ def _validate_context(context: CorrectionContext) -> None: if length < 21: raise ValueError("expected_length must permit the generic codex32 minimum length") body_length = length - len(context.hrp) - 1 - checksum = _checksum_for_encoded_length(context.hrp, body_length) + checksum = checksum_for_encoded_length(context.hrp, body_length) if body_length < checksum.length + 6: raise ValueError("expected_length must contain a header and checksum") rules = _optional_profile_rules(context.hrp) @@ -1007,7 +1007,7 @@ def correct_worksheet_residue( raise InvalidCorrectionInput("codex32 input exceeds 15 characters") try: _validate_single_case_ascii(residue) - values = list(reversed(_chars_to_u5(residue))) + values = list(reversed(chars_to_u5(residue))) except TypeError: raise except CodexError as error: diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 6da02033..a99d0ac1 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -9,7 +9,7 @@ from typing import NoReturn, SupportsIndex, cast from codex32._bip32 import _valid_root -from codex32.bech32 import CHARSET, _u5_to_chars, convertbits +from codex32.bech32 import CHARSET, convertbits, u5_to_chars from codex32.bip93 import ( IDX_SORT, Header, @@ -117,7 +117,7 @@ def _selection(threshold: int, share_count: object, indices: Sequence[str] | str def _random_identifier() -> str: - return _u5_to_chars(tuple(value & 31 for value in secrets.token_bytes(4))) + return u5_to_chars(tuple(value & 31 for value in secrets.token_bytes(4))) def _fingerprint_identifier(fingerprint: bytes) -> str: @@ -125,7 +125,7 @@ def _fingerprint_identifier(fingerprint: bytes) -> str: raise TypeError("fingerprint must be bytes") if len(fingerprint) != 4: raise ValueError("fingerprint must contain four bytes") - return _u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4])) + return u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4])) def _random_share(profile: Profile, threshold: int, identifier: str, index: str, length: int) -> Share: diff --git a/src/codex32/indel.py b/src/codex32/indel.py index 98a63698..72d3459d 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -8,7 +8,7 @@ from codex32._alignment import _IncrementalSyndromes, _View from codex32.bech32 import CHARSET, _validate_single_case_ascii -from codex32.bip93 import _checksum_for_encoded_length +from codex32.bip93 import checksum_for_encoded_length from codex32.correction import ( CorrectionCandidate, CorrectionContext, @@ -277,7 +277,7 @@ def _prepare( for shape in shapes } base = len(context.hrp) + 1 - degree = _checksum_for_encoded_length(context.hrp, target - base).length + degree = checksum_for_encoded_length(context.hrp, target - base).length return _Target(context, text, observed, immutable, target, base, degree, counts) diff --git a/src/codex32/wallet.py b/src/codex32/wallet.py index 91cdb1f2..7eec10ee 100644 --- a/src/codex32/wallet.py +++ b/src/codex32/wallet.py @@ -3,7 +3,7 @@ from typing import Literal from codex32._bip32 import _master_xprv_from_seed -from codex32.bech32 import _u5_to_chars +from codex32.bech32 import u5_to_chars from codex32.checksums import DESCSUM from codex32.profiles.ms32 import MasterSeed @@ -48,7 +48,7 @@ def _descriptor_symbols(text: str) -> list[int]: def _with_checksum(descriptor: str) -> str: - return descriptor + "#" + _u5_to_chars(DESCSUM.create(_descriptor_symbols(descriptor))) + return descriptor + "#" + u5_to_chars(DESCSUM.create(_descriptor_symbols(descriptor))) def _descriptor_records( diff --git a/tests/test_bech32.py b/tests/test_bech32.py index 6ac28b4b..4f3beac3 100644 --- a/tests/test_bech32.py +++ b/tests/test_bech32.py @@ -4,14 +4,14 @@ from codex32.bech32 import ( CHARSET, - _chars_to_u5, - _u5_to_chars, bech32_decode, bech32_encode, bech32_hrp_expand, + chars_to_u5, convertbits, + u5_to_chars, ) -from codex32.checksums import _CODEX32 +from codex32.checksums import CODEX32 from codex32.errors import ( InvalidCase, InvalidCharacter, @@ -24,33 +24,33 @@ def test_u5_character_round_trip() -> None: values = list(range(32)) - assert _u5_to_chars(values) == CHARSET - assert _chars_to_u5(CHARSET.upper()) == values + assert u5_to_chars(values) == CHARSET + assert chars_to_u5(CHARSET.upper()) == values @pytest.mark.parametrize("value", (-1, 32)) def test_u5_rejects_out_of_range_values(value: int) -> None: with pytest.raises(InvalidCharacter): - _u5_to_chars([value]) + u5_to_chars([value]) def test_lexical_parser_preserves_no_semantics() -> None: - assert bech32_decode("MS10TESTS") == ("ms", _chars_to_u5("0tests")) + assert bech32_decode("MS10TESTS") == ("ms", chars_to_u5("0tests")) def test_decoder_optionally_verifies_and_removes_checksum() -> None: - data = _chars_to_u5("0tests") - encoded = bech32_encode("ms", data, _CODEX32) + data = chars_to_u5("0tests") + encoded = bech32_encode("ms", data, CODEX32) - assert bech32_decode(encoded) == ("ms", data + _CODEX32.create(bech32_hrp_expand("ms") + data)) - assert bech32_decode(encoded, _CODEX32) == ("ms", data) + assert bech32_decode(encoded) == ("ms", data + CODEX32.create(bech32_hrp_expand("ms") + data)) + assert bech32_decode(encoded, CODEX32) == ("ms", data) damaged = encoded[:-1] + ("q" if encoded[-1] != "q" else "p") with pytest.raises(InvalidChecksum, match="invalid codex32 checksum"): - bech32_decode(damaged, _CODEX32) + bech32_decode(damaged, CODEX32) with pytest.raises(InvalidChecksum, match="invalid codex32 checksum"): - bech32_decode("ms1q", _CODEX32) + bech32_decode("ms1q", CODEX32) def test_invalid_data_character_uses_complete_one_based_position() -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ae0dc42..6377ab16 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,8 +33,8 @@ recover_secret, ) from codex32._bitcoin_core import BitcoinCore -from codex32.bech32 import _chars_to_u5, bech32_encode -from codex32.checksums import _CODEX32, _CODEX32_LONG +from codex32.bech32 import bech32_encode, chars_to_u5 +from codex32.checksums import CODEX32, CODEX32_LONG from codex32.cli import main, ms_main from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import SEED_BYTE_LENGTHS @@ -336,9 +336,9 @@ def forbidden(_seed: bytes) -> bytes: ), ) def test_check_reports_profile_lengths_for_people(hrp: str, payload_length: int, message: str) -> None: - body = _chars_to_u5("0tests" + "q" * payload_length) + body = chars_to_u5("0tests" + "q" * payload_length) expanded_body_length = 2 * len(hrp) + 1 + len(body) - checksum = _CODEX32 if expanded_body_length <= 80 else _CODEX32_LONG + checksum = CODEX32 if expanded_body_length <= 80 else CODEX32_LONG result = _invoke(["check"], bech32_encode(hrp, body, checksum)) assert result.exit_code == 2 diff --git a/tests/test_correction_bch.py b/tests/test_correction_bch.py index cd099616..61a76eba 100644 --- a/tests/test_correction_bch.py +++ b/tests/test_correction_bch.py @@ -14,7 +14,7 @@ import codex32 from codex32 import CorrectionCandidate, CorrectionContext, CorrectionEdit, Profile, correct, indel from codex32.bech32 import CHARSET -from codex32.checksums import _CODEX32, _CODEX32_LONG +from codex32.checksums import CODEX32, CODEX32_LONG from codex32.correction import ( _LONG_SPEC, _SHORT_SPEC, @@ -59,8 +59,8 @@ def _pack(values: tuple[int, ...]) -> int: def test_p70_target_constants_match_checksum_layer() -> None: - assert _pack(_SHORT_SPEC.target) == _CODEX32.constant - assert _pack(_LONG_SPEC.target) == _CODEX32_LONG.constant + assert _pack(_SHORT_SPEC.target) == CODEX32.constant + assert _pack(_LONG_SPEC.target) == CODEX32_LONG.constant def test_frozen_bch_constants_are_reproducible() -> None: diff --git a/tests/test_generation.py b/tests/test_generation.py index f54dc425..2ce8ab35 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -22,7 +22,7 @@ parse_codex32, recover_secret, ) -from codex32.bech32 import _u5_to_chars +from codex32.bech32 import u5_to_chars from codex32.errors import ( CeremonyStateError, CodexError, @@ -392,7 +392,7 @@ def test_resharing_preserves_nonzero_core_lightning_padding() -> None: source = parse_codex32(VECTOR_6["codex32_peev"]) assert isinstance(source, CoreLightningSecret) payload = (*source.payload_symbols[:-1], source.payload_symbols[-1] | 15) - nonzero = parse_codex32(oracle_encode("cl", "0peevs" + _u5_to_chars(payload))) + nonzero = parse_codex32(oracle_encode("cl", "0peevs" + u5_to_chars(payload))) assert isinstance(nonzero, CoreLightningSecret) secret, shares = _complete( CreationCeremony.from_secret(nonzero, threshold=2, indices="ac", identifier="name") diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 3fbaca94..e59474a8 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -20,12 +20,13 @@ ) from codex32.bech32 import ( CHARSET, - _chars_to_u5, bech32_decode, + bech32_encode, bech32_hrp_expand, + chars_to_u5, ) -from codex32.bip93 import _checksum_for_encoded_length -from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum +from codex32.bip93 import checksum_for_body_length, checksum_for_encoded_length +from codex32.checksums import CODEX32, CODEX32_LONG, Checksum from codex32.errors import ( InvalidCase, InvalidCharacter, @@ -45,6 +46,17 @@ def _payload(data: bytes, padding: int) -> str: return "".join(CHARSET[(combined >> (5 * (count - 1 - index))) & 31] for index in range(count)) +def test_supported_reference_vector_api_uses_public_module_names() -> None: + hrp = "zz" + body = chars_to_u5("0tests" + "q" * 26) + checksum = checksum_for_body_length(hrp, len(body)) + text = bech32_encode(hrp, body, checksum) + + artifact = parse_codex32(text) + assert artifact.hrp == hrp + assert artifact.text == text + + def test_every_supported_ms_size_and_legal_parsed_padding() -> None: for byte_length in SEED_BYTE_LENGTHS: data = bytes((index * 29 + byte_length) % 256 for index in range(byte_length)) @@ -60,10 +72,10 @@ def test_supported_factories_use_exact_bip93_lengths() -> None: artifacts = tuple(MasterSeed.from_seed(bytes(size), identifier="test") for size in SEED_BYTE_LENGTHS) assert tuple(len(artifact.text) for artifact in artifacts) == TEXT_LENGTHS assert all( - _checksum_for_encoded_length("ms", len(bech32_decode(artifact.text)[1])) is _CODEX32 + checksum_for_encoded_length("ms", len(bech32_decode(artifact.text)[1])) is CODEX32 for artifact in artifacts[:-1] ) - assert _checksum_for_encoded_length("ms", len(bech32_decode(artifacts[-1].text)[1])) is _CODEX32_LONG + assert checksum_for_encoded_length("ms", len(bech32_decode(artifacts[-1].text)[1])) is CODEX32_LONG @pytest.mark.parametrize(("seed_hex", "text"), BIP93_ADDITIONAL_MASTER_SEEDS) @@ -95,27 +107,27 @@ def test_expanded_codeword_boundaries() -> None: first_long = _oracle_encode("ms", header + "q" * 70) assert len(bech32_hrp_expand("ms")) + len(bech32_decode(max_regular)[1]) == 93 assert len(bech32_hrp_expand("ms")) + len(bech32_decode(first_long)[1]) == 96 - assert _checksum_for_encoded_length("ms", len(bech32_decode(max_regular)[1])) is _CODEX32 - assert _checksum_for_encoded_length("ms", len(bech32_decode(first_long)[1])) is _CODEX32_LONG + assert checksum_for_encoded_length("ms", len(bech32_decode(max_regular)[1])) is CODEX32 + assert checksum_for_encoded_length("ms", len(bech32_decode(first_long)[1])) is CODEX32_LONG for body_length in (74, 75): - body = _chars_to_u5(header + "q" * (body_length - len(header))) - checksum = _CODEX32_LONG.create(bech32_hrp_expand("ms") + body) + body = chars_to_u5(header + "q" * (body_length - len(header))) + checksum = CODEX32_LONG.create(bech32_hrp_expand("ms") + body) invalid = "ms1" + "".join(CHARSET[value] for value in body + checksum) with pytest.raises(InvalidLength): - _checksum_for_encoded_length("ms", len(bech32_decode(invalid)[1])) + checksum_for_encoded_length("ms", len(bech32_decode(invalid)[1])) def test_expanded_codeword_upper_bound() -> None: - max_body = _chars_to_u5("0tests" + "q" * 997) + max_body = chars_to_u5("0tests" + "q" * 997) max_text = _oracle_encode("ms", "".join(CHARSET[value] for value in max_body)) assert len(bech32_hrp_expand("ms")) + len(bech32_decode(max_text)[1]) == 1023 - assert _checksum_for_encoded_length("ms", len(bech32_decode(max_text)[1])) is _CODEX32_LONG + assert checksum_for_encoded_length("ms", len(bech32_decode(max_text)[1])) is CODEX32_LONG - oversized_body = _chars_to_u5("0tests" + "q" * 998) + oversized_body = chars_to_u5("0tests" + "q" * 998) oversized = _oracle_encode("ms", "".join(CHARSET[value] for value in oversized_body)) with pytest.raises(InvalidLength): - _checksum_for_encoded_length("ms", len(bech32_decode(oversized)[1])) + checksum_for_encoded_length("ms", len(bech32_decode(oversized)[1])) def test_checksum_is_verified_before_unknown_hrp_dispatch() -> None: @@ -172,12 +184,12 @@ def test_core_lightning_constructor_and_parsed_padding() -> None: @pytest.mark.parametrize( ("value", "checksum", "minimum", "maximum"), [ - *((value, _CODEX32, 0, 80) for value in VALID_CODEX32), - *((value, _CODEX32_LONG, 81, 1008) for value in VALID_CODEX32_LONG), + *((value, CODEX32, 0, 80) for value in VALID_CODEX32), + *((value, CODEX32_LONG, 81, 1008) for value in VALID_CODEX32_LONG), ], ) def test_official_generic_checksum_vectors_at_codec_level( - value: str, checksum: _Checksum, minimum: int, maximum: int + value: str, checksum: Checksum, minimum: int, maximum: int ) -> None: hrp, encoded = bech32_decode(value) expanded_length = len(bech32_hrp_expand(hrp)) + len(encoded) @@ -190,12 +202,12 @@ def test_official_generic_checksum_vectors_at_codec_level( @pytest.mark.parametrize( ("value", "checksum", "minimum", "maximum"), [ - *((value, _CODEX32, 0, 80) for value in INVALID_CODEX32), - *((value, _CODEX32_LONG, 81, 1008) for value in INVALID_CODEX32_LONG), + *((value, CODEX32, 0, 80) for value in INVALID_CODEX32), + *((value, CODEX32_LONG, 81, 1008) for value in INVALID_CODEX32_LONG), ], ) def test_official_invalid_generic_checksum_vectors( - value: str, checksum: _Checksum, minimum: int, maximum: int + value: str, checksum: Checksum, minimum: int, maximum: int ) -> None: try: hrp, encoded = bech32_decode(value) diff --git a/tests/test_sharing.py b/tests/test_sharing.py index 567342bc..73ec737d 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -17,9 +17,9 @@ parse_codex32, recover_secret, ) -from codex32.bech32 import CHARSET, _u5_to_chars +from codex32.bech32 import CHARSET, u5_to_chars from codex32.bip93 import IDX_SORT -from codex32.checksums import _Checksum +from codex32.checksums import Checksum from codex32.errors import ( DuplicateShareIndex, ExistingTargetIndex, @@ -37,7 +37,7 @@ def _payload_text(artifact: Share | MasterSeed | CoreLightningSecret) -> str: - return _u5_to_chars(artifact.payload_symbols) + return u5_to_chars(artifact.payload_symbols) def _ms_basis(byte_length: int = 16, threshold: int = 2): @@ -89,7 +89,7 @@ def test_interpolation_does_not_create_a_checksum( def fail_create(*_args: object, **_kwargs: object) -> tuple[int, ...]: raise AssertionError("sharing must interpolate the existing checksum") - monkeypatch.setattr(_Checksum, "create", fail_create) + monkeypatch.setattr(Checksum, "create", fail_create) recovered = recover_secret([a, c]) # type: ignore[list-item] derived = derive_share([a, c], "d") # type: ignore[list-item] assert parse_codex32(recovered.text) == recovered diff --git a/tools/alignment_benchmark.py b/tools/alignment_benchmark.py index 4004c9a8..002569e9 100644 --- a/tools/alignment_benchmark.py +++ b/tools/alignment_benchmark.py @@ -17,7 +17,7 @@ from codex32._alignment import _IncrementalSyndromes from codex32.bech32 import CHARSET, bech32_hrp_expand -from codex32.bip93 import _checksum_for_encoded_length +from codex32.bip93 import checksum_for_encoded_length from codex32.correction import ( _LONG_SPEC, _SHORT_SPEC, @@ -98,7 +98,7 @@ def public_case( "observed_body_length": len(text) - len(hrp + "1"), "target_body_lengths": {str(c.expected_length): c.expected_length - len(hrp + "1") for c in contexts}, "expanded_length": len(bech32_hrp_expand(hrp)) + body, - "checksum_symbols": _checksum_for_encoded_length(hrp, body).length, + "checksum_symbols": checksum_for_encoded_length(hrp, body).length, "unknown": unknown, "delta": delta, "explicit_erasures": erasures, diff --git a/tools/verify_correction_constants.py b/tools/verify_correction_constants.py index c1665ea4..44bd679f 100644 --- a/tools/verify_correction_constants.py +++ b/tools/verify_correction_constants.py @@ -2,7 +2,7 @@ from functools import reduce -from codex32.bech32 import CHARSET, _chars_to_u5 +from codex32.bech32 import CHARSET, chars_to_u5 from codex32.correction import ( _LONG_SPEC, _SHORT_SPEC, @@ -42,7 +42,7 @@ def _derive(base: int, first_root: int, target: str) -> _Spec: return _Spec( base, first_root, - tuple(_chars_to_u5(target)), + tuple(chars_to_u5(target)), roots, reduce(_monic_mul, map(_minimal_poly, roots)), _order(base), From cde312b6ab5c56b90426bdcdb1aa721199c46a87 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 27 Sep 2026 23:24:36 -0500 Subject: [PATCH 18/23] api: Reject Unicode lookalikes in u5 conversion The newly supported chars_to_u5 interface must not case-fold Unicode into valid Bech32 symbols. Lower only ASCII characters so lookalikes such as the Kelvin sign remain invalid, matching the normalization boundary enforced elsewhere. --- src/codex32/_bitcoin_core.py | 4 ++-- src/codex32/_cli_input.py | 7 +------ src/codex32/bech32.py | 6 +++++- src/codex32/bip93.py | 13 +++++-------- src/codex32/cli.py | 2 +- tests/test_bech32.py | 5 +++++ 6 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/codex32/_bitcoin_core.py b/src/codex32/_bitcoin_core.py index 4fc85e51..dba61edf 100644 --- a/src/codex32/_bitcoin_core.py +++ b/src/codex32/_bitcoin_core.py @@ -12,7 +12,7 @@ from typing import Literal from codex32._bip32 import _master_xprv_from_seed -from codex32.bech32 import _u5_to_chars, convertbits +from codex32.bech32 import convertbits, u5_to_chars from codex32.generation import _fingerprint_identifier from codex32.profiles.ms32 import MasterSeed from codex32.wallet import _descriptor_records @@ -92,7 +92,7 @@ def identifier_origin(secret: MasterSeed, fingerprint: bytes) -> str | None: ripemd_unavailable = True continue derived = convertbits(hashed, 8, 5, pad=True) - if identifier[:3] == _u5_to_chars(tuple(derived[:3])): + if identifier[:3] == u5_to_chars(tuple(derived[:3])): return name return "Bails check unavailable" if ripemd_unavailable else None diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index b634e195..72fbbb2a 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -12,7 +12,7 @@ from time import monotonic from typing import Any, Literal, cast -from codex32.bech32 import interpret_mixed_case +from codex32.bech32 import _ascii_lower, interpret_mixed_case from codex32.bip93 import ( Secret, Share, @@ -66,11 +66,6 @@ class InteractiveConfirmationRequired(Exception): pass -def _ascii_lower(value: str) -> str: - """Lowercase ASCII letters without normalizing Unicode lookalikes.""" - return "".join(character.lower() if character.isascii() else character for character in value) - - def _confirmation_input(prompt: str) -> str: if sys.stdin.isatty(): return _editable_input(prompt) diff --git a/src/codex32/bech32.py b/src/codex32/bech32.py index 46a072ad..9ee83bac 100644 --- a/src/codex32/bech32.py +++ b/src/codex32/bech32.py @@ -19,6 +19,10 @@ def bech32_hrp_expand(hrp: str) -> list[int]: return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] +def _ascii_lower(value: str) -> str: + return "".join(character.lower() if character.isascii() else character for character in value) + + def u5_to_chars(values: list[int] | tuple[int, ...]) -> str: """Convert 5-bit values to Bech32 characters.""" @@ -32,7 +36,7 @@ def chars_to_u5(value: str, first_position: int = 1) -> list[int]: """Convert Bech32 characters to 5-bit values.""" result: list[int] = [] - for index, character in enumerate(value.lower()): + for index, character in enumerate(_ascii_lower(value)): position = CHARSET.find(character) if position < 0: label = "Apostrophe (')" if character == "'" else f"The character {character!r}" diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 6d4837ba..bd743eb7 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -92,20 +92,17 @@ def checksum_for_encoded_length(hrp: str, encoded_length: int) -> Checksum: """Return the codex32 checksum required by an encoded HRP/data length.""" expanded_length = 2 * len(hrp) + 1 + encoded_length - if expanded_length <= 93: - return CODEX32 - if expanded_length < 96: + if 94 <= expanded_length <= 95: raise InvalidLength("expanded codex32 lengths 94 and 95 are invalid") - if expanded_length <= 1023: - return CODEX32_LONG - raise InvalidLength("expanded codex32 codeword exceeds 1023 symbols") + if expanded_length > 1023: + raise InvalidLength("expanded codex32 codeword exceeds 1023 symbols") + return CODEX32 if expanded_length <= 93 else CODEX32_LONG def checksum_for_body_length(hrp: str, body_length: int) -> Checksum: """Return the checksum required when constructing a codex32 body.""" - expanded_body_length = 2 * len(hrp) + 1 + body_length - checksum = CODEX32 if expanded_body_length <= 80 else CODEX32_LONG + checksum = CODEX32 if 2 * len(hrp) + 1 + body_length <= 80 else CODEX32_LONG checksum_for_encoded_length(hrp, body_length + checksum.length) return checksum diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 68ca4f12..38b030d8 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -20,7 +20,6 @@ from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, - _ascii_lower, _card_text, _case_interpretation, _confirm_correction, @@ -35,6 +34,7 @@ from codex32._cli_input import read_artifacts as _artifacts from codex32._cli_input import read_text as _text from codex32._cli_parser import parser as _parser +from codex32.bech32 import _ascii_lower from codex32.bip93 import ( IDX_SORT, Header, diff --git a/tests/test_bech32.py b/tests/test_bech32.py index 4f3beac3..7087348f 100644 --- a/tests/test_bech32.py +++ b/tests/test_bech32.py @@ -28,6 +28,11 @@ def test_u5_character_round_trip() -> None: assert chars_to_u5(CHARSET.upper()) == values +def test_u5_character_conversion_does_not_fold_unicode_lookalikes() -> None: + with pytest.raises(InvalidCharacter, match="K"): + chars_to_u5("K") + + @pytest.mark.parametrize("value", (-1, 32)) def test_u5_rejects_out_of_range_values(value: int) -> None: with pytest.raises(InvalidCharacter): From 00aae9baedd77a43ec626e078a6fe25118ea5650 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sun, 4 Oct 2026 18:15:56 -0500 Subject: [PATCH 19/23] docs: Record adversarial finding dispositions Map all supplied original and consolidated findings to confirmed fixes, focused PRs, intentional contracts, deferred work, and claims that did not reproduce. Preserve the original review qualifications and identify the remaining existing-secret root gap covered by PR #126. Document the stricter threshold grammar, Core-independent fallback, review-line metric including docstrings, enforcement location, and optimized-validation contract. Compact one internal tuple assignment without changing its AST; this leaves the audited stack room for the four-line Core UX delta without raising the library cap. Verified current GitHub CI and reviews, compared source and regressions, checked document links and whitespace, and confirmed Ruff plus AST equivalence for the mechanical source cleanup. AI-assisted maintainer-requested audit closure; refs #20 and #38. --- docs/audits/adversarial-2026-10-04.md | 125 ++++++++++++++++++++++++++ docs/developer/api.md | 10 ++- docs/user/guide.md | 4 + src/codex32/generation.py | 6 +- 4 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 docs/audits/adversarial-2026-10-04.md diff --git a/docs/audits/adversarial-2026-10-04.md b/docs/audits/adversarial-2026-10-04.md new file mode 100644 index 00000000..416e1a22 --- /dev/null +++ b/docs/audits/adversarial-2026-10-04.md @@ -0,0 +1,125 @@ +# Supplied adversarial reviews: finding disposition + +This is a traceability snapshot, not a release approval. The supplied reviews +examined `reviewability-v1` at `c118a83`; the branch now points to +`5325eb7543e8545b321b0512b38c896b1e269ece`. Unmerged fixes are identified by PR, +not by similarly named sibling branches. Issue +[#20](https://github.com/BenWestgate/python-codex32/issues/20) tracks audit closure; +[#38](https://github.com/BenWestgate/python-codex32/issues/38) owns integration and +the final reviewer handoff. + +## Inputs and qualifications + +The four supplied files are identified by their SHA-256 hashes. D means DeepSeek, +G means GLM, K means Kimi, and C means the consolidated report. + +| File | SHA-256 | +|---|---| +| `codex32-review-DeepSeek_V4.1_Flash.md` | `94398728d93643e609987930c7fb38ea37b828fab9071d61aa1c255e5316450a` | +| `codex32-review-GLM5.3_Flash.md` | `01569d9c3a6fbd1aae6f3a92d523ce9cafb50a66d16c56e91b1ff87030f5af11` | +| `codex32-review-KimiK3_High.md` | `139ac9366b6f4c72f33b3d17e649e88d68e266c19345102084883aa850ab2977` | +| `consolidated-review.md` | `458e647b7cd14f27f71809fdc1b240c8fd4b3cfbfb06e2005b0eb0a4d6767ea9` | + +The originals qualify several claims more narrowly than the consolidation: + +- Kimi calls the Core requirement consistent with the threat model and asks for + a documentation hint. GLM recommends a warning-only fallback. The maintainer + chose Core-backed fingerprint checks for `ms32`; generic `codex32` is the + offline fallback. This is an intentional boundary with a UX/documentation gap. +- Kimi reviewed live Core and GUI paths statically. Its restore recommendation + exceeded the then-current operator-comparison contract. The accepted release + requirement is now verification before mutation for accident safety, using + an independent record or the explicit no-record confirmation. Malicious share + replacement and encrypted descriptors remain separate human-planned work in + [#55](https://github.com/BenWestgate/python-codex32/issues/55). +- Kimi distinguishes accidental `repr()` disclosure from deliberate `str()` + export. The accepted project contract redacts both; `.text` is explicit export. +- The consolidation's shared-baseline table does not prove each original ran + every listed command. In particular, only Kimi explicitly records the BCH + constant verifier. Historical reviewer claims are not new validation evidence. +- The consolidation calls the setup defect independently found by two reviews + in its summary, although all three originals contain it. +- GLM's secondary dependency-install ordering claim did not reproduce with pip's + normal build isolation. The Astra-placeholder claim is stale. Neither justifies + a new defect issue; the unwanted repository configuration was removed anyway. + +## Numbered findings + +“Merged” below means present on the branch identified above. “PR” means a focused +patch exists but still needs human integration. Duplicate IDs share one row. + +| Consolidated ID | Original sources | Assessment and current coverage | +|---|---|---| +| BL1 | D-B1, G-1, K-F4 | Confirmed developer-install defect; #3/#6 and merged #7 remove the test-only `bip32`/secp256k1 dependency. #115 corrects contributor setup. | +| BL2 | D-B2 | Confirmed missing BSD notice; merged #15 includes `LICENSES` in wheel and sdist. | +| H1 | K-F2 | Confirmed default-rendering leak; #22 and merged #25 redact `str()` and `repr()`, including nested correction candidates. | +| H2 | K-F3 | Accepted accident-safety gate; #26/#30, CLI #57/#80/#81, GUI #118. #81 verifies existing seeds before replacement cards; #80 distinguishes an unavailable Bails check. #55 is a separate planning issue, not a missing v1 PR. | +| H3 | K-F1 | Confirmed HRP-boundary defect; #32/#33 enforce 83 characters in parsing and correction contexts. Generalized-HRP draft vectors at `BenWestgate/bips` PR #2, commit `01374bf`, explicitly allow 83 and reject 84; 1023 is the expanded-codeword bound. | +| M1 | G-2 | Confirmed standalone mixed-case regression; #37 and merged #42, including the required-preflight seed preservation follow-up. Interactive recovery already interpreted case. | +| M2 | G-3, K-F9 | Intentional Core boundary; #99 fixes misleading Core diagnostics/preflight. This change documents the generic offline fallback. Do not introduce Python secp256k1 or silently discard fingerprints. | +| M3 | K-F6 | Intended switch to a supplied complete S, with missing notice; #95 announces it after partial share entry. Rejecting S would change the chosen behavior. | +| M4 | D-D4, G-4 | Confirmed review-process gap; #38 requires the final `docs/developer/reviewing.md`, pinned candidate, reading order, reproducible evidence, GUI scope, and human integration order. This snapshot supplies finding provenance but does not replace that frozen-tip handoff. | +| M5 | D-D3 | Confirmed inherited contributor boilerplate; focused #115 replaces it and restores licensing/attribution terms. | +| M6 | D-D1 | Confirmed historical measurements presented as current; focused #116 labels the `6802d86` snapshot and raw evidence. Re-running a benchmark cannot reproduce that host's old measurements. | +| M7 | D process notes, K-F5 | Optimized tests and constant verification are in merged #47/#40. Dormant fuzz harnesses remain deferred until an engine, corpus, and budget are selected; no claimed fuzzing result is inferred from their presence. | +| M8 | K-F7 | Partially fixed by merged #16 for supplied raw bytes. The existing-secret ceremony still bypassed root validation; #125 and focused PR #126 cover the remaining API path. Parsing remains format validation; CL secrets have no BIP32 requirement. | +| M9 | K-F8 | Intentional stricter threshold grammar: `1` is rejected even for S. The API guide now records this reference-decoder divergence. | +| L1 | D-C3 | Confirmed exit-status collision; #39 and merged #45 distinguish valid `0`, emitted suggestion `1`, usage/input syntax `2`, and no usable suggestion `3`. Suggestions remain nonzero. | +| L2 | D-C1/C2, G-6 | Confirmed unreachable guards/always-false field; merged #46 removes the original findings. #105 removes the later redundant recovery/search paths. | +| L3 | D-C5 | No demonstrated input-validation bypass: current asserts narrow internal types/state after explicit validation. The API guide states the boundary; optimized CI guards it. No blanket assert replacement is warranted. | +| L4 | D-D6 | Intentional historical benchmark evidence, retained by merged #14; #116 labels its age and limits. No fresh timing guarantee follows from it. | +| L5 | D process notes, K-F9 | Maintainer-authorized library cap is `<5200`; GUI is separately `<2250` in #118. The API guide defines recursive nonblank/non-comment counting, including docstrings, and names the enforcement test. | +| L6 | D-D5 | Human integration/authorship work in #38. Preserve dependent PR history until the maintainer settles the stack and performs any required rewrite/squash. | + +## Nits and secondary claims + +| Finding | Sources | Disposition | +|---|---|---| +| Package export count says 25 instead of 24 | D-D2 | #64 removes obsolete `core_descriptors`; #53 documents the resulting 23-name package surface and supported module-level vector helpers. The public API regression rejects the removed export. | +| Test fake accepts obsolete `private=` | D-C4 | One-line #117 makes `_FakeBitcoinCore.initialize` match the production call shape. | +| Stale `MANIFEST.in` provenance exclusion | D notes, K-F9 | Already removed on the current branch (`d6a9f99`); keep `MANIFEST.in` because it still selects license, test, tool, and documentation files for the sdist. | +| Ignored provenance/plans | D notes | Intentional local-only unfinished work in `.gitignore`/`AGENTS.md`; finished reports belong in `docs/audits`, including #23 and this snapshot. #38 links the local cleanup checklist for agents. | +| Beta classifier with an rc version | G-6 | A maturity classifier and prerelease version are separate metadata; no demonstrated defect. Final stable metadata belongs to artifact qualification #5/#52. | +| Extra blank line in `pyproject.toml` | G-6 | No longer present on the current branch. | +| Repository Codex overrides | D notes | #41 and merged #48 remove the configuration. Whether the old named model exists is no longer relevant to the fix. | +| Core RC documentation links | D notes | #93 points to the maintained offline-signing tutorial, as chosen by the maintainer. Stable/signed-RC monitoring is separate from audit closure. | +| Budget-test discoverability | K-F9 | Direct test pointer added to the API guide; #38's final handoff must also name both library and GUI checks. No test-file move is needed. | +| User-facing vector helpers require underscores | G-5 | #49/#53 expose the required codec/checksum helpers at their owning modules and document internal couplings. Further architecture work is explicitly separate. | +| Stale/overlapping branch names | D notes | Use PR numbers and commit IDs as provenance. Pruning branches is housekeeping and does not establish or invalidate a fix. | +| Dependency-install ordering | G-1 secondary note, C rejected claims | Not reproduced under ordinary pip build isolation; no new issue. | + +## Verification and remaining release work + +On 2026-10-04, GitHub reported successful checks and a Codex review tied to the +current commit for #12/#13/#33, #52/#53/#57/#59/#64, #80/#81/#95/#99/#105, +#115/#116/#117, and the focused GUI #66/#77/#78/#118/#119 changes. These include +clearly disclosed Codex-generated reviews posted through the maintainer's +account; they are not human approvals or proof of an independent Claude review. +The PR #65 parent has a real open refresh-mode finding fixed by stacked #66; +review and integrate them together. GitHub's “blocked” status can also reflect +signature/authorship or branch rules even when checks pass. + +Focused local checks at #103 commit `2924f5f` passed for default rendering, +record mismatch before wallet calls, hidden fingerprint, recordless decline and +acceptance, fresh creation, existing creation, and supplied-byte root rejection. +The complete PR #103 security diff scan at behavioral parent `d9ce204`, against +`115f2c2`, found no reportable finding; its later commit changes documentation +only. These checks do not prove the later existing-secret API root path fixed. + +#93 and #97 were updated after new reviews: the offline boot USB is distinct +from the PSBT transfer medium; the FAQ distinguishes unshared fingerprint-derived +identifiers and carries #96's identical 256-bit card so its link resolves. Their +new commits need the applicable checks and review recorded before integration. + +The remaining order is the live #38 stack: restore and Core UX, foundation/API, +audit/release support, user documentation, then the GUI replay. The new #125 root +fix in #126 must be included before freeze. #115/#116/#117 supply the mechanical +cleanup; this patch supplies the remaining contract clarifications. The local +ignored `docs/planning/v1-pre-review-cleanup.md` is a working checklist, not a +shipped report. + +Human review/integration and required signatures remain. The final candidate +must then receive the pinned reviewer handoff, artifact qualification, Tails +rendering/manual GUI-to-Core checks, and a fresh adversarial pass covering the +library, CLI, GUI, and recovery documentation. Until those succeed, “all +material validated findings resolved in the release candidate” is unproven. diff --git a/docs/developer/api.md b/docs/developer/api.md index 614a5a1a..d3940247 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -156,10 +156,17 @@ above. ### Size budget V1 keeps the installed package below 5,200 logical review lines, excluding -blank and comment-only lines while counting subpackages recursively. Changing +blank and comment-only lines while counting subpackages recursively, including +docstrings. The enforcement is +`tests/test_cli.py::test_production_size_budgets_are_enforced`. Changing the budget requires explicit review and authorization together with the matching documentation and enforcement update. +Public input checks use explicit exceptions. Remaining production `assert` +statements narrow internal types or check state already established by validation; +they are not input or wallet-identity gates. CI runs the suite under `python -O` +as well as normally, so input rejection must continue to work with asserts removed. + ## Profile and opaque-HRP capabilities There is no runtime registration. An unknown HRP uses generic codex32 rules and @@ -732,6 +739,7 @@ These choices are not presented as BIP93 requirements. | Decision | Reason | |---|---| +| reject threshold digit `1`, including for S | follows the container's `0` or `2`–`9` header grammar; deliberately stricter than the reference decoder's ignored-threshold rule for unshared secrets | | `ms` accepts only six seed sizes | follows the frozen PR #2258 profile with no legacy decoder | | random electronic output indices | reduces canonical index disclosure; explicit indices preserve requested order | | generation-only CRC padding | small recovery hint; not validity or share semantics | diff --git a/docs/user/guide.md b/docs/user/guide.md index edcef9a9..46d33a8d 100644 --- a/docs/user/guide.md +++ b/docs/user/guide.md @@ -3,6 +3,10 @@ `codex32` checks, corrects, recovers, and derives shares across application prefixes. `ms32` provides Bitcoin master-seed workflows, including secure creation. Use `ms32 create` for new Bitcoin backups. +`ms32 secret` and `ms32 share` require local Bitcoin Core for fingerprint-aware +output; `ms32 correct` requires it when repairing Bitcoin recovery material. +`ms32 check` is Core-independent. For recovery, share derivation, or correction +without Core, use the corresponding generic `codex32` command. BIP39 worksheet profiles are [not recommended for creation](https://secretcodex32.com/docs/index.html), but existing backups can be recovered and corrected. diff --git a/src/codex32/generation.py b/src/codex32/generation.py index a99d0ac1..4b898f90 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -229,11 +229,7 @@ def _start( else: reheadered = _from_parts(profile, Header(threshold, identifier, "s"), secret.payload_symbols) assert isinstance(reheadered, (MasterSeed, CoreLightningSecret)) - self._secret, self._basis, self._direct_count = ( - reheadered, - [reheadered], - threshold - 1, - ) + self._secret, self._basis, self._direct_count = reheadered, [reheadered], threshold - 1 return self @classmethod From 48e2fc04a2e97597a43405da1f4d02045594529a Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sun, 4 Oct 2026 19:52:13 -0500 Subject: [PATCH 20/23] docs: Move audit ledger under security docs --- docs/security/adversarial-2026-10-04.md | 126 ++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/security/adversarial-2026-10-04.md diff --git a/docs/security/adversarial-2026-10-04.md b/docs/security/adversarial-2026-10-04.md new file mode 100644 index 00000000..25bd4e54 --- /dev/null +++ b/docs/security/adversarial-2026-10-04.md @@ -0,0 +1,126 @@ +# Supplied adversarial reviews: finding disposition + +This is a traceability snapshot, not a release approval. The supplied reviews +examined `reviewability-v1` at `c118a83`; the branch now points to +`5325eb7543e8545b321b0512b38c896b1e269ece`. Unmerged fixes are identified by PR, +not by similarly named sibling branches. Issue +[#20](https://github.com/BenWestgate/python-codex32/issues/20) tracks audit closure; +[#38](https://github.com/BenWestgate/python-codex32/issues/38) owns integration and +the final reviewer handoff. + +## Inputs and qualifications + +The four supplied files are identified by their SHA-256 hashes. D means DeepSeek, +G means GLM, K means Kimi, and C means the consolidated report. + +| File | SHA-256 | +|---|---| +| `codex32-review-DeepSeek_V4.1_Flash.md` | `94398728d93643e609987930c7fb38ea37b828fab9071d61aa1c255e5316450a` | +| `codex32-review-GLM5.3_Flash.md` | `01569d9c3a6fbd1aae6f3a92d523ce9cafb50a66d16c56e91b1ff87030f5af11` | +| `codex32-review-KimiK3_High.md` | `139ac9366b6f4c72f33b3d17e649e88d68e266c19345102084883aa850ab2977` | +| `consolidated-review.md` | `458e647b7cd14f27f71809fdc1b240c8fd4b3cfbfb06e2005b0eb0a4d6767ea9` | + +The originals qualify several claims more narrowly than the consolidation: + +- Kimi calls the Core requirement consistent with the threat model and asks for + a documentation hint. GLM recommends a warning-only fallback. The maintainer + chose Core-backed fingerprint checks for `ms32`; generic `codex32` is the + offline fallback. This is an intentional boundary with a UX/documentation gap. +- Kimi reviewed live Core and GUI paths statically. Its restore recommendation + exceeded the then-current operator-comparison contract. The accepted release + requirement is now verification before mutation for accident safety, using + an independent record or the explicit no-record confirmation. Malicious share + replacement and encrypted descriptors remain separate human-planned work in + [#55](https://github.com/BenWestgate/python-codex32/issues/55). +- Kimi distinguishes accidental `repr()` disclosure from deliberate `str()` + export. The accepted project contract redacts both; `.text` is explicit export. +- The consolidation's shared-baseline table does not prove each original ran + every listed command. In particular, only Kimi explicitly records the BCH + constant verifier. Historical reviewer claims are not new validation evidence. +- The consolidation calls the setup defect independently found by two reviews + in its summary, although all three originals contain it. +- GLM's secondary dependency-install ordering claim did not reproduce with pip's + normal build isolation. The Astra-placeholder claim is stale. Neither justifies + a new defect issue; the unwanted repository configuration was removed anyway. + +## Numbered findings + +“Merged” below means present on the branch identified above. “PR” means a focused +patch exists but still needs human integration. Duplicate IDs share one row. + +| Consolidated ID | Original sources | Assessment and current coverage | +|---|---|---| +| BL1 | D-B1, G-1, K-F4 | Confirmed developer-install defect; #3/#6 and merged #7 remove the test-only `bip32`/secp256k1 dependency. #115 corrects contributor setup. | +| BL2 | D-B2 | Confirmed missing BSD notice; merged #15 includes `LICENSES` in wheel and sdist. | +| H1 | K-F2 | Confirmed default-rendering leak; #22 and merged #25 redact `str()` and `repr()`, including nested correction candidates. | +| H2 | K-F3 | Accepted accident-safety gate; #26/#30, CLI #57/#80/#81, GUI #118. #81 verifies existing seeds before replacement cards; #80 distinguishes an unavailable Bails check. #55 is a separate planning issue, not a missing v1 PR. | +| H3 | K-F1 | Confirmed HRP-boundary defect; #32/#33 enforce 83 characters in parsing and correction contexts. Generalized-HRP draft vectors at `BenWestgate/bips` PR #2, commit `01374bf`, explicitly allow 83 and reject 84; 1023 is the expanded-codeword bound. | +| M1 | G-2 | Confirmed standalone mixed-case regression; #37 and merged #42, including the required-preflight seed preservation follow-up. Interactive recovery already interpreted case. | +| M2 | G-3, K-F9 | Intentional Core boundary; #99 fixes misleading Core diagnostics/preflight. This change documents the generic offline fallback. Do not introduce Python secp256k1 or silently discard fingerprints. | +| M3 | K-F6 | Intended switch to a supplied complete S, with missing notice; #95 announces it after partial share entry. Rejecting S would change the chosen behavior. | +| M4 | D-D4, G-4 | Confirmed review-process gap; #38 requires the final `docs/developer/reviewing.md`, pinned candidate, reading order, reproducible evidence, GUI scope, and human integration order. This snapshot supplies finding provenance but does not replace that frozen-tip handoff. | +| M5 | D-D3 | Confirmed inherited contributor boilerplate; focused #115 replaces it and restores licensing/attribution terms. | +| M6 | D-D1 | Confirmed historical measurements presented as current; focused #116 labels the `6802d86` snapshot and raw evidence. Re-running a benchmark cannot reproduce that host's old measurements. | +| M7 | D process notes, K-F5 | Optimized tests and constant verification are in merged #47/#40. Dormant fuzz harnesses remain deferred until an engine, corpus, and budget are selected; no claimed fuzzing result is inferred from their presence. | +| M8 | K-F7 | Partially fixed by merged #16 for supplied raw bytes. The existing-secret ceremony still bypassed root validation; #125 and focused PR #126 cover the remaining API path. Parsing remains format validation; CL secrets have no BIP32 requirement. | +| M9 | K-F8 | Intentional stricter threshold grammar: `1` is rejected even for S. The API guide now records this reference-decoder divergence. | +| L1 | D-C3 | Confirmed exit-status collision; #39 and merged #45 distinguish valid `0`, emitted suggestion `1`, usage/input syntax `2`, and no usable suggestion `3`. Suggestions remain nonzero. | +| L2 | D-C1/C2, G-6 | Confirmed unreachable guards/always-false field; merged #46 removes the original findings. #105 removes the later redundant recovery/search paths. | +| L3 | D-C5 | No demonstrated input-validation bypass: current asserts narrow internal types/state after explicit validation. The API guide states the boundary; optimized CI guards it. No blanket assert replacement is warranted. | +| L4 | D-D6 | Intentional historical benchmark evidence, retained by merged #14; #116 labels its age and limits. No fresh timing guarantee follows from it. | +| L5 | D process notes, K-F9 | Maintainer-authorized library cap is `<5200`; GUI is separately `<2250` in #118. The API guide defines recursive nonblank/non-comment counting, including docstrings, and names the enforcement test. | +| L6 | D-D5 | Human integration/authorship work in #38. Preserve dependent PR history until the maintainer settles the stack and performs any required rewrite/squash. | + +## Nits and secondary claims + +| Finding | Sources | Disposition | +|---|---|---| +| Package export count says 25 instead of 24 | D-D2 | #64 removes obsolete `core_descriptors`; #53 documents the resulting 23-name package surface and supported module-level vector helpers. The public API regression rejects the removed export. | +| Test fake accepts obsolete `private=` | D-C4 | One-line #117 makes `_FakeBitcoinCore.initialize` match the production call shape. | +| Stale `MANIFEST.in` provenance exclusion | D notes, K-F9 | Already removed on the current branch (`d6a9f99`); keep `MANIFEST.in` because it still selects license, test, tool, and documentation files for the sdist. | +| Ignored provenance/plans | D notes | Intentional local-only unfinished work stays under the ignored `docs/planning/` path. This finished security audit ledger lives in `docs/security/`; #23 records the earlier audit verdict. #38 links the local cleanup checklist for agents. | +| Beta classifier with an rc version | G-6 | A maturity classifier and prerelease version are separate metadata; no demonstrated defect. Final stable metadata belongs to artifact qualification #5/#52. | +| Extra blank line in `pyproject.toml` | G-6 | No longer present on the current branch. | +| Repository Codex overrides | D notes | #41 and merged #48 remove the configuration. Whether the old named model exists is no longer relevant to the fix. | +| Core RC documentation links | D notes | #93 points to the maintained offline-signing tutorial, as chosen by the maintainer. Stable/signed-RC monitoring is separate from audit closure. | +| Budget-test discoverability | K-F9 | Direct test pointer added to the API guide; #38's final handoff must also name both library and GUI checks. No test-file move is needed. | +| User-facing vector helpers require underscores | G-5 | #49/#53 expose the required codec/checksum helpers at their owning modules and document internal couplings. Further architecture work is explicitly separate. | +| Stale/overlapping branch names | D notes | Use PR numbers and commit IDs as provenance. Pruning branches is housekeeping and does not establish or invalidate a fix. | +| Dependency-install ordering | G-1 secondary note, C rejected claims | Not reproduced under ordinary pip build isolation; no new issue. | + +## Verification and remaining release work + +On 2026-10-04, GitHub reported successful checks and a Codex review tied to the +current commit for #12/#13/#33, #52/#53/#57/#59/#64, #80/#81/#93/#95/#97/#99/#105, +#115/#116/#117/#126, and the focused GUI #66/#77/#78/#118/#119 changes. These include +clearly disclosed Codex-generated reviews posted through the maintainer's +account; they are not human approvals or proof of an independent Claude review. +The PR #65 parent has a real open refresh-mode finding fixed by stacked #66; +review and integrate them together. GitHub's “blocked” status can also reflect +signature/authorship or branch rules even when checks pass. + +Focused local checks at #103 commit `2924f5f` passed for default rendering, +record mismatch before wallet calls, hidden fingerprint, recordless decline and +acceptance, fresh creation, existing creation, and supplied-byte root rejection. +The complete PR #103 security diff scan at behavioral parent `d9ce204`, against +`115f2c2`, found no reportable finding; its later commit changes documentation +only. These checks do not prove the later existing-secret API root path fixed. + +#93 and #97 were updated after new reviews: the offline boot USB is distinct +from the PSBT transfer medium; the FAQ distinguishes unshared fingerprint-derived +identifiers and carries #96's identical 256-bit card so its link resolves. Their +current heads now have green Python-package CI and current-head Codex ACKs; #93's +inline finding is resolved and #97's two follow-up findings are resolved. + +The remaining order is the live #38 stack: restore and Core UX, foundation/API, +audit/release support, user documentation, then the GUI replay. The new #125 root +fix in #126 must be included before freeze. #115/#116/#117 supply the mechanical +cleanup; this patch supplies the remaining contract clarifications. The local +ignored `docs/planning/v1-pre-review-cleanup.md` is a working checklist, not a +shipped report. + +Human review/integration and required signatures remain. The final candidate +must then receive the pinned reviewer handoff, artifact qualification, Tails +rendering/manual GUI-to-Core checks, and a fresh adversarial pass covering the +library, CLI, GUI, and recovery documentation. Until those succeed, “all +material validated findings resolved in the release candidate” is unproven. From 5ab20cda5a82b7fb3ba71c6fffc5a18cabdd7694 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sun, 4 Oct 2026 19:52:17 -0500 Subject: [PATCH 21/23] docs: Remove superseded audit ledger path --- docs/audits/adversarial-2026-10-04.md | 125 -------------------------- 1 file changed, 125 deletions(-) delete mode 100644 docs/audits/adversarial-2026-10-04.md diff --git a/docs/audits/adversarial-2026-10-04.md b/docs/audits/adversarial-2026-10-04.md deleted file mode 100644 index 416e1a22..00000000 --- a/docs/audits/adversarial-2026-10-04.md +++ /dev/null @@ -1,125 +0,0 @@ -# Supplied adversarial reviews: finding disposition - -This is a traceability snapshot, not a release approval. The supplied reviews -examined `reviewability-v1` at `c118a83`; the branch now points to -`5325eb7543e8545b321b0512b38c896b1e269ece`. Unmerged fixes are identified by PR, -not by similarly named sibling branches. Issue -[#20](https://github.com/BenWestgate/python-codex32/issues/20) tracks audit closure; -[#38](https://github.com/BenWestgate/python-codex32/issues/38) owns integration and -the final reviewer handoff. - -## Inputs and qualifications - -The four supplied files are identified by their SHA-256 hashes. D means DeepSeek, -G means GLM, K means Kimi, and C means the consolidated report. - -| File | SHA-256 | -|---|---| -| `codex32-review-DeepSeek_V4.1_Flash.md` | `94398728d93643e609987930c7fb38ea37b828fab9071d61aa1c255e5316450a` | -| `codex32-review-GLM5.3_Flash.md` | `01569d9c3a6fbd1aae6f3a92d523ce9cafb50a66d16c56e91b1ff87030f5af11` | -| `codex32-review-KimiK3_High.md` | `139ac9366b6f4c72f33b3d17e649e88d68e266c19345102084883aa850ab2977` | -| `consolidated-review.md` | `458e647b7cd14f27f71809fdc1b240c8fd4b3cfbfb06e2005b0eb0a4d6767ea9` | - -The originals qualify several claims more narrowly than the consolidation: - -- Kimi calls the Core requirement consistent with the threat model and asks for - a documentation hint. GLM recommends a warning-only fallback. The maintainer - chose Core-backed fingerprint checks for `ms32`; generic `codex32` is the - offline fallback. This is an intentional boundary with a UX/documentation gap. -- Kimi reviewed live Core and GUI paths statically. Its restore recommendation - exceeded the then-current operator-comparison contract. The accepted release - requirement is now verification before mutation for accident safety, using - an independent record or the explicit no-record confirmation. Malicious share - replacement and encrypted descriptors remain separate human-planned work in - [#55](https://github.com/BenWestgate/python-codex32/issues/55). -- Kimi distinguishes accidental `repr()` disclosure from deliberate `str()` - export. The accepted project contract redacts both; `.text` is explicit export. -- The consolidation's shared-baseline table does not prove each original ran - every listed command. In particular, only Kimi explicitly records the BCH - constant verifier. Historical reviewer claims are not new validation evidence. -- The consolidation calls the setup defect independently found by two reviews - in its summary, although all three originals contain it. -- GLM's secondary dependency-install ordering claim did not reproduce with pip's - normal build isolation. The Astra-placeholder claim is stale. Neither justifies - a new defect issue; the unwanted repository configuration was removed anyway. - -## Numbered findings - -“Merged” below means present on the branch identified above. “PR” means a focused -patch exists but still needs human integration. Duplicate IDs share one row. - -| Consolidated ID | Original sources | Assessment and current coverage | -|---|---|---| -| BL1 | D-B1, G-1, K-F4 | Confirmed developer-install defect; #3/#6 and merged #7 remove the test-only `bip32`/secp256k1 dependency. #115 corrects contributor setup. | -| BL2 | D-B2 | Confirmed missing BSD notice; merged #15 includes `LICENSES` in wheel and sdist. | -| H1 | K-F2 | Confirmed default-rendering leak; #22 and merged #25 redact `str()` and `repr()`, including nested correction candidates. | -| H2 | K-F3 | Accepted accident-safety gate; #26/#30, CLI #57/#80/#81, GUI #118. #81 verifies existing seeds before replacement cards; #80 distinguishes an unavailable Bails check. #55 is a separate planning issue, not a missing v1 PR. | -| H3 | K-F1 | Confirmed HRP-boundary defect; #32/#33 enforce 83 characters in parsing and correction contexts. Generalized-HRP draft vectors at `BenWestgate/bips` PR #2, commit `01374bf`, explicitly allow 83 and reject 84; 1023 is the expanded-codeword bound. | -| M1 | G-2 | Confirmed standalone mixed-case regression; #37 and merged #42, including the required-preflight seed preservation follow-up. Interactive recovery already interpreted case. | -| M2 | G-3, K-F9 | Intentional Core boundary; #99 fixes misleading Core diagnostics/preflight. This change documents the generic offline fallback. Do not introduce Python secp256k1 or silently discard fingerprints. | -| M3 | K-F6 | Intended switch to a supplied complete S, with missing notice; #95 announces it after partial share entry. Rejecting S would change the chosen behavior. | -| M4 | D-D4, G-4 | Confirmed review-process gap; #38 requires the final `docs/developer/reviewing.md`, pinned candidate, reading order, reproducible evidence, GUI scope, and human integration order. This snapshot supplies finding provenance but does not replace that frozen-tip handoff. | -| M5 | D-D3 | Confirmed inherited contributor boilerplate; focused #115 replaces it and restores licensing/attribution terms. | -| M6 | D-D1 | Confirmed historical measurements presented as current; focused #116 labels the `6802d86` snapshot and raw evidence. Re-running a benchmark cannot reproduce that host's old measurements. | -| M7 | D process notes, K-F5 | Optimized tests and constant verification are in merged #47/#40. Dormant fuzz harnesses remain deferred until an engine, corpus, and budget are selected; no claimed fuzzing result is inferred from their presence. | -| M8 | K-F7 | Partially fixed by merged #16 for supplied raw bytes. The existing-secret ceremony still bypassed root validation; #125 and focused PR #126 cover the remaining API path. Parsing remains format validation; CL secrets have no BIP32 requirement. | -| M9 | K-F8 | Intentional stricter threshold grammar: `1` is rejected even for S. The API guide now records this reference-decoder divergence. | -| L1 | D-C3 | Confirmed exit-status collision; #39 and merged #45 distinguish valid `0`, emitted suggestion `1`, usage/input syntax `2`, and no usable suggestion `3`. Suggestions remain nonzero. | -| L2 | D-C1/C2, G-6 | Confirmed unreachable guards/always-false field; merged #46 removes the original findings. #105 removes the later redundant recovery/search paths. | -| L3 | D-C5 | No demonstrated input-validation bypass: current asserts narrow internal types/state after explicit validation. The API guide states the boundary; optimized CI guards it. No blanket assert replacement is warranted. | -| L4 | D-D6 | Intentional historical benchmark evidence, retained by merged #14; #116 labels its age and limits. No fresh timing guarantee follows from it. | -| L5 | D process notes, K-F9 | Maintainer-authorized library cap is `<5200`; GUI is separately `<2250` in #118. The API guide defines recursive nonblank/non-comment counting, including docstrings, and names the enforcement test. | -| L6 | D-D5 | Human integration/authorship work in #38. Preserve dependent PR history until the maintainer settles the stack and performs any required rewrite/squash. | - -## Nits and secondary claims - -| Finding | Sources | Disposition | -|---|---|---| -| Package export count says 25 instead of 24 | D-D2 | #64 removes obsolete `core_descriptors`; #53 documents the resulting 23-name package surface and supported module-level vector helpers. The public API regression rejects the removed export. | -| Test fake accepts obsolete `private=` | D-C4 | One-line #117 makes `_FakeBitcoinCore.initialize` match the production call shape. | -| Stale `MANIFEST.in` provenance exclusion | D notes, K-F9 | Already removed on the current branch (`d6a9f99`); keep `MANIFEST.in` because it still selects license, test, tool, and documentation files for the sdist. | -| Ignored provenance/plans | D notes | Intentional local-only unfinished work in `.gitignore`/`AGENTS.md`; finished reports belong in `docs/audits`, including #23 and this snapshot. #38 links the local cleanup checklist for agents. | -| Beta classifier with an rc version | G-6 | A maturity classifier and prerelease version are separate metadata; no demonstrated defect. Final stable metadata belongs to artifact qualification #5/#52. | -| Extra blank line in `pyproject.toml` | G-6 | No longer present on the current branch. | -| Repository Codex overrides | D notes | #41 and merged #48 remove the configuration. Whether the old named model exists is no longer relevant to the fix. | -| Core RC documentation links | D notes | #93 points to the maintained offline-signing tutorial, as chosen by the maintainer. Stable/signed-RC monitoring is separate from audit closure. | -| Budget-test discoverability | K-F9 | Direct test pointer added to the API guide; #38's final handoff must also name both library and GUI checks. No test-file move is needed. | -| User-facing vector helpers require underscores | G-5 | #49/#53 expose the required codec/checksum helpers at their owning modules and document internal couplings. Further architecture work is explicitly separate. | -| Stale/overlapping branch names | D notes | Use PR numbers and commit IDs as provenance. Pruning branches is housekeeping and does not establish or invalidate a fix. | -| Dependency-install ordering | G-1 secondary note, C rejected claims | Not reproduced under ordinary pip build isolation; no new issue. | - -## Verification and remaining release work - -On 2026-10-04, GitHub reported successful checks and a Codex review tied to the -current commit for #12/#13/#33, #52/#53/#57/#59/#64, #80/#81/#95/#99/#105, -#115/#116/#117, and the focused GUI #66/#77/#78/#118/#119 changes. These include -clearly disclosed Codex-generated reviews posted through the maintainer's -account; they are not human approvals or proof of an independent Claude review. -The PR #65 parent has a real open refresh-mode finding fixed by stacked #66; -review and integrate them together. GitHub's “blocked” status can also reflect -signature/authorship or branch rules even when checks pass. - -Focused local checks at #103 commit `2924f5f` passed for default rendering, -record mismatch before wallet calls, hidden fingerprint, recordless decline and -acceptance, fresh creation, existing creation, and supplied-byte root rejection. -The complete PR #103 security diff scan at behavioral parent `d9ce204`, against -`115f2c2`, found no reportable finding; its later commit changes documentation -only. These checks do not prove the later existing-secret API root path fixed. - -#93 and #97 were updated after new reviews: the offline boot USB is distinct -from the PSBT transfer medium; the FAQ distinguishes unshared fingerprint-derived -identifiers and carries #96's identical 256-bit card so its link resolves. Their -new commits need the applicable checks and review recorded before integration. - -The remaining order is the live #38 stack: restore and Core UX, foundation/API, -audit/release support, user documentation, then the GUI replay. The new #125 root -fix in #126 must be included before freeze. #115/#116/#117 supply the mechanical -cleanup; this patch supplies the remaining contract clarifications. The local -ignored `docs/planning/v1-pre-review-cleanup.md` is a working checklist, not a -shipped report. - -Human review/integration and required signatures remain. The final candidate -must then receive the pinned reviewer handoff, artifact qualification, Tails -rendering/manual GUI-to-Core checks, and a fresh adversarial pass covering the -library, CLI, GUI, and recovery documentation. Until those succeed, “all -material validated findings resolved in the release candidate” is unproven. From fce27059110431f711c2d039656378630e6e19b0 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 5 Oct 2026 00:47:55 -0500 Subject: [PATCH 22/23] docs: Refresh adversarial audit status Record the mechanically refreshed restore stack, current review and CI evidence, the fresh dev-install/full-suite proof, and the remaining human-authorship boundary.\n\nRefs #20. Refs #38. --- docs/security/adversarial-2026-10-04.md | 33 +++++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/security/adversarial-2026-10-04.md b/docs/security/adversarial-2026-10-04.md index 25bd4e54..43cecb32 100644 --- a/docs/security/adversarial-2026-10-04.md +++ b/docs/security/adversarial-2026-10-04.md @@ -90,14 +90,29 @@ patch exists but still needs human integration. Duplicate IDs share one row. ## Verification and remaining release work -On 2026-10-04, GitHub reported successful checks and a Codex review tied to the -current commit for #12/#13/#33, #52/#53/#57/#59/#64, #80/#81/#93/#95/#97/#99/#105, -#115/#116/#117/#126, and the focused GUI #66/#77/#78/#118/#119 changes. These include -clearly disclosed Codex-generated reviews posted through the maintainer's -account; they are not human approvals or proof of an independent Claude review. -The PR #65 parent has a real open refresh-mode finding fixed by stacked #66; -review and integrate them together. GitHub's “blocked” status can also reflect -signature/authorship or branch rules even when checks pass. +On 2026-10-05, GitHub reported successful exact-head checks and current-head +Codex or disclosed AI-assisted reviews for #12/#13/#33, +#52/#53/#57/#59/#64, #93/#97/#99/#105/#115/#116/#117/#126, and the focused GUI +#66/#77/#78/#118/#119 changes. These are not human approvals or proof of an +independent Claude review. The PR #65 parent has a real open refresh-mode +finding fixed by stacked #66; review and integrate them together. GitHub's +“blocked” status can also reflect signature/authorship or branch rules even when +checks pass. + +The restore/Core line is now mechanically refreshed in release order as +#57 → #105 → #99 → #80 (`3a35463`) → #81 (`b2aafde`) → #95 (`5e44dcb`). The +#80/#81/#95 focused behavior was replayed without design changes. GitHub's +current-head matrices are green for all three, the #80 Core fixture is green, +and Codex reported no major issue on each exact refreshed head. A disposable +Python 3.14 environment installed `.[dev]` at the refreshed #95 tip with no +Python `bip32` dependency; all 941 tests passed normally and all 941 passed under +`python -O`. Ruff check/format, strict mypy, correction-constant verification, +and `git diff --check` also pass on that composed tip. + +#115 head `2e947b12` corrects the final shell-specific Windows activation nit; +its exact-head GitHub checks are green and Codex found no further content issue. +Its remaining unresolved review item is the repository's responsible-human +authorship requirement, which automation cannot satisfy. Focused local checks at #103 commit `2924f5f` passed for default rendering, record mismatch before wallet calls, hidden fingerprint, recordless decline and @@ -124,3 +139,5 @@ must then receive the pinned reviewer handoff, artifact qualification, Tails rendering/manual GUI-to-Core checks, and a fresh adversarial pass covering the library, CLI, GUI, and recovery documentation. Until those succeed, “all material validated findings resolved in the release candidate” is unproven. +The final exact-head CI/review evidence for this ledger-only update belongs in +PR #127 so recording it does not require another self-referential ledger edit. From 72dbd698b72412d93feea854dd464a9336a8421f Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Mon, 5 Oct 2026 01:56:00 -0500 Subject: [PATCH 23/23] docs: Record post-audit release gates Narrow the original H1 disposition to the complete-artifact leak actually reported by the external reviewers, then record the later descriptor-authentication and correction-edit rendering findings separately. Update the public API count and exact-head verification evidence for their focused fixes.\n\nRefs #20\nRefs #38\nRefs #128\nRefs #129 --- docs/security/adversarial-2026-10-04.md | 32 ++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/security/adversarial-2026-10-04.md b/docs/security/adversarial-2026-10-04.md index 43cecb32..9a958aa0 100644 --- a/docs/security/adversarial-2026-10-04.md +++ b/docs/security/adversarial-2026-10-04.md @@ -52,7 +52,7 @@ patch exists but still needs human integration. Duplicate IDs share one row. |---|---|---| | BL1 | D-B1, G-1, K-F4 | Confirmed developer-install defect; #3/#6 and merged #7 remove the test-only `bip32`/secp256k1 dependency. #115 corrects contributor setup. | | BL2 | D-B2 | Confirmed missing BSD notice; merged #15 includes `LICENSES` in wheel and sdist. | -| H1 | K-F2 | Confirmed default-rendering leak; #22 and merged #25 redact `str()` and `repr()`, including nested correction candidates. | +| H1 | K-F2 | Confirmed artifact default-rendering leak; #22 and merged #25 redact artifact `str()` and `repr()`, closing K-F2's complete-secret disclosure. A later, narrower correction-edit rendering issue is tracked separately below. | | H2 | K-F3 | Accepted accident-safety gate; #26/#30, CLI #57/#80/#81, GUI #118. #81 verifies existing seeds before replacement cards; #80 distinguishes an unavailable Bails check. #55 is a separate planning issue, not a missing v1 PR. | | H3 | K-F1 | Confirmed HRP-boundary defect; #32/#33 enforce 83 characters in parsing and correction contexts. Generalized-HRP draft vectors at `BenWestgate/bips` PR #2, commit `01374bf`, explicitly allow 83 and reject 84; 1023 is the expanded-codeword bound. | | M1 | G-2 | Confirmed standalone mixed-case regression; #37 and merged #42, including the required-preflight seed preservation follow-up. Interactive recovery already interpreted case. | @@ -75,7 +75,7 @@ patch exists but still needs human integration. Duplicate IDs share one row. | Finding | Sources | Disposition | |---|---|---| -| Package export count says 25 instead of 24 | D-D2 | #64 removes obsolete `core_descriptors`; #53 documents the resulting 23-name package surface and supported module-level vector helpers. The public API regression rejects the removed export. | +| Package export count says 25 instead of 24 | D-D2 | #130 supersedes #64's API cleanup by removing obsolete `core_descriptors` plus two unused CL-generation exports; if integrated, package-level `__all__` contains 22 names. #53's supported reference-vector helpers remain module-level rather than package exports. | | Test fake accepts obsolete `private=` | D-C4 | One-line #117 makes `_FakeBitcoinCore.initialize` match the production call shape. | | Stale `MANIFEST.in` provenance exclusion | D notes, K-F9 | Already removed on the current branch (`d6a9f99`); keep `MANIFEST.in` because it still selects license, test, tool, and documentation files for the sdist. | | Ignored provenance/plans | D notes | Intentional local-only unfinished work stays under the ignored `docs/planning/` path. This finished security audit ledger lives in `docs/security/`; #23 records the earlier audit verdict. #38 links the local cleanup checklist for agents. | @@ -88,17 +88,43 @@ patch exists but still needs human integration. Duplicate IDs share one row. | Stale/overlapping branch names | D notes | Use PR numbers and commit IDs as provenance. Pruning branches is housekeeping and does not establish or invalidate a fix. | | Dependency-install ordering | G-1 secondary note, C rejected claims | Not reproduced under ordinary pip build isolation; no new issue. | +## Post-audit follow-ups + +These were found after the four supplied reports and are not retroactively +attributed to those reviewers. They remain release-gate work because they touch +the same reviewed security/API surface: + +- #128 / focused #130: the unused public `core_descriptors(..., private=False)` + path derived descriptors from the named Core wallet rather than authenticating + them to the supplied seed. Removing the unused pre-1.0 API also supersedes the + narrower #64 cleanup. +- #129 / focused #132: `CorrectionEdit`'s generated dataclass representation + disclosed observed/replacement characters when a correction candidate was + logged. This is narrower than K-F2's complete-artifact disclosure, but it + violates the same accidental-rendering boundary. #132 excludes only those two + fields from default rendering while preserving explicit field access and + correction behavior. + ## Verification and remaining release work On 2026-10-05, GitHub reported successful exact-head checks and current-head Codex or disclosed AI-assisted reviews for #12/#13/#33, -#52/#53/#57/#59/#64, #93/#97/#99/#105/#115/#116/#117/#126, and the focused GUI +#52/#53/#57/#59, #93/#97/#99/#105/#115/#116/#117/#126, and the focused GUI #66/#77/#78/#118/#119 changes. These are not human approvals or proof of an independent Claude review. The PR #65 parent has a real open refresh-mode finding fixed by stacked #66; review and integrate them together. GitHub's “blocked” status can also reflect signature/authorship or branch rules even when checks pass. +The post-audit fixes are also mechanically ready for human review. #130 head +`2cbd2793ae` has a green Python matrix and Bitcoin Core fixture; its one Codex +documentation finding was corrected and resolved, and Codex then reported no +major issue on that exact head. #132 head `f64da6f9d9` has a green Python matrix +and an exact-head Codex no-major-issue review. Its focused local verification +also passed 903 tests normally and under `python -O`, Ruff check/format, strict +mypy, correction-constant re-derivation, and all 57 frozen differential cases. +These reviews do not replace responsible-human authorship or integration. + The restore/Core line is now mechanically refreshed in release order as #57 → #105 → #99 → #80 (`3a35463`) → #81 (`b2aafde`) → #95 (`5e44dcb`). The #80/#81/#95 focused behavior was replayed without design changes. GitHub's