From d5549081ad643a203039ff861c4f6a9a4dffbca2 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 17 Oct 2026 13:48:00 +0000 Subject: [PATCH 1/6] python-codex32: only encode/decode the secret from bytes Shares are now made only by interpolation. encode() and from_seed() reject share indices other than 's', and decode() returns no seed bytes or padding for shares. Codex32String keeps the string it was given instead of re-encoding from bytes, so attributes are no longer writable aliases. Drop code a reference implementation should not carry: - the bip32 dependency and the fingerprint default identifier (BIP-93 leaves the identifier to the user; coincurve also fails to build on Python 3.13) - segwit_addr.py, Bech32/Bech32m constants and their tests - unused HRP_CODES, IDX_ORDER and IdNotLength4 Claude-Session: https://claude.ai/code/session_01PYQtm2qPkBYqZ9WuNvhv51 --- README.md | 26 +--- src/codex32/bip93.py | 36 ++--- src/codex32/checksums.py | 9 +- src/codex32/segwit_addr.py | 85 ----------- tests/data/bip93_vectors.py | 2 +- tests/test_bech32.py | 220 --------------------------- tests/test_bip93.py | 3 +- tests/test_roundtrip_interpolated.py | 40 ----- 8 files changed, 20 insertions(+), 401 deletions(-) delete mode 100644 src/codex32/segwit_addr.py delete mode 100644 tests/test_bech32.py delete mode 100644 tests/test_roundtrip_interpolated.py diff --git a/README.md b/README.md index e87e0556..d45b660e 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,15 @@ Reference implementation of BIP-0093 (codex32): checksummed, SSSS-aware BIP32 se This repository implements the codex32 string format described by BIP-0093. It provides encoding/decoding, regular/long codex32 checksums, CRC padding for base conversions, -Shamir secret sharing scheme (SSSS) interpolation helpers and helpers to build codex32 strings from seed bytes. +Shamir secret sharing scheme (SSSS) interpolation helpers and helpers to build codex32 secrets from seed bytes. +Shares are made only by interpolation, never from bytes. ## Features - Encode/decode codex32 data via `from_string` and `from_unchecksummed_string`. -- Regular checksum (13 chars) and long checksum (15 chars) support. -- Construct codex32 strings from raw seed bytes via `from_seed`. -- CRC-based default padding scheme for `from_seed`. -- `from_seed` requires an explicit four-character identifier in its prefix. -- Interpolate/recover shares via `interpolate_at`. +- Regular and long codex32 checksum support. +- Construct codex32 secrets (share index `s`) from raw seed bytes via `from_seed`, with CRC padding by default. +- Interpolate shares recover secrets via `interpolate_at`. - Parse codex32 strings and access parts via properties. -- Mutate codex32 strings by reassigning `is_upper`, `hrp`, `k`, `ident`, `share_idx`, `data`, and `pad_val`. -- Contains module and tests for Bech32/Bech32m and segwit addresses. ## Security Caution: This is reference code. Verify carefully before using with real funds. @@ -62,8 +59,8 @@ print(a.payload) # payload part print(a.checksum) # checksum part print(len(a)) # length of the codex32 string print(a.is_upper) # case is upper True/False -print(s.data.hex()) # raw seed bytes as hex -print(a.pad_val) # padding value integer, (MSB first) +print(s.data.hex()) # raw seed bytes as hex (None for shares) +print(s.pad_val) # padding value integer, (MSB first) (None for shares) @@ -80,15 +77,6 @@ print(derived_share_d.s) e = Codex32String.from_string("cl", "cl10lueasd35kw6r5de5kueedxyesqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqanvrktzhlhusz") print(e.ident) print(e.s) - -# Relabel a Codex32String object -e.ident = "cln2" -print(e.ident) -print(e.s) - -# Uppercase a Codex32String object (for encoding in QR codes or handwriting) -e.is_upper = True -print(e.s) ``` ## Tests diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 4fb4234d..63785a36 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -39,11 +39,6 @@ from codex32.checksums import CODEX32, CODEX32_LONG from codex32.errors import CodexError -HRP_CODES = { - "ms": 0, # BIP-0032 master seed - "cl": 1, # CLN HSM secret -} # Registry: https://github.com/satoshilabs/slips/blob/master/slip-0173.md#uses-of-codex32 -IDX_ORDER = "sacdefghjklmnpqrstuvwxyz023456789" # Canonical BIP93 share indices alphabetical order BECH32_INV = [ 0, 1, @@ -83,9 +78,6 @@ # pylint: disable=missing-class-docstring -class IdNotLength4(CodexError): ... - - class InvalidThreshold(CodexError): ... @@ -173,12 +165,16 @@ def decode(hrp: str, s: str, pad_val: int | str = "any"): decoded = convertbits(data[6:], 5, 8, False, pad_val) if hrp == "ms" and (not 16 <= (msl := len(decoded)) <= 64 or msl % 4): raise InvalidSeedLength(f"Master seeds must be in 16..20..64 bytes, got {msl}") + if header[5] != "s": + return header, None, None # shares are not seeds: no bytes or padding pad = data[-1] % (1 << ((len(data[6:]) * 5) % 8)) return header, bytes(decoded), pad if pad_val == "any" else pad_val def encode(hrp: str, header: str, seed: bytes, pad_val: int | str = "CRC"): - """Encode a codex32 string given HRP, header, seed, and padding.""" + """Encode a codex32 secret given HRP, header, seed, and padding.""" + if header[5:] != "s": + raise InvalidShareIndex(f"only secrets ('s') encode bytes, got '{header}'") u5_payload = convertbits(seed, 8, 5, True, pad_val) ret = codex32_encode(hrp, chars_to_u5(header) + u5_payload) if len(header) != 6 or (header, seed, pad_val) != decode(hrp, ret, pad_val): @@ -191,7 +187,7 @@ class Codex32String: def __init__(self, s: str) -> None: """Initialize Codex32String from a codex32 string.""" - self.is_upper = s.isupper() + self.s, self.is_upper = s, s.isupper() self.hrp = codex32_decode(s)[0] header, self.data, self.pad_val = decode(self.hrp, s) self.k, self.ident, self.share_idx = header[0], header[1:5], header[5] @@ -199,14 +195,7 @@ def __init__(self, s: str) -> None: @property def payload(self) -> str: """Return the payload part of the codex32 string.""" - return u5_to_chars(convertbits(self.data, 8, 5, True, self.pad_val)) - - @property - def s(self) -> str: - """Return the full codex32 string.""" - header = self.k + self.ident + self.share_idx - ret = encode(self.hrp, header, self.data, self.pad_val) - return ret.upper() if ret and self.is_upper else ret + return self.s[len(self.hrp) + 7 : len(self) - len(self.checksum)] def __str__(self) -> str: return self.s @@ -263,12 +252,7 @@ def interpolate_at( def from_seed( cls, data: bytes, prefix: str, pad_val: int | str = "CRC" ) -> "Codex32String": - """Create Codex32String from seed bytes and a prefix with an identifier.""" + """Create a secret Codex32String given prefix (HRP, k, ident) and seed.""" hrp, data_part = u5_parse(prefix) - header = u5_to_chars(data_part) - k = "0" if not header else header[:1] - ident = header[1 : max(5, len(header) - 1)] - if len(ident) != 4: - raise IdNotLength4(f"prefix must contain a four-character identifier, got {len(ident)}") - share_idx = "s" if not header[5:] else header[5:6] - return cls(encode(hrp, k + ident + share_idx, data, pad_val)) + header = u5_to_chars(data_part) + ("s" if len(data_part) == 5 else "") + return cls(encode(hrp, header, data, pad_val)) diff --git a/src/codex32/checksums.py b/src/codex32/checksums.py index a133fd61..5496a04a 100644 --- a/src/codex32/checksums.py +++ b/src/codex32/checksums.py @@ -1,5 +1,3 @@ -# Bech32/Bech32m constants in this file are derived from work by: -# Copyright (c) 2017, 2020 Pieter Wuille, MIT License # codex32 constants in this file are derived from work by: # Author: Leon Olsson Curr and Pearlwort Sneed # License: BSD-3-Clause @@ -25,11 +23,10 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -"""Checksum specs and utilities for Bech32, codex32, and CRC variants.""" +"""Checksum specs and utilities for codex32 and CRC variants.""" # Generators are the reduction polynomials for polymod computations -BECH32_GEN = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] CODEX32_GEN = [ 0x19DC500CE73FDE210, 0x1BFAE00DEF77FE529, @@ -44,8 +41,6 @@ 0x0C577EAECCF1990D13C, 0x1887F74F8DC71B10651, ] -BECH32_CONST = 1 -BECH32M_CONST = 0x2BC830A3 CODEX32_CONST = 0x10CE0795C2FD1E62A CODEX32_LONG_CONST = 0x43381E570BF4798AB26 @@ -84,8 +79,6 @@ def create(self, values): codex32_long_spec = (CODEX32_LONG_GEN, 15, CODEX32_LONG_CONST) # detects 8 errors CODEX32_LONG = Checksum("Long codex32", codex32_long_spec, (81, 1008)) CODEX32 = Checksum("codex32", (CODEX32_GEN, 13, CODEX32_CONST), (0, 80)) -BECH32 = Checksum("Bech32", (BECH32_GEN, 6, BECH32_CONST), (0, 83)) # detects 4 errors -BECH32M = Checksum("Bech32m", (BECH32_GEN, 6, BECH32M_CONST), (0, 83)) CRC1 = Checksum("CRC1", ([1], 1, 0), (0, 0)) CRC2 = Checksum("CRC2", ([3], 2, 0), (0, 1)) # detects 2 errors CRC3 = Checksum("CRC3", ([3], 3, 0), (0, 4)) diff --git a/src/codex32/segwit_addr.py b/src/codex32/segwit_addr.py deleted file mode 100644 index f970b009..00000000 --- a/src/codex32/segwit_addr.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright (c) 2017, 2020 Pieter Wuille -# Copyright (c) 2026 Ben Westgate -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -"""Reference implementation for Bech32/Bech32m and segwit addresses.""" - -from enum import Enum - -from codex32.errors import CodexError -from codex32.bech32 import u5_encode, u5_decode, convertbits -from codex32.checksums import BECH32, BECH32M - - -class Encoding(Enum): - """Enumeration type to list the various supported encodings.""" - - BECH32 = 1 - BECH32M = 2 - - -def bech32_encode(hrp, data, spec): - """Compute a Bech32 string given HRP and data values.""" - return u5_encode(hrp, data, BECH32 if spec == Encoding.BECH32 else BECH32M) - - -def bech32_decode(bech: str): - """Validate a Bech32/Bech32m string, and determine HRP and data.""" - try: - hrp, data, spec = u5_decode(bech, [BECH32, BECH32M]) - if spec is None: - return (None, None, None) - return hrp, data, Encoding.BECH32 if spec == BECH32 else Encoding.BECH32M - except CodexError: - return (None, None, None) - - -def decode(hrp: str, addr): - """Decode a segwit address.""" - hrpgot, data, spec = bech32_decode(addr) - if hrpgot != hrp or not data: - return (None, None) - try: - decoded = convertbits(data[1:], 5, 8, False) - except CodexError: - decoded = None - if decoded is None or len(decoded) < 2 or len(decoded) > 40: - return (None, None) - if data[0] > 16: - return (None, None) - if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: - return (None, None) - if ( - data[0] == 0 - and spec != Encoding.BECH32 - or data[0] != 0 - and spec != Encoding.BECH32M - ): - return (None, None) - return (data[0], decoded) - - -def encode(hrp, witver, witprog): - """Encode a segwit address.""" - spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M - ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5, True, 0), spec) - if decode(hrp, ret) == (None, None): - return None - return ret diff --git a/tests/data/bip93_vectors.py b/tests/data/bip93_vectors.py index 2f03743e..a3b42639 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -68,7 +68,6 @@ VECTOR_6 = { "codex32_luea": "cl10lueasd35kw6r5de5kueedxyesqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqanvrktzhlhusz", - "ident_cln2": "cln2", "codex32_cln2": "cl10cln2sd35kw6r5de5kueedxyesqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqn9lcvcu7cez4s", "codex32_peev": "cl10peevst6cqh0wu7p5ssjyf4z4ez42ks9jlt3zneju9uuypr2hddak6tlqsjhsks4laxts8q", } @@ -163,6 +162,7 @@ ("ms", "0fauxxxs", bytes(16)), # Invalid identifier length ("ms", "fauxxs", bytes(32)), # Invalid threshold ("ms", "0fauxx", bytes(64)), # Invalid share idx + ("ms", "2fauxa", bytes(16)), # Invalid share idx: shares are not encoded from bytes ("ms", "0fauxs", bytes(15)), # Invalid seed length (<16 bytes) ("ms", "0fauxs", bytes(65)), # Invalid seed length (>64 bytes) ("ms", "0fauxs", bytes(17)), # Invalid seed length (non-multiple of 4 bytes) diff --git a/tests/test_bech32.py b/tests/test_bech32.py deleted file mode 100644 index 7c9bc763..00000000 --- a/tests/test_bech32.py +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/python3 - -# Copyright (c) 2017 Pieter Wuille -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - - -"""Reference tests for segwit adresses""" - -import binascii -import unittest -from codex32 import segwit_addr - - -def segwit_scriptpubkey(witver, witprog): - """Construct a Segwit scriptPubKey for a given witness program.""" - return bytes([witver + 0x50 if witver else 0, len(witprog)] + witprog) - - -VALID_BECH32 = [ - "A12UEL5L", - "a12uel5l", - "an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", - "abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", - "11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", - "split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", - "?1ezyfcl", -] - -VALID_BECH32M = [ - "A1LQFN3A", - "a1lqfn3a", - "an83characterlonghumanreadablepartthatcontainsthetheexcludedcharactersbioandnumber11sg7hg6", - "abcdef1l7aum6echk45nj3s0wdvt2fg8x9yrzpqzd3ryx", - "11llllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllludsr8", - "split1checkupstagehandshakeupstreamerranterredcaperredlc445v", - "?1v759aa", -] - -INVALID_BECH32 = [ - " 1nwldj5", # HRP character out of range - "\x7f" + "1axkwrx", # HRP character out of range - "\x80" + "1eym55h", # HRP character out of range - # overall max length exceeded - "an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx", - "pzry9x0s0muk", # No separator character - "1pzry9x0s0muk", # Empty HRP - "x1b4n0q5v", # Invalid data character - "li1dgmt3", # Too short checksum - "de1lg7wt" + "\xff", # Invalid character in checksum - "A1G7SGD8", # checksum calculated with uppercase form of HRP - "10a06t8", # empty HRP - "1qzzfhee", # empty HRP -] - -INVALID_BECH32M = [ - " 1xj0phk", # HRP character out of range - "\x7f" + "1g6xzxy", # HRP character out of range - "\x80" + "1vctc34", # HRP character out of range - # overall max length exceeded - "an84characterslonghumanreadablepartthatcontainsthetheexcludedcharactersbioandnumber11d6pts4", - "qyrz8wqd2c9m", # No separator character - "1qyrz8wqd2c9m", # Empty HRP - "y1b0jsk6g", # Invalid data character - "lt1igcx5c0", # Invalid data character - "in1muywd", # Too short checksum - "mm1crxm3i", # Invalid character in checksum - "au1s5cgom", # Invalid character in checksum - "M1VUXWEZ", # Checksum calculated with uppercase form of HRP - "16plkw9", # Empty HRP - "1p2gdwpf", # Empty HRP -] - -VALID_ADDRESS = [ - [ - "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4", - "0014751e76e8199196d454941c45d1b3a323f1433bd6", - ], - [ - "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7", - "00201863143c14c5166804bd19203356da136c985678cd4d27a1b8c6329604903262", - ], - [ - "bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y", - "5128751e76e8199196d454941c45d1b3a323f1433bd6751e76e8199196d454941c45d1b3a323f1433bd6", - ], - ["BC1SW50QGDZ25J", "6002751e"], - ["bc1zw508d6qejxtdg4y5r3zarvaryvaxxpcs", "5210751e76e8199196d454941c45d1b3a323"], - [ - "tb1qqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesrxh6hy", - "0020000000c4a5cad46221b2a187905e5266362b99d5e91c6ce24d165dab93e86433", - ], - [ - "tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c", - "5120000000c4a5cad46221b2a187905e5266362b99d5e91c6ce24d165dab93e86433", - ], - [ - "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", - "512079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - ], -] - -INVALID_ADDRESS = [ - # Invalid HRP - "tc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq5zuyut", - # Invalid checksum algorithm (bech32 instead of bech32m) - "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqh2y7hd", - # Invalid checksum algorithm (bech32 instead of bech32m) - "tb1z0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqglt7rf", - # Invalid checksum algorithm (bech32 instead of bech32m) - "BC1S0XLXVLHEMJA6C4DQV22UAPCTQUPFHLXM9H8Z3K2E72Q4K9HCZ7VQ54WELL", - # Invalid checksum algorithm (bech32m instead of bech32) - "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh", - # Invalid checksum algorithm (bech32m instead of bech32) - "tb1q0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq24jc47", - # Invalid character in checksum - "bc1p38j9r5y49hruaue7wxjce0updqjuyyx0kh56v8s25huc6995vvpql3jow4", - # Invalid witness version - "BC130XLXVLHEMJA6C4DQV22UAPCTQUPFHLXM9H8Z3K2E72Q4K9HCZ7VQ7ZWS8R", - # Invalid program length (1 byte) - "bc1pw5dgrnzv", - # Invalid program length (41 bytes) - "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7v8n0nx0muaewav253zgeav", - # Invalid program length for witness version 0 (per BIP141) - "BC1QR508D6QEJXTDG4Y5R3ZARVARYV98GJ9P", - # Mixed case - "tb1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq47Zagq", - # More than 4 padding bits - "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7v07qwwzcrf", - # Non-zero padding in 8-to-5 conversion - "tb1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vpggkg4j", - # Empty data section - "bc1gmk9yu", -] - -INVALID_ADDRESS_ENC = [ - ("BC", 0, 20), - ("bc", 0, 21), - ("bc", 17, 32), - ("bc", 1, 1), - ("bc", 16, 41), -] - - -class TestSegwitAddress(unittest.TestCase): - """Unit test class for segwit addressess.""" - - def test_valid_checksum(self): - """Test checksum creation and validation.""" - for spec in segwit_addr.Encoding: - tests = ( - VALID_BECH32 if spec == segwit_addr.Encoding.BECH32 else VALID_BECH32M - ) - for test in tests: - hrp, _, dspec = segwit_addr.bech32_decode(test) - self.assertTrue(hrp is not None and dspec == spec) - pos = test.rfind("1") - test = test[: pos + 1] + chr(ord(test[pos + 1]) ^ 1) + test[pos + 2 :] - hrp, _, dspec = segwit_addr.bech32_decode(test) - self.assertIsNone(hrp) - - def test_invalid_checksum(self): - """Test validation of invalid checksums.""" - for spec in segwit_addr.Encoding: - tests = ( - INVALID_BECH32 - if spec == segwit_addr.Encoding.BECH32 - else INVALID_BECH32M - ) - for test in tests: - hrp, _, dspec = segwit_addr.bech32_decode(test) - self.assertTrue(hrp is None or dspec != spec) - - def test_valid_address(self): - """Test whether valid addresses decode to the correct output.""" - for address, hexscript in VALID_ADDRESS: - hrp = "bc" - witver, witprog = segwit_addr.decode(hrp, address) - if witver is None: - hrp = "tb" - witver, witprog = segwit_addr.decode(hrp, address) - self.assertIsNotNone(witver, address) - scriptpubkey = segwit_scriptpubkey(witver, witprog) - self.assertEqual(scriptpubkey, binascii.unhexlify(hexscript)) - addr = segwit_addr.encode(hrp, witver, witprog) - self.assertEqual(address.lower(), addr) - - def test_invalid_address(self): - """Test whether invalid addresses fail to decode.""" - for test in INVALID_ADDRESS: - witver, _ = segwit_addr.decode("bc", test) - self.assertIsNone(witver) - witver, _ = segwit_addr.decode("tb", test) - self.assertIsNone(witver) - - def test_invalid_address_enc(self): - """Test whether address encoding fails on invalid input.""" - for hrp, version, length in INVALID_ADDRESS_ENC: - code = segwit_addr.encode(hrp, version, [0] * length) - self.assertIsNone(code) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_bip93.py b/tests/test_bip93.py index f466fecf..1212cbf8 100644 --- a/tests/test_bip93.py +++ b/tests/test_bip93.py @@ -76,6 +76,7 @@ def test_derive_and_recover(): a = Codex32String(VECTOR_2["share_A"]) c = Codex32String(VECTOR_2["share_C"]) # interpolation target is 'D' (uppercase as inputs are uppercase) + assert a.data is None and c.pad_val is None # shares have no seed bytes d = Codex32String.interpolate_at([a, c], "D") assert str(d) == VECTOR_2["derived_D"] s = Codex32String.interpolate_at([a, c], "S") @@ -133,8 +134,6 @@ def test_alternate_hrp(): """Test Vector 6: codex32 strings with "cl" HRP.""" c0 = Codex32String(VECTOR_6["codex32_luea"]) assert str(c0) == VECTOR_6["codex32_luea"] - c0.ident = VECTOR_6["ident_cln2"] - assert str(c0) == VECTOR_6["codex32_cln2"] c1 = Codex32String(VECTOR_6["codex32_cln2"]) assert str(c1) == VECTOR_6["codex32_cln2"] c2 = Codex32String.from_string("cl", VECTOR_6["codex32_peev"]) diff --git a/tests/test_roundtrip_interpolated.py b/tests/test_roundtrip_interpolated.py deleted file mode 100644 index 6c6dfbef..00000000 --- a/tests/test_roundtrip_interpolated.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Test for round-trip encoding/decoding and recovery via interpolation.""" - -from codex32 import Codex32String, decode - - -def test_round_trip_recovery(): - """Test round-trip encoding/decoding and recovery via interpolation.""" - # secret share from seed - s = Codex32String.from_seed( - bytes.fromhex("68f14219957131d21b615271058437e8"), "ms13k00ls" - ) - assert s.s == "ms13k00lsdrc5yxv4wycayxmp2fcstpphaq55a60p9hfds9t" - a = Codex32String.from_seed( - bytes.fromhex("641be1cb12c97ede1c6bad8edf067760"), "ms13k00la" - ) - assert a.s == "ms13k00lavsd7rjcje9ldu8rt4k8d7pnhvppyrt5gpff9wwl" - c = Codex32String.from_seed( - bytes.fromhex("61b3c4052f7a31dc2b425c843a13c9b4"), "ms13k00lc" - ) - assert c.s == "ms13k00lcvxeugpf00gcac26ztjzr5y7fknx9cw72l8md0xn" - # derive next share via interpolation - d = Codex32String.interpolate_at([s, a, c], "d") - assert d.s == "ms13k00ldp4v5nw8lph96x47mjxzgwjexehw766s4dmj6qx8" - - # now round-trip d share ('d' is derived via interpolation, NOT via 'from_seed') - dd = Codex32String.from_seed(d.data, "ms13k00ld", d.pad_val) - assert dd.s == d.s - - e = Codex32String.interpolate_at([s, a, c], "e") - f = Codex32String.interpolate_at([s, a, c], "f") - assert e.s == "ms13k00lezuknydaaygk5u20zs4fm736vj6zlcrjhtduanyk" - assert f.s == "ms13k00lf0ehe53zsu6vrxcjjh9v7wzsa8vd9pjk28l8zavw" - - # recover from shares, use 'd' without round-trip - rec_s = Codex32String.interpolate_at([a, c, d], "s") - # recover from shares, use 'd' after round-trip - rec_ss = Codex32String.interpolate_at([a, c, dd], "s") - # confirm recovered secrets and padding match original - assert decode(s.hrp, rec_s.s, "CRC") == ("3k00ls", s.data, "CRC") - assert decode(s.hrp, rec_ss.s, "CRC") == ("3k00ls", s.data, "CRC") From 925be0bd19f01baea073de15c41396bad75bef7a Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 17 Oct 2026 14:30:46 +0000 Subject: [PATCH 2/6] python-codex32: follow BIP-93 master and the HRP draft (bips#2320) - "ms" strings must have an encoded length of 48, 54, 61, 67, 74 or 127, and master seeds must be 16, 20, 24, 28, 32 or 64 bytes. - Select the checksum by expanded codeword length (HRP expansion plus data part): regular up to 93, long from 96 to 1023; 94 and 95 are invalid. - Limit the HRP to 83 characters. - Add Test vectors 6-8 from BIP-93 master and 9-11 plus the new invalid vectors from bips#2320. Drop the long-HRP boundary vectors that encoded the old data-length checksum selection. Claude-Session: https://claude.ai/code/session_01PYQtm2qPkBYqZ9WuNvhv51 --- README.md | 2 +- src/codex32/bech32.py | 10 +++++---- src/codex32/bip93.py | 9 +++++--- src/codex32/checksums.py | 4 ++-- tests/data/bip93_vectors.py | 41 +++++++++++++++++++++++++++-------- tests/test_bip93.py | 43 ++++++++++++++++++++++++++++++------- 6 files changed, 82 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d45b660e..09141a39 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Reference implementation of BIP-0093 (codex32): checksummed, SSSS-aware BIP32 se This repository implements the codex32 string format described by BIP-0093. It provides encoding/decoding, regular/long codex32 checksums, CRC padding for base conversions, Shamir secret sharing scheme (SSSS) interpolation helpers and helpers to build codex32 secrets from seed bytes. -Shares are made only by interpolation, never from bytes. +Shares are never encoded from bytes: generate them from random characters or by interpolation. ## Features - Encode/decode codex32 data via `from_string` and `from_unchecksummed_string`. diff --git a/src/codex32/bech32.py b/src/codex32/bech32.py index 3e734e93..a401d04c 100644 --- a/src/codex32/bech32.py +++ b/src/codex32/bech32.py @@ -101,7 +101,8 @@ def u5_parse(bech: str) -> tuple[str, list[int]]: raise InvalidCase("mixed upper/lower case bech32 string") if (pos := (bech := bech.lower()).rfind("1")) < 1: raise MissingHrp("empty HRP") if not pos else SeparatorNotFound("'1' not found") - hrp = bech[:pos] + if len(hrp := bech[:pos]) > 83: + raise InvalidLength(f"HRP has {len(hrp)} characters, the maximum is 83") data = chars_to_u5(bech[pos + 1 :]) return hrp, data @@ -111,13 +112,14 @@ def u5_decode(bech: str, encodings: list[Checksum]) -> tuple[str, list[int], Che hrp, data = u5_parse(bech) e = MissingEncoding("no encoding or encodings were passed") for spec in encodings: - if len(hrp) <= (datlen := len(bech) - 1 - spec.cs_len): - if datlen in (c := spec.coverage): + explen = len(bech32_hrp_expand(hrp)) + len(data) + if spec.cs_len <= len(data): + if explen in (c := spec.coverage): if spec.verify(bech32_hrp_expand(hrp) + data): return hrp, data[: -spec.cs_len], spec e = InvalidChecksum(f"{spec.kind} checksum invalid for hrp and data") if not isinstance(e, InvalidChecksum): - e = InvalidLength(f"{datlen} chars {spec.kind} reqs {min(c)}..{max(c)}") + e = InvalidLength(f"{explen} expanded, {spec.kind}: {min(c)}-{max(c)}") if not isinstance(e, (InvalidLength, InvalidChecksum)): e = MissingChecksum(f"{spec.kind}: {len(data)} data chars < {spec.cs_len}") raise e diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 63785a36..a9c7c294 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -29,6 +29,7 @@ from codex32.bech32 import ( CHARSET, + InvalidLength, chars_to_u5, convertbits, u5_to_chars, @@ -130,6 +131,8 @@ def bech32_lagrange(pts, x): def codex32_decode(codex): """Validate a codex32 string, and determine HRP and data.""" + if u5_parse(codex)[0] == "ms" and len(codex) not in (48, 54, 61, 67, 74, 127): + raise InvalidLength("'ms' strings must be 48, 54, 61, 67, 74 or 127 chars") return u5_decode(codex, [CODEX32_LONG, CODEX32]) @@ -147,7 +150,7 @@ def codex32_interpolate(strings, x): def codex32_encode(hrp: str, data): """Compute a codex32 string given HRP and data values.""" - spec = CODEX32_LONG if len(hrp) + len(data) > 80 else CODEX32 + spec = CODEX32_LONG if 2 * len(hrp) + 1 + len(data) + 13 > 93 else CODEX32 return u5_encode(hrp, data, spec) @@ -163,8 +166,8 @@ def decode(hrp: str, s: str, pad_val: int | str = "any"): if k == "0" and (idx := header[5]) != "s": raise InvalidShareIndex(f"share index '{idx}' must be 's' when k='0'") decoded = convertbits(data[6:], 5, 8, False, pad_val) - if hrp == "ms" and (not 16 <= (msl := len(decoded)) <= 64 or msl % 4): - raise InvalidSeedLength(f"Master seeds must be in 16..20..64 bytes, got {msl}") + if hrp == "ms" and (msl := len(decoded)) not in (16, 20, 24, 28, 32, 64): + raise InvalidSeedLength(f"Master seeds are 16-32 (by 4) or 64 bytes, got {msl}") if header[5] != "s": return header, None, None # shares are not seeds: no bytes or padding pad = data[-1] % (1 << ((len(data[6:]) * 5) % 8)) diff --git a/src/codex32/checksums.py b/src/codex32/checksums.py index 5496a04a..499b2b3c 100644 --- a/src/codex32/checksums.py +++ b/src/codex32/checksums.py @@ -77,8 +77,8 @@ def create(self, values): codex32_long_spec = (CODEX32_LONG_GEN, 15, CODEX32_LONG_CONST) # detects 8 errors -CODEX32_LONG = Checksum("Long codex32", codex32_long_spec, (81, 1008)) -CODEX32 = Checksum("codex32", (CODEX32_GEN, 13, CODEX32_CONST), (0, 80)) +CODEX32_LONG = Checksum("Long codex32", codex32_long_spec, (96, 1023)) # expanded +CODEX32 = Checksum("codex32", (CODEX32_GEN, 13, CODEX32_CONST), (0, 93)) CRC1 = Checksum("CRC1", ([1], 1, 0), (0, 0)) CRC2 = Checksum("CRC2", ([3], 2, 0), (0, 1)) # detects 2 errors CRC3 = Checksum("CRC3", ([3], 3, 0), (0, 4)) diff --git a/tests/data/bip93_vectors.py b/tests/data/bip93_vectors.py index a3b42639..8d741f66 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -66,19 +66,33 @@ "secret_hex": "dc5423251cb87175ff8110c8531d0952d8d73e1194e95b5f19d6f9df7c01111104c9baecdfea8cccc677fb9ddc8aec5553b86e528bcadfdcc201c17c638c47e9", } -VECTOR_6 = { +VECTORS_6_7_8 = [ # (master seed hex, codex32 secret) + ("000102030405060708090a0b0c0d0e0f10111213", "ms10seedsqqqsyqcyq5rqwzqfpg9scrgwpugpzysn9vaqzzvs20xnl"), + ("202122232425262728292a2b2c2d2e2f3031323334353637", "ms10seedsyqsjygeyy5nzw2pf9g4jctfw9ucrzv3nxs6nvdau84gz0632s0xs"), + ("404142434445464748494a4b4c4d4e4f505152535455565758595a5b", "ms10seedsgpq5ys6yg4rywjzfff95cn2wfag9z5jn2324v46ct9d9hrcduqw8c3lccl"), +] + +VECTOR_9 = { "codex32_luea": "cl10lueasd35kw6r5de5kueedxyesqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqanvrktzhlhusz", "codex32_cln2": "cl10cln2sd35kw6r5de5kueedxyesqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqn9lcvcu7cez4s", "codex32_peev": "cl10peevst6cqh0wu7p5ssjyf4z4ez42ks9jlt3zneju9uuypr2hddak6tlqsjhsks4laxts8q", + "ms_peev": "ms10peevst6cqh0wu7p5ssjyf4z4ez42ks9jlt3zneju9uuypr2hddak6tlqstxmpzl24l6e0d", +} + +VECTOR_10 = { + "share_a": "test_vector12spana320zyxwvutsrqpnmlkjhgfedca320zyxwvutsrqpnmlkjhgfedca304ppsqh4l7v3dh", + "share_c": "test_vector12spancacdefghjklmnpqrstuvwxyz023acdefghjklmnpqrstuvwxyz023jxmjy7q9xl7d3ul", + "derived_d": "test_vector12spandll4f8jlh4e5vdvuldlfxu2jhdnll4f8jlh4e5vdvuldlfxu2jhdnx3pe3yqzdl7p30y", + "secret_s": "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5kdf37v5qm2l7l3az", } +VECTOR_11 = "a83characterlongcodex32humanreadablepartforsecretsharewiththedigit1andthelettersbio10keyss9mfjjk25y05e3nq" + VALID_CODEX32 = [ "A12UEL5LLGCHJ4UJCQVHG", "a12uel5llgchj4ujcqvhg", - "a74characterlonghumanreadablepartcontainingnumber1andexcludedcharactersbio15tttgsdupy3h58nvmja", "abcdef13qpzry9x8gf2tvdw0s3jn54khce6mua7lclc606q3t75r4", - "1199999999999999999999999999999999999999999999999999999999999999999999999999999997f7ekwq8dq7tm", "split12checkupstagehandshakeupstreamerranterredcaperred75pe8uz2kh9ey", "?13zyfclf624rkvjcl35t", ] @@ -86,9 +100,7 @@ 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", "?17v59aaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2uwygewx4t4gps0", ] @@ -96,8 +108,6 @@ " 12fauxxpgjxu9gyqhql4", # HRP character out of range "\x7f" + "12fauxxk7kd7xqlns9mj", # HRP character out of range "\x80" + "12fauxxgqp5ecwf5kzg3", # HRP character out of range - # overall max length exceeded - "a75characterslonghumanreadablepartcontainingnumber1andexcludedcharactersbio12fauxxau7wnkdhzp90r", "x12fauxbhf2k7v7ay7ua5", # Invalid data character "li12fauxxz4pdg55uwav3", # Too short checksum "de12fauxxrmt7mj886swl" + "\xff", # Invalid character in checksum @@ -110,8 +120,6 @@ + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx836hdd09mhkhkhx", # HRP character out of range "\x80" + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxlhf5ywnkmk4r3tc", # HRP character out of range - # overall max length exceeded - "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 @@ -244,6 +252,21 @@ "s10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxh9d0fhnvfyx3x", ] +VALID_NOT_MASTER_SEED = [ + "m10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxx8t28z74x8hs4l", + "s10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxh9d0fhnvfyx3x", +] + +INVALID_HRP_VECTORS = [ + # expanded codeword lengths 94 and 95 + "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5kp0f0tlr2cxh0t", + "test_vector12spans6xqguzttxkeqnjsjzv4jv3nz5k6xqguzttxkeqnjsjzv4jv3nz5c2m7mxmk8s5qecp", + # checksum computed with the uppercase form of the HRP + "CL10PEEVST6CQH0WU7P5SSJYF4Z4EZ42KS9JLT3ZNEJU9UUYPR2HDDAK6TLQS3J5AYX4Y08079", + # HRP longer than 83 characters + "a84characterslongcodex32humanreadablepartforsecretsharewiththedigit1andthelettersbio10keyss9hqxu3pxsnkdkth", +] + BAD_CASES = [ "Ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2", "mS10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2", diff --git a/tests/test_bip93.py b/tests/test_bip93.py index 1212cbf8..219d8b67 100644 --- a/tests/test_bip93.py +++ b/tests/test_bip93.py @@ -9,7 +9,12 @@ VECTOR_3, VECTOR_4, VECTOR_5, - VECTOR_6, + VECTORS_6_7_8, + VECTOR_9, + VECTOR_10, + VECTOR_11, + VALID_NOT_MASTER_SEED, + INVALID_HRP_VECTORS, VALID_CODEX32, VALID_CODEX32_LONG, INVALID_CODEX32, @@ -130,14 +135,35 @@ def test_long_string(): assert long_seed.data.hex() == VECTOR_5["secret_hex"] +def test_master_seed_sizes(): + """Test Vectors 6, 7 and 8: 160-, 192- and 224-bit master seeds.""" + for seed_hex, secret in VECTORS_6_7_8: + assert Codex32String(secret).data.hex() == seed_hex + + def test_alternate_hrp(): - """Test Vector 6: codex32 strings with "cl" HRP.""" - c0 = Codex32String(VECTOR_6["codex32_luea"]) - assert str(c0) == VECTOR_6["codex32_luea"] - c1 = Codex32String(VECTOR_6["codex32_cln2"]) - assert str(c1) == VECTOR_6["codex32_cln2"] - c2 = Codex32String.from_string("cl", VECTOR_6["codex32_peev"]) - assert str(c2) == VECTOR_6["codex32_peev"] + """Test Vector 9: codex32 strings with "cl" HRP.""" + c0 = Codex32String(VECTOR_9["codex32_luea"]) + assert str(c0) == VECTOR_9["codex32_luea"] + c1 = Codex32String(VECTOR_9["codex32_cln2"]) + assert str(c1) == VECTOR_9["codex32_cln2"] + c2 = Codex32String.from_string("cl", VECTOR_9["codex32_peev"]) + assert str(c2) == VECTOR_9["codex32_peev"] + assert Codex32String(VECTOR_9["ms_peev"]).payload == c2.payload + for s in VALID_NOT_MASTER_SEED: + assert str(Codex32String(s)) == s + + +def test_long_hrp(): + """Test Vectors 10 and 11: long HRPs select the checksum by expanded length.""" + a, c = Codex32String(VECTOR_10["share_a"]), Codex32String(VECTOR_10["share_c"]) + assert len(a.checksum) == 15 + assert str(Codex32String.interpolate_at([a, c], "d")) == VECTOR_10["derived_d"] + assert str(Codex32String.interpolate_at([a, c], "s")) == VECTOR_10["secret_s"] + assert len(Codex32String(VECTOR_11).hrp) == 83 + for s in INVALID_HRP_VECTORS: + with pytest.raises((InvalidLength, InvalidChecksum)): + Codex32String(s) def test_valid_codex32(): @@ -197,6 +223,7 @@ def test_invalid_master_seed_enc(): InvalidDataValue, InvalidThreshold, InvalidShareIndex, + InvalidLength, AssertionError, ) ): From c24a4d48d717561a9a8a9eb282aa687afaf3defb Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Sat, 17 Oct 2026 16:37:25 +0000 Subject: [PATCH 3/6] python-codex32: keep byte encoding "ms"-specific codex32_decode now checks only what every codex32 string has: the checksum and the header (digit threshold, share index "s" when k=0). ms32_verify adds the BIP-93 "ms" length rule, and Codex32String uses it for "ms" strings, so generic strings with other HRPs no longer need byte-aligned payloads. encode() and decode() only handle codex32-encoded master seeds, so they drop their hrp argument. Codex32String stores the HRP, header and payload characters and gets bytes from data(), which only works for "ms" secrets. The seed-size check and InvalidSeedLength are gone since the "ms" lengths already imply the seed sizes. Claude-Session: https://claude.ai/code/session_01PYQtm2qPkBYqZ9WuNvhv51 --- README.md | 3 +- src/codex32/bip93.py | 69 +++++++++++++++++++------------------ tests/data/bip93_vectors.py | 29 +++++++--------- tests/test_bip93.py | 40 +++++++-------------- 4 files changed, 63 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 09141a39..d3642bde 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,7 @@ print(a.payload) # payload part print(a.checksum) # checksum part print(len(a)) # length of the codex32 string print(a.is_upper) # case is upper True/False -print(s.data.hex()) # raw seed bytes as hex (None for shares) -print(s.pad_val) # padding value integer, (MSB first) (None for shares) +print(s.data().hex()) # master seed bytes as hex ("ms" secrets only) diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index a9c7c294..230d4e57 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -103,9 +103,6 @@ class RepeatedIndex(CodexError): ... class ThresholdNotPassed(CodexError): ... -class InvalidSeedLength(CodexError): ... - - def bech32_mul(a, b): """Multiply two Bech32 values.""" res = 0 @@ -130,10 +127,24 @@ def bech32_lagrange(pts, x): def codex32_decode(codex): - """Validate a codex32 string, and determine HRP and data.""" - if u5_parse(codex)[0] == "ms" and len(codex) not in (48, 54, 61, 67, 74, 127): + """Validate a codex32 string's checksum and header, and determine HRP and data.""" + hrp, data, spec = u5_decode(codex, [CODEX32_LONG, CODEX32]) + if len(header := u5_to_chars(data[:6])) < 6: + raise MismatchedLength(f"'{header}' header too short: {len(data)} < 6") + if not (k := header[0]).isdigit(): + raise InvalidThreshold(f"threshold parameter '{k}' must be a digit") + if k == "0" and (idx := header[5]) != "s": + raise InvalidShareIndex(f"share index '{idx}' must be 's' when k='0'") + return hrp, data, spec + + +def ms32_verify(s: str): + """Validate a codex32-encoded master seed or share, and determine HRP and data.""" + if (hrp := u5_parse(s)[0]) != "ms": + raise MismatchedHrp(f"{hrp} != ms") + if len(s) not in (48, 54, 61, 67, 74, 127): # 16-32 (by 4) or 64 byte seeds raise InvalidLength("'ms' strings must be 48, 54, 61, 67, 74 or 127 chars") - return u5_decode(codex, [CODEX32_LONG, CODEX32]) + return codex32_decode(s) def codex32_interpolate(strings, x): @@ -154,33 +165,23 @@ def codex32_encode(hrp: str, data): return u5_encode(hrp, data, spec) -def decode(hrp: str, s: str, pad_val: int | str = "any"): - """Decode a codex32 string, and determine header, seed, and padding.""" - hrpgot, data, _ = codex32_decode(s) - if hrpgot != hrp: - raise MismatchedHrp(f"{hrpgot} != {hrp}") - if len(header := u5_to_chars(data[:6])) < 6: - raise MismatchedLength(f"'{header}' header too short: {len(data)} < 6") - if not (k := header[0]).isdigit(): - raise InvalidThreshold(f"threshold parameter '{k}' must be a digit") - if k == "0" and (idx := header[5]) != "s": - raise InvalidShareIndex(f"share index '{idx}' must be 's' when k='0'") +def decode(s: str, pad_val: int | str = "any"): + """Decode a codex32-encoded master seed, and determine header, seed, and padding.""" + data = ms32_verify(s)[1] + if (header := u5_to_chars(data[:6]))[5] != "s": + raise InvalidShareIndex(f"only master seeds ('s') have bytes, got '{header}'") decoded = convertbits(data[6:], 5, 8, False, pad_val) - if hrp == "ms" and (msl := len(decoded)) not in (16, 20, 24, 28, 32, 64): - raise InvalidSeedLength(f"Master seeds are 16-32 (by 4) or 64 bytes, got {msl}") - if header[5] != "s": - return header, None, None # shares are not seeds: no bytes or padding pad = data[-1] % (1 << ((len(data[6:]) * 5) % 8)) return header, bytes(decoded), pad if pad_val == "any" else pad_val -def encode(hrp: str, header: str, seed: bytes, pad_val: int | str = "CRC"): - """Encode a codex32 secret given HRP, header, seed, and padding.""" +def encode(header: str, seed: bytes, pad_val: int | str = "CRC"): + """Encode a codex32-encoded master seed given header, seed, and padding.""" if header[5:] != "s": raise InvalidShareIndex(f"only secrets ('s') encode bytes, got '{header}'") u5_payload = convertbits(seed, 8, 5, True, pad_val) - ret = codex32_encode(hrp, chars_to_u5(header) + u5_payload) - if len(header) != 6 or (header, seed, pad_val) != decode(hrp, ret, pad_val): + ret = codex32_encode("ms", chars_to_u5(header) + u5_payload) + if len(header) != 6 or (header, seed, pad_val) != decode(ret, pad_val): raise MismatchedLength(f"'{header}' header must be 6 chars, got {len(header)}") return ret @@ -191,14 +192,14 @@ class Codex32String: def __init__(self, s: str) -> None: """Initialize Codex32String from a codex32 string.""" self.s, self.is_upper = s, s.isupper() - self.hrp = codex32_decode(s)[0] - header, self.data, self.pad_val = decode(self.hrp, s) + verify = ms32_verify if u5_parse(s)[0] == "ms" else codex32_decode + self.hrp, data, _ = verify(s) + header, self.payload = u5_to_chars(data[:6]), u5_to_chars(data[6:]) self.k, self.ident, self.share_idx = header[0], header[1:5], header[5] - @property - def payload(self) -> str: - """Return the payload part of the codex32 string.""" - return self.s[len(self.hrp) + 7 : len(self) - len(self.checksum)] + def data(self) -> bytes: + """Return the master seed bytes of a codex32-encoded master seed.""" + return decode(self.s)[1] def __str__(self) -> str: return self.s @@ -255,7 +256,9 @@ def interpolate_at( def from_seed( cls, data: bytes, prefix: str, pad_val: int | str = "CRC" ) -> "Codex32String": - """Create a secret Codex32String given prefix (HRP, k, ident) and seed.""" + """Create a master seed secret given prefix (ms1, k, ident) and seed bytes.""" hrp, data_part = u5_parse(prefix) + if hrp != "ms": + raise MismatchedHrp(f"{hrp} != ms") header = u5_to_chars(data_part) + ("s" if len(data_part) == 5 else "") - return cls(encode(hrp, header, data, pad_val)) + return cls(encode(header, data, pad_val)) diff --git a/tests/data/bip93_vectors.py b/tests/data/bip93_vectors.py index 8d741f66..0f914eeb 100644 --- a/tests/data/bip93_vectors.py +++ b/tests/data/bip93_vectors.py @@ -160,22 +160,19 @@ ] INVALID_MASTER_SEED_ENC = [ - ("MS", "0FAUXS", bytes(16)), # Invalid uppercase - ("MS", "0fauxs", bytes(16)), # Invalid mixed case - ("ms", "0FAUXS", bytes(16)), # Invalid mixed case - ("", "0fauxs", bytes(16)), # Invalid empty HRP - ("ms", "", b"\xd0" * 32), # Invalid empty header - ("ms", "0faux", b"\x80" * 32), # Invalid missing share idx - ("ms", "0", b"\x80" * 32), # Invalid missing identifier and share idx - ("ms", "0fauxxxs", bytes(16)), # Invalid identifier length - ("ms", "fauxxs", bytes(32)), # Invalid threshold - ("ms", "0fauxx", bytes(64)), # Invalid share idx - ("ms", "2fauxa", bytes(16)), # Invalid share idx: shares are not encoded from bytes - ("ms", "0fauxs", bytes(15)), # Invalid seed length (<16 bytes) - ("ms", "0fauxs", bytes(65)), # Invalid seed length (>64 bytes) - ("ms", "0fauxs", bytes(17)), # Invalid seed length (non-multiple of 4 bytes) - ("ms", "0fauxs", bytes(18)), # Invalid seed length (non-multiple of 4 bytes) - ("ms", "0fauxs", bytes(19)), # Invalid seed length (non-multiple of 4 bytes) + ("0FAUXS", bytes(16)), # Invalid mixed case + ("", b"\xd0" * 32), # Invalid empty header + ("0faux", b"\x80" * 32), # Invalid missing share idx + ("0", b"\x80" * 32), # Invalid missing identifier and share idx + ("0fauxxxs", bytes(16)), # Invalid identifier length + ("fauxxs", bytes(32)), # Invalid threshold + ("0fauxx", bytes(64)), # Invalid share idx + ("2fauxa", bytes(16)), # Invalid share idx: shares are not encoded from bytes + ("0fauxs", bytes(15)), # Invalid seed length (<16 bytes) + ("0fauxs", bytes(65)), # Invalid seed length (>64 bytes) + ("0fauxs", bytes(17)), # Invalid seed length (non-multiple of 4 bytes) + ("0fauxs", bytes(18)), # Invalid seed length (non-multiple of 4 bytes) + ("0fauxs", bytes(19)), # Invalid seed length (non-multiple of 4 bytes) ] diff --git a/tests/test_bip93.py b/tests/test_bip93.py index 219d8b67..d2e5ff4a 100644 --- a/tests/test_bip93.py +++ b/tests/test_bip93.py @@ -1,7 +1,5 @@ # tests/test_bip93.py """Tests for BIP-93 codex32 implementation.""" -import inspect - import pytest from data.bip93_vectors import ( VECTOR_1, @@ -31,8 +29,6 @@ ) from codex32.bip93 import ( Codex32String, - IdNotLength4, - InvalidSeedLength, MismatchedHrp, MismatchedLength, codex32_decode, @@ -64,16 +60,7 @@ def test_parts(): assert s.ident == VECTOR_1["identifier"] assert s.payload == VECTOR_1["payload"] assert s.checksum == VECTOR_1["checksum"] - assert s.data.hex() == VECTOR_1["secret_hex"] - - -def test_from_seed_requires_explicit_identifier(): - """Seed encoding must not infer an identifier or import BIP32.""" - seed = bytes.fromhex(VECTOR_3["secret_hex"]) - prefix = inspect.signature(Codex32String.from_seed).parameters["prefix"] - assert prefix.default is inspect.Parameter.empty - with pytest.raises(IdNotLength4, match="four-character identifier"): - Codex32String.from_seed(seed, "ms10") + assert s.data().hex() == VECTOR_1["secret_hex"] def test_derive_and_recover(): @@ -81,12 +68,13 @@ def test_derive_and_recover(): a = Codex32String(VECTOR_2["share_A"]) c = Codex32String(VECTOR_2["share_C"]) # interpolation target is 'D' (uppercase as inputs are uppercase) - assert a.data is None and c.pad_val is None # shares have no seed bytes + with pytest.raises(InvalidShareIndex): # shares have no seed bytes + a.data() d = Codex32String.interpolate_at([a, c], "D") assert str(d) == VECTOR_2["derived_D"] s = Codex32String.interpolate_at([a, c], "S") assert str(s) == VECTOR_2["secret_S"] - assert s.data.hex() == VECTOR_2["secret_hex"] + assert s.data().hex() == VECTOR_2["secret_hex"] def test_from_seed_and_interpolate_3_of_5(): @@ -114,7 +102,7 @@ def test_from_seed_and_alternates(): # confirm all 16 encodings decode to same master data s = Codex32String.from_seed(seed, "ms10leet", pad_val) assert str(s) == VECTOR_4[f"secret_s_alternate_{pad_val}"] - assert s.data.hex() == VECTOR_4["secret_hex"] + assert s.data().hex() == VECTOR_4["secret_hex"] def test_long_string(): @@ -129,16 +117,16 @@ def test_long_string(): ) assert s.checksum == VECTOR_5["checksum"] assert str(s) == VECTOR_5["secret_s"] - assert s.data.hex() == VECTOR_5["secret_hex"] + assert s.data().hex() == VECTOR_5["secret_hex"] long_str = VECTOR_5["secret_s"] long_seed = Codex32String(long_str) - assert long_seed.data.hex() == VECTOR_5["secret_hex"] + assert long_seed.data().hex() == VECTOR_5["secret_hex"] def test_master_seed_sizes(): """Test Vectors 6, 7 and 8: 160-, 192- and 224-bit master seeds.""" for seed_hex, secret in VECTORS_6_7_8: - assert Codex32String(secret).data.hex() == seed_hex + assert Codex32String(secret).data().hex() == seed_hex def test_alternate_hrp(): @@ -199,7 +187,6 @@ def test_invalid_master_seed(): MismatchedHrp, MismatchedLength, InvalidChecksum, - InvalidSeedLength, InvalidLength, InvalidChar, InvalidThreshold, @@ -207,17 +194,16 @@ def test_invalid_master_seed(): IncompleteGroup, ) ): - decode("ms", test) + decode(test) def test_invalid_master_seed_enc(): """Test whether master seed encoding fails on invalid input.""" - for hrp, header, data in INVALID_MASTER_SEED_ENC: + for header, data in INVALID_MASTER_SEED_ENC: with pytest.raises( ( MissingHrp, MismatchedLength, - InvalidSeedLength, InvalidChar, InvalidCase, InvalidDataValue, @@ -227,7 +213,7 @@ def test_invalid_master_seed_enc(): AssertionError, ) ): - encode(hrp, header, data) + encode(header, data) def test_bad_checksums(): @@ -241,7 +227,7 @@ def test_wrong_checksums_or_length(): """Test strings with wrong checksums or lengths.""" for chk in WRONG_CHECKSUMS: with pytest.raises( - (InvalidLength, InvalidSeedLength, IncompleteGroup, InvalidChecksum) + (InvalidLength, IncompleteGroup, InvalidChecksum) ): Codex32String(chk) @@ -249,7 +235,7 @@ def test_wrong_checksums_or_length(): def test_invalid_length(): """Test strings with invalid lengths.""" for chk in INVALID_LENGTHS: - with pytest.raises((InvalidLength, InvalidSeedLength, IncompleteGroup)): + with pytest.raises((InvalidLength, IncompleteGroup)): Codex32String(chk) From e34a1432a35cd8340920b0c109c8ae536edbd075 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 4 Nov 2026 08:02:15 +0000 Subject: [PATCH 4/6] Release 0.7.0 Publish the reference implementation merged upstream in BlockstreamResearch/codex32#74 (apoelstra utACK 2ecd191). src/ and tests/ now match reference/python-codex32 at 2ecd191; the README keeps the banner and Python 3.15 support. Claude-Session: https://claude.ai/code/session_01MDsbGqZmvVThoJrs4vzc4K --- README.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d3642bde..fa90443d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ from codex32 import Codex32String # Create from seed bytes s = Codex32String.from_seed( bytes.fromhex('ffeeddccbbaa99887766554433221100'), - "ms13cashs", # prefix string with explicit identifier (HRP + '1' + header) + "ms13cashs", # prefix string, (HRP + '1' + header) 0 # padding value (default "CRC", otherwise integer) ) print(s.s) # codex32 string diff --git a/pyproject.toml b/pyproject.toml index 6ad33728..901a1b84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codex32" -version = "0.6.2" +version = "0.7.0" authors = [ { name = "Ben Westgate", email = "benwestgate@protonmail.com" }, ] From df6b257524b8d29970ead427c8252d6c1048ff3f Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 4 Nov 2026 09:35:03 -0600 Subject: [PATCH 5/6] Make Codex32String attributes immutable --- src/codex32/bip93.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 230d4e57..51cab2fd 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -189,6 +189,16 @@ def encode(header: str, seed: bytes, pad_val: int | str = "CRC"): class Codex32String: """Class representing a codex32 string.""" + __slots__ = ("s", "is_upper", "hrp", "payload", "k", "ident", "share_idx") + + def __setattr__(self, name, value): + if hasattr(self, name): + raise AttributeError(f"{name} is read-only") + super().__setattr__(name, value) + + def __delattr__(self, name): + raise AttributeError(f"{name} is read-only") + def __init__(self, s: str) -> None: """Initialize Codex32String from a codex32 string.""" self.s, self.is_upper = s, s.isupper() From 96c40b989569fe5d5ea11e306e3b5706382ec580 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 4 Nov 2026 09:35:12 -0600 Subject: [PATCH 6/6] Test Codex32String reassignment, deletion, and slots --- tests/test_bip93.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_bip93.py b/tests/test_bip93.py index d2e5ff4a..dc278366 100644 --- a/tests/test_bip93.py +++ b/tests/test_bip93.py @@ -50,6 +50,28 @@ from codex32.checksums import CODEX32, CODEX32_LONG +@pytest.mark.parametrize( + "name", ("s", "is_upper", "hrp", "payload", "k", "ident", "share_idx") +) +def test_codex32_string_read_only_attributes(name): + """Stored metadata cannot be reassigned or deleted after parsing.""" + share = Codex32String(VECTOR_1["secret_s"]) + original = str(share) + with pytest.raises(AttributeError): + setattr(share, name, "changed") + with pytest.raises(AttributeError): + delattr(share, name) + assert str(share) == original + + +def test_codex32_string_slots(): + """Instances cannot acquire arbitrary attributes or an instance dict.""" + share = Codex32String(VECTOR_1["secret_s"]) + with pytest.raises(AttributeError): + setattr(share, "unexpected", "value") + assert not hasattr(share, "__dict__") + + def test_parts(): """Test Vector 1: parse a codex32 string into parts""" s = Codex32String(VECTOR_1["secret_s"])