diff --git a/codex32_terminal/.gitignore b/codex32_terminal/.gitignore new file mode 100644 index 0000000..f033e81 --- /dev/null +++ b/codex32_terminal/.gitignore @@ -0,0 +1,27 @@ +# Python artifacts +__pycache__/ +*.py[cod] +*.so +*.egg-info/ +dist/ +build/ + +# Virtual environments +venv/ +.venv/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# OS +.DS_Store +Thumbs.db diff --git a/codex32_terminal/README.md b/codex32_terminal/README.md index d7002b6..623a2bd 100644 --- a/codex32_terminal/README.md +++ b/codex32_terminal/README.md @@ -1,6 +1,13 @@ # Codex32 Terminal MVP -This folder contains a terminal-based MVP for validating Codex32 shares, recovering a secret `S` share from `k-of-n` shares, and converting the master seed into a 12-word BIP39 mnemonic (display-only). +This folder contains a terminal-based MVP for validating Codex32 shares, recovering a secret `S` share from `k-of-n` shares, and converting the master seed into a BIP39 mnemonic (display-only). + +## Supported Seed Sizes + +| Seed Size | Codex32 Length | BIP39 Output | +|-----------|----------------|--------------| +| 128-bit | 48 characters | 12 words | +| 256-bit | 74 characters | 24 words | ## Current status (Jan 31, 2026) @@ -11,43 +18,102 @@ This folder contains a terminal-based MVP for validating Codex32 shares, recover - Checksum + header validation - `k-of-n` share recovery via interpolation - Display recovered `S` share, seed hex, and BIP39 mnemonic +- **128-bit and 256-bit seed support** (auto-detected) ⚠️ Not an ECW yet (no error correction). The codex32 Python library does **not** provide substitution/erasure correction, so this tool does not attempt it. Error-correction should be implemented separately before advertising ECW behavior. -## Setup (Windows PowerShell) +## Setup + +### macOS / Linux + +```bash +cd codex32_terminal +python3 -m venv venv +source venv/bin/activate +pip install codex32 embit +``` + +### Windows PowerShell From the repo root: ```powershell python -m venv .\codex32_terminal\venv .\codex32_terminal\venv\Scripts\Activate.ps1 ; pip install codex32 embit -pip freeze > .\codex32_terminal\requirements.txt ``` -> Note: Use a semicolon between `Activate.ps1` and subsequent commands in PowerShell. - ## Run ### Box-by-box entry (default) +**macOS / Linux:** +```bash +source venv/bin/activate +python src/main.py +``` + +**Windows PowerShell:** ```powershell -.\codex32_terminal\venv\Scripts\Activate.ps1 ; python .\codex32_terminal\src\main.py +.\venv\Scripts\Activate.ps1 ; python src\main.py ``` Features: -- Prefix `MS1` is pre-filled. -- Enter one character per box. -- Backspace: press Enter on empty input or type `<` to go back. -- Ctrl+C cancels entry. +- Prompts for seed size (128-bit or 256-bit) at start +- Prefix `MS1` is pre-filled +- Enter one character per box +- Backspace: press Enter on empty input or type `<` to go back +- Ctrl+C cancels entry ### Full-share paste mode +**macOS / Linux:** +```bash +source venv/bin/activate +python src/main.py --full +``` + +**Windows PowerShell:** ```powershell -.\codex32_terminal\venv\Scripts\Activate.ps1 ; python .\codex32_terminal\src\main.py --full +.\venv\Scripts\Activate.ps1 ; python src\main.py --full ``` -Paste full shares in sequence. For `k-of-n` shares, the tool will ask for additional shares until the threshold is met. +Paste full shares in sequence. Seed size is auto-detected from string length (48 or 74 chars). For `k-of-n` shares, the tool will ask for additional shares until the threshold is met. + +## Run Tests + +**macOS / Linux:** +```bash +source venv/bin/activate +python tests/test_vectors.py +python tests/test_256bit.py +``` + +**Windows PowerShell:** +```powershell +.\venv\Scripts\Activate.ps1 +python tests\test_vectors.py +python tests\test_256bit.py +``` + +### Expected test output + +``` +vector2: seed OK -> spice afford liquid stool forest agent choose draw clinic cram obvious enough +vector3: seed OK -> zoo ivory industry jar praise service talk skirt during october lounge absurd +test_valid_lengths_constant: PASS +test_256bit_parse: PASS +test_256bit_validate_s_share: PASS +test_256bit_seed_extraction: PASS +test_256bit_to_mnemonic: PASS (24 words) + Mnemonic: zoo ivory industry jar praise service talk skirt during october lounge acid year humble cream inspire office dry sunset pride drip much dune arm +test_128bit_still_works: PASS (12 words) +test_auto_detect_length: PASS +test_invalid_length_rejected: PASS +test_seed_bytes_to_mnemonic_both_sizes: PASS + +All 256-bit tests passed! +``` ## Test vectors @@ -64,7 +130,7 @@ Expected output: - Seed hex: `d1808e096b35b209ca12132b264662a5` - BIP39 mnemonic: `spice afford liquid stool forest agent choose draw clinic cram obvious enough` -### Vector 3 (k=3, cash) +### Vector 3 (k=3, cash, 128-bit) - Share a: `ms13casha320zyxwvutsrqpnmlkjhgfedca2a8d0zehn8a0t` - Share c: `ms13cashcacdefghjklmnpqrstuvwxyz023949xq35my48dr` @@ -74,7 +140,16 @@ Expected output: - Recovered S-share: `ms13cashsllhdmn9m42vcsamx24zrxgs3qqjzqud4m0d6nln` - Seed hex: `ffeeddccbbaa99887766554433221100` -- BIP39 mnemonic: `zoo ivory industry jar praise service talk skirt during october lounge absurd` +- BIP39 mnemonic (12 words): `zoo ivory industry jar praise service talk skirt during october lounge absurd` + +### Vector 4 (256-bit seed) + +- S-share: `ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycma` + +Expected output: + +- Seed hex: `ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100` +- BIP39 mnemonic (24 words): `zoo ivory industry jar praise service talk skirt during october lounge acid year humble cream inspire office dry sunset pride drip much dune arm` ## Codebase overview @@ -94,13 +169,17 @@ Expected output: - Progress display, preview/confirm, prompts - `tests/test_vectors.py` - - Manual harness for BIP-93 vectors 2/3 + - Manual harness for BIP-93 vectors 2/3 (128-bit) + +- `tests/test_256bit.py` + - Tests for 256-bit seed support (BIP-93 vector 4) + - Verifies both 128-bit and 256-bit paths work correctly ### Implementation rationale - **Validation** uses `codex32.Codex32String`, which enforces checksum + header correctness. - **Recovery** uses `Codex32String.interpolate_at` to reconstruct the `S` share from `k` valid shares. -- **BIP39 mnemonic** is a display encoding of the 16-byte master seed (no PBKDF2). This mirrors BIP-93 guidance. +- **BIP39 mnemonic** is a display encoding of the master seed (no PBKDF2). 128-bit seeds produce 12 words, 256-bit seeds produce 24 words. This mirrors BIP-93 guidance. ## Next steps (SeedSigner port) diff --git a/codex32_terminal/src/controller.py b/codex32_terminal/src/controller.py index 7c6a451..ef1734a 100644 --- a/codex32_terminal/src/controller.py +++ b/codex32_terminal/src/controller.py @@ -14,7 +14,10 @@ ) -TOTAL_LEN = 48 +# Supported seed sizes - must match VALID_LENGTHS in model.py +# 128-bit (48 chars) or 256-bit (74 chars) +LEN_128BIT = 48 +LEN_256BIT = 74 BASE_PREFIX = "MS1" FIRST_BOX = len(BASE_PREFIX) + 1 CANCELLED = object() @@ -34,18 +37,18 @@ def _is_backspace(value: str) -> bool: return value == "" or value == "<" -def collect_codex32_boxes(prefix: str, start_box: int) -> str: +def collect_codex32_boxes(prefix: str, start_box: int, total_len: int) -> str: current = prefix - view.display_progress(current, TOTAL_LEN) + view.display_progress(current, total_len) box_number = start_box - while box_number <= TOTAL_LEN: + while box_number <= total_len: raw = view.get_box_input(box_number) ch = _normalize_box_char(raw) if _is_backspace(ch): if len(current) > len(prefix): current = current[:-1] box_number -= 1 - view.display_progress(current, TOTAL_LEN) + view.display_progress(current, total_len) else: view.display_error("Already at the first editable box.") continue @@ -56,7 +59,7 @@ def collect_codex32_boxes(prefix: str, start_box: int) -> str: view.display_error("Invalid bech32 character. Use bech32 charset.") continue current += ch - view.display_progress(current, TOTAL_LEN) + view.display_progress(current, total_len) box_number += 1 return current @@ -66,10 +69,10 @@ def _display_and_confirm(codex_str: str) -> bool: return view.confirm("Submit this codex32 string?") -def _collect_share_box(prefix: str, start_box: int, index: int, total: int) -> str | object | None: +def _collect_share_box(prefix: str, start_box: int, index: int, total: int, total_len: int) -> str | object | None: view.display_share_prompt(index, total) try: - codex_str = collect_codex32_boxes(prefix, start_box) + codex_str = collect_codex32_boxes(prefix, start_box, total_len) except KeyboardInterrupt: view.display_cancelled() return CANCELLED @@ -99,11 +102,19 @@ def _collect_share_full(prefix: str | None, index: int, total: int) -> str | obj def run(entry_mode: str = "box") -> int: view.display_welcome(entry_mode) + + # For box mode, ask user about seed size + total_len = LEN_128BIT # default + if entry_mode == "box": + seed_size = view.get_seed_size_choice() + if seed_size == "256": + total_len = LEN_256BIT + while True: if entry_mode == "full": result = _collect_share_full(BASE_PREFIX, 1, 1) else: - result = _collect_share_box(BASE_PREFIX, FIRST_BOX, 1, 1) + result = _collect_share_box(BASE_PREFIX, FIRST_BOX, 1, 1, total_len) if result is CANCELLED: return 1 if result is None: @@ -142,12 +153,15 @@ def run(entry_mode: str = "box") -> int: if first_share.s.isupper(): prefix = prefix.upper() + # For subsequent shares, use the same total_len as detected from first share + share_total_len = len(first_share.s) + while len(shares) < threshold: share_index = len(shares) + 1 if entry_mode == "full": result = _collect_share_full(prefix, share_index, threshold) else: - result = _collect_share_box(prefix, len(prefix) + 1, share_index, threshold) + result = _collect_share_box(prefix, len(prefix) + 1, share_index, threshold, share_total_len) if result is CANCELLED: return 1 if result is None: diff --git a/codex32_terminal/src/model.py b/codex32_terminal/src/model.py index 58ca69a..425c306 100644 --- a/codex32_terminal/src/model.py +++ b/codex32_terminal/src/model.py @@ -6,6 +6,12 @@ from embit import bip39 +# Valid codex32 string lengths and corresponding seed sizes +# 48 chars = 128-bit seed (16 bytes) = 12-word mnemonic +# 74 chars = 256-bit seed (32 bytes) = 24-word mnemonic +VALID_LENGTHS = {48: 16, 74: 32} + + class Codex32InputError(ValueError): """Raised when a codex32 input fails validation.""" @@ -18,15 +24,34 @@ def sanitize_codex32_input(raw: str) -> str: return compact.replace("-", "") -def parse_codex32_share(codex_str: str, expected_len: int = 48) -> Codex32String: - """Parse and validate a codex32 share string (checksum + header).""" +def parse_codex32_share(codex_str: str, expected_len: int | None = None) -> Codex32String: + """Parse and validate a codex32 share string (checksum + header). + + Args: + codex_str: The codex32 string to parse + expected_len: Expected length (48 or 74), or None to auto-detect + """ cleaned = sanitize_codex32_input(codex_str) if not cleaned: raise Codex32InputError("Codex32 input is empty") - if expected_len is not None and len(cleaned) != expected_len: - raise Codex32InputError( - f"Expected {expected_len} characters for a 128-bit codex32 share, got {len(cleaned)}" - ) + + # Validate length + if expected_len is not None: + if expected_len not in VALID_LENGTHS: + raise Codex32InputError( + f"Invalid expected_len {expected_len}, must be 48 (128-bit) or 74 (256-bit)" + ) + if len(cleaned) != expected_len: + raise Codex32InputError( + f"Expected {expected_len} characters, got {len(cleaned)}" + ) + else: + # Auto-detect: must be a valid length + if len(cleaned) not in VALID_LENGTHS: + raise Codex32InputError( + f"Invalid length {len(cleaned)}, expected 48 (128-bit) or 74 (256-bit)" + ) + try: codex = Codex32String(cleaned) except CodexError as exc: @@ -36,37 +61,49 @@ def parse_codex32_share(codex_str: str, expected_len: int = 48) -> Codex32String return codex -def validate_codex32_s_share(codex_str: str, expected_len: int = 48) -> Codex32String: - """Validate a codex32 S-share string and return a Codex32String object.""" +def validate_codex32_s_share(codex_str: str, expected_len: int | None = None) -> Codex32String: + """Validate a codex32 S-share string and return a Codex32String object. + + Args: + codex_str: The codex32 string to validate + expected_len: Expected length (48 or 74), or None to auto-detect + """ codex = parse_codex32_share(codex_str, expected_len) if codex.share_idx.lower() != "s": raise Codex32InputError( f"Share index must be 's' for an unshared secret, got '{codex.share_idx}'" ) - if len(codex.data) != 16: + # Validate seed size matches expected length + expected_bytes = VALID_LENGTHS.get(len(codex.s)) + if expected_bytes is not None and len(codex.data) != expected_bytes: raise Codex32InputError( - f"Expected 16-byte (128-bit) master seed, got {len(codex.data)} bytes" + f"Expected {expected_bytes}-byte seed for {len(codex.s)}-char string, " + f"got {len(codex.data)} bytes" ) return codex def codex32_to_seed_bytes(codex_str: str) -> bytes: - """Convert a codex32 S-share string into 16 bytes of seed entropy.""" + """Convert a codex32 S-share string into seed entropy (16 or 32 bytes).""" codex = validate_codex32_s_share(codex_str) return codex.data def seed_bytes_to_mnemonic(seed_bytes: bytes) -> str: - """Convert 16 bytes of entropy to a 12-word BIP39 mnemonic.""" - if len(seed_bytes) != 16: + """Convert seed entropy to a BIP39 mnemonic. + + Args: + seed_bytes: 16 bytes (128-bit) for 12 words, or 32 bytes (256-bit) for 24 words + """ + if len(seed_bytes) not in (16, 32): raise Codex32InputError( - f"Expected 16 bytes of entropy for a 12-word mnemonic, got {len(seed_bytes)}" + f"Expected 16 bytes (12 words) or 32 bytes (24 words), got {len(seed_bytes)} bytes" ) return bip39.mnemonic_from_bytes(seed_bytes) def codex32_to_mnemonic(codex_str: str) -> str: - """Convert a codex32 S-share into a 12-word BIP39 mnemonic.""" + """Convert a codex32 S-share into a BIP39 mnemonic (12 or 24 words).""" return seed_bytes_to_mnemonic(codex32_to_seed_bytes(codex_str)) diff --git a/codex32_terminal/src/view.py b/codex32_terminal/src/view.py index a52dab0..8621dd1 100644 --- a/codex32_terminal/src/view.py +++ b/codex32_terminal/src/view.py @@ -6,12 +6,27 @@ def display_welcome(entry_mode: str) -> None: print("Codex32 S-share entry (MVP)") if entry_mode == "full": - print("Paste full shares. Ctrl+C cancels.") + print("Paste full shares (48 or 74 chars). Ctrl+C cancels.") else: print("Enter characters box-by-box. Prefix 'MS1' is pre-filled.") print("Use Backspace (empty input) or '<' to go back. Ctrl+C cancels.") +def get_seed_size_choice() -> str: + """Ask user to select seed size for box-by-box entry.""" + print("\nSelect seed size:") + print(" [1] 128-bit (48 characters, 12-word mnemonic)") + print(" [2] 256-bit (74 characters, 24-word mnemonic)") + while True: + choice = input("Enter 1 or 2 [default: 1]: ").strip() + if choice == "" or choice == "1": + return "128" + elif choice == "2": + return "256" + else: + print("Invalid choice. Enter 1 or 2.") + + def display_progress(current: str, total_len: int) -> None: remaining = max(0, total_len - len(current)) progress = f"{current}{'_' * remaining}" @@ -64,9 +79,11 @@ def display_cancelled() -> None: def display_success(seed_bytes: bytes, mnemonic: str, recovered_share: str | None = None) -> None: - print("\nCodex32 S-share accepted.") + word_count = len(mnemonic.split()) + bit_size = len(seed_bytes) * 8 + print(f"\nCodex32 S-share accepted ({bit_size}-bit seed).") print(f"Seed (hex): {seed_bytes.hex()}") if recovered_share: print(f"Recovered S-share: {recovered_share}") - print(f"BIP39 mnemonic: {mnemonic}") + print(f"BIP39 mnemonic ({word_count} words): {mnemonic}") print("Note: This mnemonic is a display encoding of the BIP32 seed; no PBKDF2 is used.") diff --git a/codex32_terminal/tests/test_256bit.py b/codex32_terminal/tests/test_256bit.py new file mode 100644 index 0000000..76bf874 --- /dev/null +++ b/codex32_terminal/tests/test_256bit.py @@ -0,0 +1,266 @@ +"""Test 256-bit seed support (BIP-93 Test Vector 4).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from embit import bip39 + +from model import ( + Codex32InputError, + parse_codex32_share, + validate_codex32_s_share, + codex32_to_seed_bytes, + seed_bytes_to_mnemonic, + codex32_to_mnemonic, + VALID_LENGTHS, +) + + +# BIP-93 Test Vector 4: 256-bit seed +VECTOR4 = { + "codex32": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycma", + "seed_hex": "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100", + "mnemonic": "zoo ivory industry jar praise service talk skirt during october lounge acid year humble cream inspire office dry sunset pride drip much dune arm", + "length": 74, + "seed_bytes": 32, + "word_count": 24, +} + +# BIP-93 Test Vector 2: 128-bit seed (for comparison) +VECTOR2 = { + "codex32": "MS12NAMES6XQGUZTTXKEQNJSJZV4JV3NZ5K3KWGSPHUH6EVW", + "seed_hex": "d1808e096b35b209ca12132b264662a5", + "length": 48, + "seed_bytes": 16, + "word_count": 12, +} + + +def test_valid_lengths_constant(): + """Test that VALID_LENGTHS maps correctly.""" + assert 48 in VALID_LENGTHS, "48 should be a valid length" + assert 74 in VALID_LENGTHS, "74 should be a valid length" + assert VALID_LENGTHS[48] == 16, "48 chars should map to 16 bytes" + assert VALID_LENGTHS[74] == 32, "74 chars should map to 32 bytes" + print("test_valid_lengths_constant: PASS") + + +def test_256bit_parse(): + """Test parsing a 256-bit codex32 string.""" + codex = parse_codex32_share(VECTOR4["codex32"]) + assert codex is not None + assert len(codex.s) == VECTOR4["length"] + print("test_256bit_parse: PASS") + + +def test_256bit_validate_s_share(): + """Test validating a 256-bit S-share.""" + codex = validate_codex32_s_share(VECTOR4["codex32"]) + assert codex is not None + assert codex.share_idx.lower() == "s" + assert len(codex.data) == VECTOR4["seed_bytes"] + print("test_256bit_validate_s_share: PASS") + + +def test_256bit_seed_extraction(): + """Test extracting seed bytes from 256-bit codex32.""" + seed_bytes = codex32_to_seed_bytes(VECTOR4["codex32"]) + assert seed_bytes.hex() == VECTOR4["seed_hex"], ( + f"Seed mismatch: got {seed_bytes.hex()}, expected {VECTOR4['seed_hex']}" + ) + assert len(seed_bytes) == 32, f"Expected 32 bytes, got {len(seed_bytes)}" + print("test_256bit_seed_extraction: PASS") + + +def test_256bit_to_mnemonic(): + """Test converting 256-bit seed to 24-word mnemonic.""" + mnemonic = codex32_to_mnemonic(VECTOR4["codex32"]) + words = mnemonic.split() + assert len(words) == VECTOR4["word_count"], ( + f"Expected {VECTOR4['word_count']} words, got {len(words)}" + ) + assert mnemonic == VECTOR4["mnemonic"], ( + f"Mnemonic mismatch: got {mnemonic}" + ) + print(f"test_256bit_to_mnemonic: PASS ({len(words)} words)") + + +def test_128bit_still_works(): + """Test that 128-bit seeds still work (regression test).""" + seed_bytes = codex32_to_seed_bytes(VECTOR2["codex32"]) + assert seed_bytes.hex() == VECTOR2["seed_hex"] + + mnemonic = codex32_to_mnemonic(VECTOR2["codex32"]) + words = mnemonic.split() + assert len(words) == VECTOR2["word_count"], ( + f"Expected {VECTOR2['word_count']} words, got {len(words)}" + ) + print(f"test_128bit_still_works: PASS ({len(words)} words)") + + +def test_auto_detect_length(): + """Test that length auto-detection works for both sizes.""" + # 128-bit + codex_128 = parse_codex32_share(VECTOR2["codex32"]) # No expected_len + assert len(codex_128.data) == 16 + + # 256-bit + codex_256 = parse_codex32_share(VECTOR4["codex32"]) # No expected_len + assert len(codex_256.data) == 32 + + print("test_auto_detect_length: PASS") + + +def test_invalid_length_rejected(): + """Test that invalid lengths are rejected.""" + # Too short (47 chars) + try: + parse_codex32_share("MS12NAMES6XQGUZTTXKEQNJSJZV4JV3NZ5K3KWGSPHUH6EV") + raise AssertionError("Should have rejected 47-char input") + except Codex32InputError: + pass + + # Wrong length (50 chars - neither 48 nor 74) + try: + parse_codex32_share("MS12NAMES6XQGUZTTXKEQNJSJZV4JV3NZ5K3KWGSPHUH6EVWXX") + raise AssertionError("Should have rejected 50-char input") + except Codex32InputError: + pass + + print("test_invalid_length_rejected: PASS") + + +def test_seed_bytes_to_mnemonic_both_sizes(): + """Test seed_bytes_to_mnemonic handles both 16 and 32 bytes.""" + # 16 bytes -> 12 words + seed_16 = bytes.fromhex(VECTOR2["seed_hex"]) + mnemonic_12 = seed_bytes_to_mnemonic(seed_16) + assert len(mnemonic_12.split()) == 12 + + # 32 bytes -> 24 words + seed_32 = bytes.fromhex(VECTOR4["seed_hex"]) + mnemonic_24 = seed_bytes_to_mnemonic(seed_32) + assert len(mnemonic_24.split()) == 24 + + # Invalid size should fail + try: + seed_bytes_to_mnemonic(bytes(20)) # 20 bytes is not valid + raise AssertionError("Should have rejected 20-byte seed") + except Codex32InputError: + pass + + print("test_seed_bytes_to_mnemonic_both_sizes: PASS") + + +def test_256bit_invalid_checksum_rejected(): + """Test that 256-bit strings with invalid checksums are rejected.""" + # Valid: ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycma + # Change last character to corrupt checksum + invalid = "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycmx" + + try: + parse_codex32_share(invalid) + raise AssertionError("Should have rejected invalid 256-bit checksum") + except Codex32InputError: + pass + + print("test_256bit_invalid_checksum_rejected: PASS") + + +def test_256bit_single_char_corruption(): + """Test that single character corruption is detected in 256-bit strings.""" + valid = "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycma" + + # Test corruption at key positions: + # 0=HRP, 10=identifier, 30=payload middle, 50=payload, 73=last char (checksum) + positions = [0, 10, 30, 50, 73] + corruptions_detected = 0 + + for pos in positions: + char_list = list(valid) + original = char_list[pos] + # Pick a different bech32 char + char_list[pos] = 'q' if original != 'q' else 'p' + corrupted = ''.join(char_list) + + try: + parse_codex32_share(corrupted) + except Codex32InputError: + corruptions_detected += 1 + + assert corruptions_detected == len(positions), ( + f"Only detected {corruptions_detected}/{len(positions)} corruptions" + ) + print(f"test_256bit_single_char_corruption: PASS ({corruptions_detected} detected)") + + +def test_73_char_length_rejected(): + """Test that 73 characters (one short of 256-bit) is rejected.""" + # 73 chars - too short for 256-bit + too_short = "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycm" + + try: + parse_codex32_share(too_short) + raise AssertionError("Should have rejected 73-char input") + except Codex32InputError: + pass + + print("test_73_char_length_rejected: PASS") + + +def test_75_char_length_rejected(): + """Test that 75 characters (one more than 256-bit) is rejected.""" + # 75 chars - too long for 256-bit + too_long = "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycmaa" + + try: + parse_codex32_share(too_long) + raise AssertionError("Should have rejected 75-char input") + except Codex32InputError: + pass + + print("test_75_char_length_rejected: PASS") + + +def test_mnemonic_words_are_valid_bip39(): + """Test that generated mnemonic words are valid BIP39 words.""" + # 256-bit mnemonic + mnemonic = codex32_to_mnemonic(VECTOR4["codex32"]) + + # Verify each word is in BIP39 wordlist + for word in mnemonic.split(): + assert word in bip39.WORDLIST, f"'{word}' is not a valid BIP39 word" + + # 128-bit mnemonic + mnemonic_128 = codex32_to_mnemonic(VECTOR2["codex32"]) + for word in mnemonic_128.split(): + assert word in bip39.WORDLIST, f"'{word}' is not a valid BIP39 word" + + print("test_mnemonic_words_are_valid_bip39: PASS") + + +def main(): + test_valid_lengths_constant() + test_256bit_parse() + test_256bit_validate_s_share() + test_256bit_seed_extraction() + test_256bit_to_mnemonic() + test_128bit_still_works() + test_auto_detect_length() + test_invalid_length_rejected() + test_seed_bytes_to_mnemonic_both_sizes() + test_256bit_invalid_checksum_rejected() + test_256bit_single_char_corruption() + test_73_char_length_rejected() + test_75_char_length_rejected() + test_mnemonic_words_are_valid_bip39() + print("\nAll 256-bit tests passed!") + + +if __name__ == "__main__": + main()