Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 7 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 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.
Expand Down Expand Up @@ -47,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
Expand All @@ -62,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
print(a.pad_val) # padding value integer, (MSB first)
print(s.data().hex()) # master seed bytes as hex ("ms" secrets only)



Expand All @@ -80,15 +76,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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand Down
10 changes: 6 additions & 4 deletions src/codex32/bech32.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
106 changes: 53 additions & 53 deletions src/codex32/bip93.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from codex32.bech32 import (
CHARSET,
InvalidLength,
chars_to_u5,
convertbits,
u5_to_chars,
Expand All @@ -39,11 +40,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,
Expand Down Expand Up @@ -83,9 +79,6 @@
# pylint: disable=missing-class-docstring


class IdNotLength4(CodexError): ...


class InvalidThreshold(CodexError): ...


Expand All @@ -110,9 +103,6 @@ class RepeatedIndex(CodexError): ...
class ThresholdNotPassed(CodexError): ...


class InvalidSeedLength(CodexError): ...


def bech32_mul(a, b):
"""Multiply two Bech32 values."""
res = 0
Expand All @@ -137,8 +127,24 @@ def bech32_lagrange(pts, x):


def codex32_decode(codex):
"""Validate a codex32 string, and determine HRP and data."""
return u5_decode(codex, [CODEX32_LONG, CODEX32])
"""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):
Expand All @@ -155,58 +161,55 @@ 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)


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 (not 16 <= (msl := len(decoded)) <= 64 or msl % 4):
raise InvalidSeedLength(f"Master seeds must be in 16..20..64 bytes, got {msl}")
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."""
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


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.is_upper = s.isupper()
self.hrp = codex32_decode(s)[0]
header, self.data, self.pad_val = decode(self.hrp, s)
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]
Comment thread
BenWestgate marked this conversation as resolved.
Resolved

@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
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
Expand Down Expand Up @@ -263,12 +266,9 @@ 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 master seed secret given prefix (ms1, k, ident) and seed bytes."""
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))
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))
13 changes: 3 additions & 10 deletions src/codex32/checksums.py
Original file line number Diff line number Diff line change
@@ -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 <pearlwort@wpsoftware.net>
# License: BSD-3-Clause
Expand All @@ -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,
Expand All @@ -44,8 +41,6 @@
0x0C577EAECCF1990D13C,
0x1887F74F8DC71B10651,
]
BECH32_CONST = 1
BECH32M_CONST = 0x2BC830A3
CODEX32_CONST = 0x10CE0795C2FD1E62A
CODEX32_LONG_CONST = 0x43381E570BF4798AB26

Expand Down Expand Up @@ -82,10 +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))
BECH32 = Checksum("Bech32", (BECH32_GEN, 6, BECH32_CONST), (0, 83)) # detects 4 errors
BECH32M = Checksum("Bech32m", (BECH32_GEN, 6, BECH32M_CONST), (0, 83))
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))
Expand Down
85 changes: 0 additions & 85 deletions src/codex32/segwit_addr.py

This file was deleted.

Loading
Loading