diff --git a/reference/python-codex32/LICENSE b/reference/python-codex32/LICENSE new file mode 100644 index 0000000..c707f7b --- /dev/null +++ b/reference/python-codex32/LICENSE @@ -0,0 +1,21 @@ +MIT License + +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. diff --git a/reference/python-codex32/LICENSES/BSD-3-Clause-Curr-Sneed.txt b/reference/python-codex32/LICENSES/BSD-3-Clause-Curr-Sneed.txt new file mode 100644 index 0000000..7cf010a --- /dev/null +++ b/reference/python-codex32/LICENSES/BSD-3-Clause-Curr-Sneed.txt @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright 2023 Leon Olsson Curr and Pearlwort Sneed + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/reference/python-codex32/README.md b/reference/python-codex32/README.md new file mode 100644 index 0000000..7b3d2e4 --- /dev/null +++ b/reference/python-codex32/README.md @@ -0,0 +1,83 @@ +# python-codex32 + +Reference implementation of BIP-0093 (codex32): checksummed, SSSS-aware BIP32 seed strings. + +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 never encoded from bytes: generate them from random characters or by interpolation. + +## Features +- Encode/decode codex32 data via `from_string` and `from_unchecksummed_string`. +- 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. + +## Security +Caution: This is reference code. Verify carefully before using with real funds. + +## Installation +**Compatibility:** Python 3.10–3.14 + +**Recommended:** use a virtual environment +### Linux / macOS +```bash +python -m venv .venv +source .venv/bin/activate +pip install codex32 +``` +### Windows +```powershell +python -m venv .venv +.venv\Scripts\Activate.ps1 +pip install codex32 +``` + + +## Quick usage +```python +from codex32 import Codex32String + +# Create from seed bytes +s = Codex32String.from_seed( + bytes.fromhex('ffeeddccbbaa99887766554433221100'), + "ms13cashs", # prefix string, (HRP + '1' + header) + 0 # padding value (default "CRC", otherwise integer) +) +print(s.s) # codex32 string + +# Parse an existing codex32 string and inspect parts +a = Codex32String("ms13casha320zyxwvutsrqpnmlkjhgfedca2a8d0zehn8a0t") +print(a.hrp) # human-readable part +print(a.k) # threshold parameter +print(a.ident) # 4 character identifier +print(a.share_idx) # share index character +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()) # master seed bytes as hex ("ms" secrets only) + + + +# Create from unchecksummed string (will append checksum) +c = Codex32String.from_unchecksummed_string("ms13cashcacdefghjklmnpqrstuvwxyz023") +print(str(c)) # equivalent to print(c.s) + +# Interpolate shares to recover or derive target share index +shares = [s, a, c] +derived_share_d = Codex32String.interpolate_at(shares, target='d') +print(derived_share_d.s) + +# Create Codex32String object from existing codex32 string and validate any HRP +e = Codex32String.from_string("cl", "cl10lueasd35kw6r5de5kueedxyesqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqanvrktzhlhusz") +print(e.ident) +print(e.s) +``` + +## Tests +``` bash +pip install -e .[dev] +pytest +``` diff --git a/reference/python-codex32/pyproject.toml b/reference/python-codex32/pyproject.toml new file mode 100644 index 0000000..49e5421 --- /dev/null +++ b/reference/python-codex32/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["setuptools>=80", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "codex32" +version = "0.6.0" +authors = [ + { name = "Ben Westgate", email = "benwestgate@protonmail.com" }, +] +description = "Python reference implementation for codex32 (BIP93) and codex32-encoded master seeds." +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE*"] +maintainers = [ + { name = "Ben Westgate", email = "benwestgate@protonmail.com" }, +] +keywords = ["bitcoin", "HD wallet", "BIP32", "BIP93", "BIP173", "BIP350", "codex32", "cryptography", "secret sharing", "secrets", "entropy", "bech32"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", + "Topic :: Security :: Cryptography", + "Topic :: Software Development :: Libraries", +] + +[project.optional-dependencies] +dev = ["pytest"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[project.urls] +Homepage = "https://github.com/benwestgate/python-codex32" +Repository = "https://github.com/benwestgate/python-codex32" +Issues = "https://github.com/benwestgate/python-codex32/issues" +Changelog = "https://github.com/benwestgate/python-codex32/tree/master/CHANGELOG.md" diff --git a/reference/python-codex32/src/codex32/__init__.py b/reference/python-codex32/src/codex32/__init__.py new file mode 100644 index 0000000..b0fe0f5 --- /dev/null +++ b/reference/python-codex32/src/codex32/__init__.py @@ -0,0 +1,27 @@ +# 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. + +"""codex32 package: bech32/codex32 helpers and encoders/decoders.""" + +from .bip93 import Codex32String, encode, decode +from .errors import CodexError + + +__all__ = ["CodexError", "Codex32String", "encode", "decode"] diff --git a/reference/python-codex32/src/codex32/bech32.py b/reference/python-codex32/src/codex32/bech32.py new file mode 100644 index 0000000..a401d04 --- /dev/null +++ b/reference/python-codex32/src/codex32/bech32.py @@ -0,0 +1,161 @@ +# Portions of this file are derived from work by: +# Copyright (c) 2017, 2020 Pieter Wuille +# +# Additional code and modifications: +# 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. + +"""Internal bech32 and u5 helpers for Bech32/codex32 encoding and decoding.""" + +from codex32.errors import CodexError +from codex32.checksums import Checksum, crc_pad + + +# pylint: disable=missing-class-docstring +class InvalidDataValue(CodexError): ... + + +class IncompleteGroup(CodexError): ... + + +class InvalidLength(CodexError): ... + + +class InvalidChar(CodexError): ... + + +class InvalidCase(CodexError): ... + + +class InvalidChecksum(CodexError): ... + + +class InvalidPadding(CodexError): ... + + +class MissingHrp(CodexError): ... + + +class SeparatorNotFound(CodexError): ... + + +class MissingChecksum(CodexError): ... + + +class MissingEncoding(CodexError): ... + + +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def bech32_hrp_expand(hrp: str) -> list[int]: + """Expand the HRP into values for checksum computation.""" + return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + + +def u5_to_chars(data: list[int]) -> str: + """Map list of 5-bit integers (0-31) -> Bech32 data-part string.""" + for i, x in enumerate(data): + if not 0 <= x < 32: + raise InvalidDataValue(f"from 0 to 31 index={i} value={x}") + return "".join(CHARSET[d] for d in data) + + +def u5_encode(hrp: str, data: list[int], spec: Checksum) -> str: + """Compute a Bech32 string given HRP and data values.""" + combined = data + spec.create(bech32_hrp_expand(hrp) + data) + return hrp + "1" + u5_to_chars(combined) + + +def chars_to_u5(bech: str) -> list[int]: + """Map Bech32 data-part string -> list of 5-bit integers (0-31).""" + for i, ch in enumerate(bech): + if ch not in CHARSET: + raise InvalidChar(f"'{ch!r}' at pos={i} in data part") + return [CHARSET.find(x) for x in bech] + + +def u5_parse(bech: str) -> tuple[str, list[int]]: + """Parse a Bech32/Codex32 string, and return HRP and 5-bit data.""" + for i, ch in enumerate(bech): + if ord(ch) < 33 or ord(ch) > 126: + raise InvalidChar(f"non-printable U+{ord(ch):04X} at pos={i}") + if bech.upper() != bech and bech.lower() != bech: + 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") + 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 + + +def u5_decode(bech: str, encodings: list[Checksum]) -> tuple[str, list[int], Checksum]: + """Validate a Bech32/Codex32 string, and determine HRP and data.""" + hrp, data = u5_parse(bech) + e = MissingEncoding("no encoding or encodings were passed") + for spec in encodings: + 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"{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 + + +def convertbits( + data: list[int] | bytes, + frombits: int, + tobits: int, + pad: bool = True, + pad_val: int | str = 0, +) -> list[int]: + """General power-of-2 base conversion.""" + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + raise InvalidDataValue(f"{value} is not in 0 to {(1 << frombits) - 1}") + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if not pad and bits >= frombits: + raise IncompleteGroup(f" {bits} bits remaining, must be {frombits - 1} or less") + pad_len = (tobits - bits) if pad and bits else bits + pv = crc_pad(convertbits(data, frombits, 1)) if pad_val == "CRC" else pad_val + if isinstance(pad_val, int) and not 0 <= pad_val < (1 << pad_len): + raise InvalidDataValue(f"padding int {pad_val} must be 0 to {(1< +# License: BSD-3-Clause +# Derived work: BECH32_INV, bech32_mul, bech32_lagrange, codex32_interpolate +# +# Modifications and additional code: +# Copyright (c) 2026 Ben Westgate , MIT License +# +# 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 codex32/Long codex32 and codex32-encoded master seeds.""" + + +from codex32.bech32 import ( + CHARSET, + InvalidLength, + chars_to_u5, + convertbits, + u5_to_chars, + u5_decode, + u5_encode, + u5_parse, +) +from codex32.checksums import CODEX32, CODEX32_LONG +from codex32.errors import CodexError + +BECH32_INV = [ + 0, + 1, + 20, + 24, + 10, + 8, + 12, + 29, + 5, + 11, + 4, + 9, + 6, + 28, + 26, + 31, + 22, + 18, + 17, + 23, + 2, + 25, + 16, + 19, + 3, + 21, + 14, + 30, + 13, + 7, + 27, + 15, +] + + +# pylint: disable=missing-class-docstring + + +class InvalidThreshold(CodexError): ... + + +class InvalidShareIndex(CodexError): ... + + +class MismatchedLength(CodexError): ... + + +class MismatchedHrp(CodexError): ... + + +class MismatchedThreshold(CodexError): ... + + +class MismatchedId(CodexError): ... + + +class RepeatedIndex(CodexError): ... + + +class ThresholdNotPassed(CodexError): ... + + +def bech32_mul(a, b): + """Multiply two Bech32 values.""" + res = 0 + for i in range(5): + res ^= a if ((b >> i) & 1) else 0 + a *= 2 + a ^= 41 if (32 <= a) else 0 + return res + + +def bech32_lagrange(pts, x): + """Compute Bech32 lagrange.""" + n = 1 + c = [] + for i in pts: + n = bech32_mul(n, i ^ x) + m = 1 + for j in pts: + m = bech32_mul(m, (x if i == j else i) ^ j) + c.append(m) + return [bech32_mul(n, BECH32_INV[i]) for i in c] + + +def codex32_decode(codex): + """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 codex32_decode(s) + + +def codex32_interpolate(strings, x): + """Interpolate a set of codex32 data values given target index.""" + w = bech32_lagrange([s[5] for s in strings], x) + res = [] + for i in range(len(strings[0])): + n = 0 + for j, val in enumerate(strings): + n ^= bech32_mul(w[j], val[i]) + res.append(n) + return res + + +def codex32_encode(hrp: str, data): + """Compute a codex32 string given HRP and data values.""" + spec = CODEX32_LONG if 2 * len(hrp) + 1 + len(data) + 13 > 93 else CODEX32 + return u5_encode(hrp, data, spec) + + +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) + pad = data[-1] % (1 << ((len(data[6:]) * 5) % 8)) + return header, bytes(decoded), pad if pad_val == "any" else pad_val + + +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("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 + + +class Codex32String: + """Class representing a codex32 string.""" + + def __init__(self, s: str) -> None: + """Initialize Codex32String from a codex32 string.""" + self.s, self.is_upper = s, s.isupper() + 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] + + 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 + + def __len__(self) -> int: + return len(self.s) + + @property + def checksum(self) -> str: + """Return the checksum part of the codex32 string.""" + return self.s[-codex32_decode(self.s)[2].cs_len :] + + @classmethod + def from_unchecksummed_string(cls, s: str) -> "Codex32String": + """Create Codex32String from unchecksummed string.""" + ret = codex32_encode(*u5_parse(s)) + return cls(ret.upper() if s.isupper() else ret) + + @classmethod + def from_string(cls, hrp: str, s: str) -> "Codex32String": + """Create Codex32String from a given codex32 string and HRP.""" + if (hrpgot := u5_parse(s)[0]) != hrp: + raise MismatchedHrp(f"{hrpgot} != {hrp}") + return cls(s) + + @classmethod + def interpolate_at( + cls, shares: list["Codex32String"], target: str = "s" + ) -> "Codex32String": + """Interpolate a set of Codex32String objects to a specific target index.""" + if not all(isinstance(share, Codex32String) for share in shares): + raise TypeError("All shares must be Codex32String instances") + if (threshold := int(shares[0].k) if shares else 1) > len(shares): + raise ThresholdNotPassed(f"threshold={threshold}, n_shares={len(shares)}") + for share in shares: + if len(shares[0]) != len(share): + raise MismatchedLength(f"{len(shares[0])}, {len(share)}") + if shares[0].hrp != share.hrp: + raise MismatchedHrp(f"{shares[0].hrp}, {share.hrp}") + if shares[0].k != share.k: + raise MismatchedThreshold(f"{shares[0].k}, {share.k}") + if shares[0].ident != share.ident: + raise MismatchedId(f"{shares[0].ident}, {share.ident}") + if [share.share_idx for share in shares].count(share.share_idx) > 1: + raise RepeatedIndex(share.share_idx) + if ret := [share for share in shares if share.share_idx == target.lower()]: + return ret.pop() + u5_shares = [codex32_decode(share.s)[1] for share in shares] + data = codex32_interpolate(u5_shares, CHARSET.find(target.lower())) + ret = codex32_encode(shares[0].hrp, data) + return cls(ret.upper() if all(s.s.upper() == s.s for s in shares) else ret) + + @classmethod + def from_seed( + cls, data: bytes, prefix: str, pad_val: int | str = "CRC" + ) -> "Codex32String": + """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(header, data, pad_val)) diff --git a/reference/python-codex32/src/codex32/checksums.py b/reference/python-codex32/src/codex32/checksums.py new file mode 100644 index 0000000..499b2b3 --- /dev/null +++ b/reference/python-codex32/src/codex32/checksums.py @@ -0,0 +1,92 @@ +# codex32 constants in this file are derived from work by: +# Author: Leon Olsson Curr and Pearlwort Sneed +# License: BSD-3-Clause +# +# Additional code: +# Copyright (c) 2026 Ben Westgate , MIT License +# +# 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. + +"""Checksum specs and utilities for codex32 and CRC variants.""" + + +# Generators are the reduction polynomials for polymod computations +CODEX32_GEN = [ + 0x19DC500CE73FDE210, + 0x1BFAE00DEF77FE529, + 0x1FBD920FFFE7BEE52, + 0x1739640BDEEE3FDAD, + 0x07729A039CFC75F5A, +] +CODEX32_LONG_GEN = [ + 0x3D59D273535EA62D897, + 0x7A9BECB6361C6C51507, + 0x543F9B7E6C38D8A2A0E, + 0x0C577EAECCF1990D13C, + 0x1887F74F8DC71B10651, +] +CODEX32_CONST = 0x10CE0795C2FD1E62A +CODEX32_LONG_CONST = 0x43381E570BF4798AB26 + + +class Checksum: + """Checksum spec (polynomial gens, length, constant, coverage, create and verify).""" + + def __init__(self, kind: str, specs, coverage): + self.kind = kind + self.gen, self.cs_len, self.const = specs + self.coverage = range(coverage[0], coverage[1] + 1) # valid lengths + self.shift = len(self.gen) * (self.cs_len - 1) + self.mask = (1 << self.shift) - 1 + + def polymod(self, values, residue=1): + """Internal function that computes the Bech32/Codex32/CRC checksums.""" + for value in values: + top = residue >> self.shift + residue = (residue & self.mask) << len(self.gen) ^ value + for i, g in enumerate(self.gen): + residue ^= g if ((top >> i) & 1) else 0 + return residue + + def verify(self, values): + """Verify a checksum given values.""" + return self.polymod(values) == self.const + + def create(self, values): + """Compute the checksum values given values.""" + polymod = self.polymod(values + [0] * self.cs_len) ^ self.const + mask = (1 << (w := len(self.gen))) - 1 + cs_len = self.cs_len + return [(polymod >> (w * (cs_len - 1 - i))) & mask for i in range(cs_len)] + + +codex32_long_spec = (CODEX32_LONG_GEN, 15, CODEX32_LONG_CONST) # detects 8 errors +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)) +CRC4 = Checksum("CRC4", ([3], 4, 0), (0, 11)) + + +def crc_pad(bits: list[int]) -> int: + """Compute the CRC padding value given payload bits list.""" + crc = [None, CRC1, CRC2, CRC3, CRC4][k := ((-len(bits) % 5) or (len(bits) % 8))] + crc_bits = crc.create(bits[: len(bits) // 8 * 8]) if crc else [] + return sum(b << (k - 1 - i) for i, b in enumerate(crc_bits)) if k else 0 diff --git a/reference/python-codex32/src/codex32/errors.py b/reference/python-codex32/src/codex32/errors.py new file mode 100644 index 0000000..03c44b1 --- /dev/null +++ b/reference/python-codex32/src/codex32/errors.py @@ -0,0 +1,12 @@ +"""codex32 / Bech32 encoding and usage errors.""" + + +class CodexError(Exception): + """Base class for all codex32 / Bech32 errors.""" + + def __init__(self, extra: str | None = None) -> None: + self.extra = extra + super().__init__(extra) + + def __str__(self) -> str: + return str(self.extra) if self.extra else "" diff --git a/reference/python-codex32/tests/data/bip93_vectors.py b/reference/python-codex32/tests/data/bip93_vectors.py new file mode 100644 index 0000000..0f914ee --- /dev/null +++ b/reference/python-codex32/tests/data/bip93_vectors.py @@ -0,0 +1,275 @@ +# tests/data/bip93_vectors.py +# pylint: disable=line-too-long +"""BIP-93 / codex32 canonical test vectors.""" +VECTOR_1 = { + "secret_s": "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw", + "secret_hex": "318c6318c6318c6318c6318c6318c631", + "hrp": "ms", + "k": "0", + "identifier": "test", + "share_index": "s", + "payload": "xxxxxxxxxxxxxxxxxxxxxxxxxx", + "checksum": "4nzvca9cmczlw", +} + +VECTOR_2 = { + "share_A": "MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM", + "share_C": "MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN", + "derived_D": "MS12NAMEDLL4F8JLH4E5VDVULDLFXU2JHDNLSM97XVENRXEG", + "secret_S": "MS12NAMES6XQGUZTTXKEQNJSJZV4JV3NZ5K3KWGSPHUH6EVW", + "secret_hex": "d1808e096b35b209ca12132b264662a5", +} + +VECTOR_3 = { + "secret_hex": "ffeeddccbbaa99887766554433221100", + "secret_s": "ms13cashsllhdmn9m42vcsamx24zrxgs3qqjzqud4m0d6nln", + "share_a": "ms13casha320zyxwvutsrqpnmlkjhgfedca2a8d0zehn8a0t", + "share_c": "ms13cashcacdefghjklmnpqrstuvwxyz023949xq35my48dr", + "derived_d": "ms13cashd0wsedstcdcts64cd7wvy4m90lm28w4ffupqs7rm", + "derived_e": "ms13casheekgpemxzshcrmqhaydlp6yhms3ws7320xyxsar9", + "derived_f": "ms13cashf8jh6sdrkpyrsp5ut94pj8ktehhw2hfvyrj48704", + "secret_s_alternate_0": "ms13cashsllhdmn9m42vcsamx24zrxgs3qqjzqud4m0d6nln", + "secret_s_alternate_1": "ms13cashsllhdmn9m42vcsamx24zrxgs3qpte35dvzkjpt0r", + "secret_s_alternate_2": "ms13cashsllhdmn9m42vcsamx24zrxgs3qzfatvdwq5692k6", + "secret_s_alternate_3": "ms13cashsllhdmn9m42vcsamx24zrxgs3qrsx6ydhed97jx2", +} + +VECTOR_4 = { + "secret_hex": "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100", + "secret_s": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycma", + "secret_s_alternate_0": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqtum9pgv99ycma", + "secret_s_alternate_1": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqpj82dp34u6lqtd", + "secret_s_alternate_2": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqzsrs4pnh7jmpj5", + "secret_s_alternate_3": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqrfcpap2w8dqezy", + "secret_s_alternate_4": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqy5tdvphn6znrf0", + "secret_s_alternate_5": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyq9dsuypw2ragmel", + "secret_s_alternate_6": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqx05xupvgp4v6qx", + "secret_s_alternate_7": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyq8k0h5p43c2hzsk", + "secret_s_alternate_8": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqgum7hplmjtr8ks", + "secret_s_alternate_9": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqf9q0lpxzt5clxq", + "secret_s_alternate_10": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyq28y48pyqfuu7le", + "secret_s_alternate_11": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqt7ly0paesr8x0f", + "secret_s_alternate_12": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqvrvg7pqydv5uyz", + "secret_s_alternate_13": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqd6hekpea5n0y5j", + "secret_s_alternate_14": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqwcnrwpmlkmt9dt", + "secret_s_alternate_15": "ms10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyq0pgjxpzx0ysaam", +} + +VECTOR_5 = { + "hrp": "MS", + "k": "0", + "identifier": "0C8V", + "share_idx": "S", + "payload": "M32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06F", + "checksum": "HPV80UNDVARHRAK", + "secret_s": "MS100C8VSM32ZXFGUHPCHTLUPZRY9X8GF2TVDW0S3JN54KHCE6MUA7LQPZYGSFJD6AN074RXVCEMLH8WU3TK925ACDEFGHJKLMNPQRSTUVWXY06FHPV80UNDVARHRAK", + "secret_hex": "dc5423251cb87175ff8110c8531d0952d8d73e1194e95b5f19d6f9df7c01111104c9baecdfea8cccc677fb9ddc8aec5553b86e528bcadfdcc201c17c638c47e9", +} + +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", + "abcdef13qpzry9x8gf2tvdw0s3jn54khce6mua7lclc606q3t75r4", + "split12checkupstagehandshakeupstreamerranterredcaperred75pe8uz2kh9ey", + "?13zyfclf624rkvjcl35t", +] + +VALID_CODEX32_LONG = [ + "A12UEL5LQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQV3RR8ZLCK96GTC3", + "a12uel5lqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqv3rr8zlck96gtc3", + "abcdef12l7aum6echk45nj3s0wdvt2fg8x9yrzpql7aum6echk45nj3s0wdvt2fg8x9yrzpql7aum6echk45nj3s0wdvt2fg8x9yrzpqp9evrmhc52umqew", + "split13checkupstagehandshakeupstreamerranterredcaperredscatteredsusurrantplunderedqsp5ws8r2klm66l", + "?17v59aaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2uwygewx4t4gps0", +] +INVALID_CODEX32 = { + " 12fauxxpgjxu9gyqhql4", # HRP character out of range + "\x7f" + "12fauxxk7kd7xqlns9mj", # HRP character out of range + "\x80" + "12fauxxgqp5ecwf5kzg3", # HRP character out of range + "x12fauxbhf2k7v7ay7ua5", # Invalid data character + "li12fauxxz4pdg55uwav3", # Too short checksum + "de12fauxxrmt7mj886swl" + "\xff", # Invalid character in checksum + "A12FAUXXMRQDLRATCD0WJ", # Checksum calculated with uppercase form of HRP +} + +INVALID_CODEX32_LONG = { + " 12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx8qt67zg4n9sqylv", # HRP character out of range + "\x7f" + + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx836hdd09mhkhkhx", # HRP character out of range + "\x80" + + "12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxlhf5ywnkmk4r3tc", # HRP character out of range + "y12bfauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxt3y5fewy4gnw2hs", # Invalid data character + "lt12ifauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxjxd0ehq868vm3zl", # Invalid data character + "in12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxvgegljrsvs5w9q", # Too short checksum + "mm12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxbz9tqm7y53swfaw", # Invalid character in checksum + "au12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxoqz9gl44za2owxc", # Invalid character in checksum + "M12FAUXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX4DZ47P062DVJUNM", # Checksum calculated with uppercase form of HRP +} + +INVALID_MASTER_SEED = [ + # Invalid HRP + "cl10fauxs0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq3yaqk7cywvn0h", + # Invalid checksum algorithm (long codex32 instead of codex32) + "ms10fauxs0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq7rvjegpr4vhajgq", + # Invalid checksum algorithm (bech32m instead of codex32) + "ms10fauxs0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqu8ld7l", + # Invalid checksum algorithm (bech32 instead of codex32) + "MS10FAUXS0XLXVLHEMJA6C4DQV22UAPCTQUPFHLXM9H8Z3K2E72Q4K9HCZ7VQFM0PMA", + # Invalid checksum algorithm (bech32m instead of codex32) + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh", + # Invalid checksum algorithm (bech32 instead of long codex32) + "ms10fauxsxlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq7dmzyl", + # Invalid checksum algorithm (codex32 instead of long codex32) + "ms10fauxsxlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq3vy27qhysq096", + # Invalid character in checksum + "ms10fauxs0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqg4khy5dvhao86", + # Invalid seed length (15 byte) + "MS10FAUXS508D6QEJXTDG4Y5R3ZARVARYEQ3F4VK4PNLXD", + # Invalid seed length (65 bytes) + "ms10fauxsxlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqqqetj40aljmajm22", + # Mixed case + "ms10fauxs0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqg4khy5dvhA686", + # More than 4 padding bits + "ms10fauxs508d6qejxtdg4y5r3zarvary234567890z2jnc3fj9mqt9", + # Empty data section + "ms1pg7d74n5v8xrz", + # Empty payload section + "ms12fauxxcel69nm8tntnn", +] + +INVALID_MASTER_SEED_ENC = [ + ("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) +] + + +BAD_CHECKSUMS = [ + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxve740yyge2ghq", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxve740yyge2ghp", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxlk3yepcstwr", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxx6pgnv7jnpcsp", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxx0cpvr7n4geq", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxm5252y7d3lr", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxrd9sukzl05ej", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxc55srw5jrm0", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxgc7rwhtudwc", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxx4gy22afwghvs", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxme084q0vpht7pe0", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxme084q0vpht7pew", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxqyadsp3nywm8a", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxzvg7ar4hgaejk", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxcznau0advgxqe", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxch3jrc6j5040j", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx52gxl6ppv40mcv", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx7g4g2nhhle8fk", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx63m45uj8ss4x8", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxy4r708q7kg65x", +] + +WRONG_CHECKSUMS = [ + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxurfvwmdcmymdufv", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxcsyppjkd8lz4hx3", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxu6hwvl5p0l9xf3c", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxwqey9rfs6smenxa", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxv70wkzrjr4ntqet", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx3hmlrmpa4zl0v", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxrfggf88znkaup", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxpt7l4aycv9qzj", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxus27z9xtyxyw3", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxcwm4re8fs78vn", +] + +INVALID_LENGTHS = [ + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxw0a4c70rfefn4", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxk4pavy5n46nea", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxx9lrwar5zwng4w", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxr335l5tv88js3", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxvu7q9nz8p7dj68v", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxpq6k542scdxndq3", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxkmfw6jm270mz6ej", + "ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxzhddxw99w7xws", + "ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxx42cux6um92rz", + "ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxarja5kqukdhy9", + "ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxky0ua3ha84qk8", + "ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx9eheesxadh2n2n9", + "ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx9llwmgesfulcj2z", + "ms12fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx02ev7caq6n9fgkf", +] + +INVALID_SHARE_INDEX = [ + "ms10fauxxxxxxxxxxxxxxxxxxxxxxxxxxxx0z26tfn0ulw3p", +] + +INVALID_THRESHOLD = [ + "ms1fauxxxxxxxxxxxxxxxxxxxxxxxxxxxxxda3kr3s0s2swg", +] + +INVALID_PREFIX_OR_SEPARATOR = [ + "0fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2", + "10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2", + "ms0fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2", + "m10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2", + "s10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2", + "0fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxhkd4f70m8lgws", + "10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxhkd4f70m8lgws", + "m10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxx8t28z74x8hs4l", + "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", + "MS10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2", + "ms10FAUXsxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2", + "ms10fauxSxxxxxxxxxxxxxxxxxxxxxxxxxxuqxkk05lyf3x2", + "ms10fauxsXXXXXXXXXXXXXXXXXXXXXXXXXXuqxkk05lyf3x2", + "ms10fauxsxxxxxxxxxxxxxxxxxxxxxxxxxxUQXKK05LYF3X2", +] diff --git a/reference/python-codex32/tests/test_bip93.py b/reference/python-codex32/tests/test_bip93.py new file mode 100644 index 0000000..d2e5ff4 --- /dev/null +++ b/reference/python-codex32/tests/test_bip93.py @@ -0,0 +1,267 @@ +# tests/test_bip93.py +"""Tests for BIP-93 codex32 implementation.""" +import pytest +from data.bip93_vectors import ( + VECTOR_1, + VECTOR_2, + VECTOR_3, + VECTOR_4, + VECTOR_5, + VECTORS_6_7_8, + VECTOR_9, + VECTOR_10, + VECTOR_11, + VALID_NOT_MASTER_SEED, + INVALID_HRP_VECTORS, + VALID_CODEX32, + VALID_CODEX32_LONG, + INVALID_CODEX32, + INVALID_CODEX32_LONG, + INVALID_MASTER_SEED, + INVALID_MASTER_SEED_ENC, + BAD_CHECKSUMS, + WRONG_CHECKSUMS, + INVALID_LENGTHS, + INVALID_SHARE_INDEX, + INVALID_THRESHOLD, + INVALID_PREFIX_OR_SEPARATOR, + BAD_CASES, +) +from codex32.bip93 import ( + Codex32String, + MismatchedHrp, + MismatchedLength, + codex32_decode, + encode, + decode, + InvalidThreshold, + InvalidShareIndex, +) +from codex32.bech32 import ( + InvalidCase, + InvalidChecksum, + InvalidDataValue, + IncompleteGroup, + InvalidLength, + InvalidChar, + MissingHrp, + SeparatorNotFound, +) +from codex32.checksums import CODEX32, CODEX32_LONG + + +def test_parts(): + """Test Vector 1: parse a codex32 string into parts""" + s = Codex32String(VECTOR_1["secret_s"]) + assert str(s) == VECTOR_1["secret_s"] + assert s.hrp == VECTOR_1["hrp"] + assert s.k == VECTOR_1["k"] + assert s.share_idx == VECTOR_1["share_index"] + 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_derive_and_recover(): + """Test Vector 2: derive new share and recover the secret""" + a = Codex32String(VECTOR_2["share_A"]) + c = Codex32String(VECTOR_2["share_C"]) + # interpolation target is 'D' (uppercase as inputs are uppercase) + 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"] + + +def test_from_seed_and_interpolate_3_of_5(): + """Test Vector 3: encode secret share from seed and split 3-of-5""" + seed = bytes.fromhex(VECTOR_3["secret_hex"]) + a = Codex32String(VECTOR_3["share_a"]) + c = Codex32String(VECTOR_3["share_c"]) + s = Codex32String.from_seed(seed, f"{a.hrp}1{a.k}{a.ident}", 0) + assert str(s) == VECTOR_3["secret_s"] + d = Codex32String.interpolate_at([s, a, c], "d") + e = Codex32String.interpolate_at([s, a, c], "e") + f = Codex32String.interpolate_at([s, a, c], "f") + assert str(d) == VECTOR_3["derived_d"] + assert str(e) == VECTOR_3["derived_e"] + assert str(f) == VECTOR_3["derived_f"] + for pad_val in range(0b11 + 1): + s = Codex32String.from_seed(seed, f"{a.hrp}1{a.k}{a.ident}", pad_val) + assert str(s) == VECTOR_3[f"secret_s_alternate_{pad_val}"] + + +def test_from_seed_and_alternates(): + """Test Vector 4: encode secret share from seed""" + seed = bytes.fromhex(VECTOR_4["secret_hex"]) + for pad_val in range(0b1111 + 1): + # 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"] + + +def test_long_string(): + """Test Vector 5: decode long codex32 secret and confirm secret bytes.""" + s = Codex32String.from_unchecksummed_string( + VECTOR_5["hrp"] + + "1" + + VECTOR_5["k"] + + VECTOR_5["identifier"] + + VECTOR_5["share_idx"] + + VECTOR_5["payload"] + ) + assert s.checksum == VECTOR_5["checksum"] + assert str(s) == VECTOR_5["secret_s"] + 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"] + + +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 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(): + """Test checksum creation and validation.""" + for spec in CODEX32, CODEX32_LONG: + tests = VALID_CODEX32 if spec == CODEX32 else VALID_CODEX32_LONG + for test in tests: + hrp, _, dspec = codex32_decode(test) + assert hrp is not None and dspec == spec + pos = test.rfind("1") + test = test[: pos + 1] + chr(ord(test[pos + 1]) ^ 1) + test[pos + 2 :] + with pytest.raises(InvalidChecksum): + codex32_decode(test) + + +def test_invalid_checksum(): + """Test validation of invalid checksums.""" + for spec in CODEX32, CODEX32_LONG: + tests = INVALID_CODEX32 if spec == CODEX32 else INVALID_CODEX32_LONG + for test in tests: + with pytest.raises( + (InvalidChecksum, InvalidLength, InvalidChar, AssertionError) + ): + _, _, dspec = codex32_decode(test) + assert dspec != spec + + +def test_invalid_master_seed(): + """Test whether invalid addresses fail to decode.""" + for test in INVALID_MASTER_SEED: + with pytest.raises( + ( + MismatchedHrp, + MismatchedLength, + InvalidChecksum, + InvalidLength, + InvalidChar, + InvalidThreshold, + InvalidCase, + IncompleteGroup, + ) + ): + decode(test) + + +def test_invalid_master_seed_enc(): + """Test whether master seed encoding fails on invalid input.""" + for header, data in INVALID_MASTER_SEED_ENC: + with pytest.raises( + ( + MissingHrp, + MismatchedLength, + InvalidChar, + InvalidCase, + InvalidDataValue, + InvalidThreshold, + InvalidShareIndex, + InvalidLength, + AssertionError, + ) + ): + encode(header, data) + + +def test_bad_checksums(): + """Test strings with bad checksums.""" + for chk in BAD_CHECKSUMS: + with pytest.raises((InvalidChecksum)): + Codex32String(chk) + + +def test_wrong_checksums_or_length(): + """Test strings with wrong checksums or lengths.""" + for chk in WRONG_CHECKSUMS: + with pytest.raises( + (InvalidLength, IncompleteGroup, InvalidChecksum) + ): + Codex32String(chk) + + +def test_invalid_length(): + """Test strings with invalid lengths.""" + for chk in INVALID_LENGTHS: + with pytest.raises((InvalidLength, IncompleteGroup)): + Codex32String(chk) + + +def test_invalid_index(): + """Test strings with invalid share indices.""" + for chk in INVALID_SHARE_INDEX: + with pytest.raises(InvalidShareIndex): + Codex32String(chk) + + +def test_invalid_threshold(): + """Test strings with invalid threshold characters.""" + for chk in INVALID_THRESHOLD: + with pytest.raises(InvalidThreshold): + Codex32String(chk) + + +def test_invalid_prefix_or_separator(): + """Test strings with invalid prefixes or separators.""" + for chk in INVALID_PREFIX_OR_SEPARATOR: + with pytest.raises((MismatchedHrp, SeparatorNotFound, MissingHrp)): + Codex32String.from_string("ms", chk) + + +def test_invalid_case_examples(): + """Test strings with invalid casing.""" + for chk in BAD_CASES: + with pytest.raises(InvalidCase): + Codex32String(chk)