281 lines
9.9 KiB
Python
281 lines
9.9 KiB
Python
"""base32c.py -- Crockford Base32 codec with optional check symbol. Stdlib only.
|
|
|
|
Spec (authoritative): https://www.crockford.com/base32.html
|
|
Douglas Crockford, 2019-03-04. Fetched 2026-10-23.
|
|
|
|
Quoted from the spec, verbatim:
|
|
|
|
"The Base 32 symbol set is a superset of the Base 16 symbol set. We chose a
|
|
symbol set of 10 digits and 22 letters. We exclude 4 of the 26 letters: I L O U."
|
|
I Can be confused with 1
|
|
L Can be confused with 1
|
|
O Can be confused with 0
|
|
U Accidental obscenity
|
|
|
|
"When decoding, upper and lower case letters are accepted, and i and l will be
|
|
treated as 1 and o will be treated as 0. When encoding, only upper case letters
|
|
are used."
|
|
|
|
"If the bit-length of the number to be encoded is not a multiple of 5 bits, then
|
|
zero-extend the number to make its bit-length a multiple of 5."
|
|
|
|
"Hyphens (-) can be inserted into symbol strings. This can partition a string into
|
|
manageable pieces, improving readability by helping to prevent confusion. Hyphens
|
|
are ignored during decoding."
|
|
|
|
Check symbols, values 32..36: '*' '~' '$' '=' 'U'
|
|
"The check symbol encodes the number modulo 37, 37 being the least prime number
|
|
greater than 32."
|
|
|
|
Design notes (why the check symbol has the detection powers the test suite claims):
|
|
|
|
Let the payload encode value V with symbols d_0..d_{k-1} (d_0 most significant),
|
|
and let the trailing check symbol be C = V mod 37.
|
|
|
|
* Single-symbol substitution. Replacing the symbol at distance j from the right
|
|
(weight 32**j) by a different canonical symbol changes the value by
|
|
d * 32**j with 1 <= |d| <= 31. Modulo 37: 32 == -5 is a unit (37 prime,
|
|
37 does not divide 32**j) and |d| < 37, so the change is never 0 mod 37.
|
|
=> every single-symbol substitution in a canonically-encoded string is caught.
|
|
|
|
* Adjacent transposition. Swapping neighbours of weights 32**(j+1), 32**j
|
|
changes the value by (a-b) * 32**j * 31, with a != b, |a-b| <= 31. Neither
|
|
32**j, 31, nor (a-b) is 0 mod 37. => every adjacent transposition of two
|
|
distinct symbols is caught.
|
|
|
|
* Known blind spot (value-based checks are blind to leading zeros). Deleting a
|
|
leading '0', or inserting '0' at the front, leaves the value -- and therefore
|
|
the check symbol -- unchanged. Such edits are NOT detected. The test suite
|
|
asserts this limitation rather than hiding it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
|
|
__all__ = [
|
|
"ALPHABET",
|
|
"CHECK_ALPHABET",
|
|
"Base32Error",
|
|
"InvalidSymbolError",
|
|
"CheckSymbolError",
|
|
"encode",
|
|
"encode_divmod",
|
|
"decode",
|
|
"decode_positional",
|
|
"check_symbol",
|
|
"encode_checked",
|
|
"decode_checked",
|
|
"verify",
|
|
]
|
|
|
|
# 32 canonical payload symbols: 0-9 then the 22 letters, I L O U excluded.
|
|
ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
assert len(ALPHABET) == 32
|
|
assert set("ILOU").isdisjoint(ALPHABET)
|
|
|
|
# 37-symbol check alphabet: the payload alphabet plus the 5 check-only symbols.
|
|
CHECK_ALPHABET = ALPHABET + "*~$=U"
|
|
assert len(CHECK_ALPHABET) == 37
|
|
|
|
_SYMBOL_VALUE = {c: i for i, c in enumerate(ALPHABET)}
|
|
# Decode-only aliases allowed by the spec: i,l -> 1 ; o -> 0 (case folded).
|
|
for _alias, _target in (("I", "1"), ("L", "1"), ("O", "0")):
|
|
_SYMBOL_VALUE[_alias] = _SYMBOL_VALUE[_target]
|
|
|
|
_CHECK_VALUE = {c: i for i, c in enumerate(CHECK_ALPHABET)}
|
|
for _alias, _target in (("I", "1"), ("L", "1"), ("O", "0"), ("U", "U")):
|
|
_CHECK_VALUE[_alias] = _CHECK_VALUE[_target]
|
|
|
|
|
|
class Base32Error(ValueError):
|
|
"""Base class for every rejection this module raises."""
|
|
|
|
|
|
class InvalidSymbolError(Base32Error):
|
|
"""A character is not a legal Crockford Base32 symbol in this position."""
|
|
|
|
|
|
class CheckSymbolError(Base32Error):
|
|
"""The trailing check symbol does not match the payload's value mod 37."""
|
|
|
|
|
|
def _symbol_value(ch: str) -> int:
|
|
"""Value 0..31 of a payload symbol. 'U' is check-only and is rejected here."""
|
|
c = ch.upper()
|
|
v = _SYMBOL_VALUE.get(c)
|
|
if v is None:
|
|
raise InvalidSymbolError(
|
|
f"{ch!r} is not a payload symbol (I L O U are excluded; U is check-only)"
|
|
)
|
|
return v
|
|
|
|
|
|
def _check_value(ch: str) -> int:
|
|
"""Value 0..36 of a check symbol (accepts '*~$=U' and the i/l/o aliases)."""
|
|
v = _CHECK_VALUE.get(ch.upper())
|
|
if v is None:
|
|
raise InvalidSymbolError(f"{ch!r} is not a check symbol")
|
|
return v
|
|
|
|
|
|
def encode(value: int, min_length: int = 0, hyphenate: int = 0) -> str:
|
|
"""Encode a non-negative int. Zero-extends to a multiple of 5 bits.
|
|
|
|
min_length: left-pad with '0' to at least this many symbols.
|
|
hyphenate: if > 0, group the payload in runs of that many symbols joined by '-'.
|
|
"""
|
|
if not isinstance(value, int) or isinstance(value, bool):
|
|
raise TypeError("value must be an int")
|
|
if value < 0:
|
|
raise ValueError("Crockford Base32 encodes non-negative integers only")
|
|
if min_length < 0:
|
|
raise ValueError("min_length must be >= 0")
|
|
if hyphenate < 0:
|
|
raise ValueError("hyphenate must be >= 0")
|
|
|
|
bits = bin(value)[2:]
|
|
bits = "0" * (-len(bits) % 5) + bits # zero-extend to a multiple of 5 bits
|
|
out = "".join(ALPHABET[int(bits[i : i + 5], 2)] for i in range(0, len(bits), 5))
|
|
if len(out) < min_length:
|
|
out = "0" * (min_length - len(out)) + out
|
|
if hyphenate:
|
|
out = "-".join(
|
|
out[i : i + hyphenate] for i in range(0, len(out), hyphenate)
|
|
) # last group keeps 1..hyphenate symbols; a single symbol gets no hyphen
|
|
return out
|
|
|
|
|
|
def encode_divmod(value: int) -> str:
|
|
"""Independent encoder: repeated division by 32 instead of 5-bit slicing.
|
|
|
|
Kept in the module so the test suite can cross-check two unrelated algorithms.
|
|
"""
|
|
if not isinstance(value, int) or isinstance(value, bool):
|
|
raise TypeError("value must be an int")
|
|
if value < 0:
|
|
raise ValueError("Crockford Base32 encodes non-negative integers only")
|
|
if value == 0:
|
|
return "0"
|
|
digits = []
|
|
while value:
|
|
value, r = divmod(value, 32)
|
|
digits.append(ALPHABET[r])
|
|
return "".join(reversed(digits))
|
|
|
|
|
|
def decode(text: str) -> int:
|
|
"""Decode a symbol string (hyphens ignored, case folded, i/l/o aliases honoured)."""
|
|
if not isinstance(text, str):
|
|
raise TypeError("text must be a str")
|
|
body = text.replace("-", "")
|
|
if not body:
|
|
raise InvalidSymbolError("empty symbol string")
|
|
value = 0
|
|
for ch in body: # Horner
|
|
value = value * 32 + _symbol_value(ch)
|
|
return value
|
|
|
|
|
|
def decode_positional(text: str) -> int:
|
|
"""Independent decoder: explicit positional weights instead of Horner."""
|
|
if not isinstance(text, str):
|
|
raise TypeError("text must be a str")
|
|
body = text.replace("-", "")
|
|
if not body:
|
|
raise InvalidSymbolError("empty symbol string")
|
|
n = len(body)
|
|
return sum(_symbol_value(ch) * (32 ** (n - 1 - i)) for i, ch in enumerate(body))
|
|
|
|
|
|
def check_symbol(value: int) -> str:
|
|
"""The check symbol for a value: CHECK_ALPHABET[value % 37]."""
|
|
if not isinstance(value, int) or isinstance(value, bool):
|
|
raise TypeError("value must be an int")
|
|
if value < 0:
|
|
raise ValueError("check symbol is defined for non-negative integers")
|
|
return CHECK_ALPHABET[value % 37]
|
|
|
|
|
|
def encode_checked(value: int, min_length: int = 0, hyphenate: int = 0) -> str:
|
|
"""Payload plus trailing check symbol. Hyphenation applies to the payload only."""
|
|
return encode(value, min_length=min_length, hyphenate=hyphenate) + check_symbol(value)
|
|
|
|
|
|
def decode_checked(text: str) -> int:
|
|
"""Decode payload+check symbol; raise CheckSymbolError if the check fails."""
|
|
if not isinstance(text, str):
|
|
raise TypeError("text must be a str")
|
|
body = text.replace("-", "")
|
|
if len(body) < 2:
|
|
raise CheckSymbolError("need at least one payload symbol plus a check symbol")
|
|
payload, chk = body[:-1], body[-1]
|
|
value = decode(payload)
|
|
got = _check_value(chk)
|
|
if got != value % 37:
|
|
raise CheckSymbolError(
|
|
f"check symbol {chk!r} (= {got}) does not match value mod 37 (= {value % 37})"
|
|
)
|
|
return value
|
|
|
|
|
|
def verify(text: str) -> bool:
|
|
"""True iff text carries a valid check symbol."""
|
|
try:
|
|
decode_checked(text)
|
|
except Base32Error:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _main(argv: list[str] | None = None) -> int:
|
|
ap = argparse.ArgumentParser(prog="base32c", description="Crockford Base32 codec")
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
|
|
p = sub.add_parser("encode")
|
|
p.add_argument("value", type=int)
|
|
p.add_argument("--min-length", type=int, default=0)
|
|
p.add_argument("--hyphenate", type=int, default=0)
|
|
|
|
p = sub.add_parser("decode")
|
|
p.add_argument("text")
|
|
|
|
p = sub.add_parser("encode-checked")
|
|
p.add_argument("value", type=int)
|
|
p.add_argument("--min-length", type=int, default=0)
|
|
p.add_argument("--hyphenate", type=int, default=0)
|
|
|
|
p = sub.add_parser("verify")
|
|
p.add_argument("text")
|
|
|
|
p = sub.add_parser("roundtrip")
|
|
p.add_argument("value", type=int)
|
|
|
|
args = ap.parse_args(argv)
|
|
try:
|
|
if args.cmd == "encode":
|
|
print(encode(args.value, args.min_length, args.hyphenate))
|
|
elif args.cmd == "decode":
|
|
print(decode(args.text))
|
|
elif args.cmd == "encode-checked":
|
|
print(encode_checked(args.value, args.min_length, args.hyphenate))
|
|
elif args.cmd == "verify":
|
|
value = decode_checked(args.text)
|
|
print(f"OK {value}")
|
|
elif args.cmd == "roundtrip":
|
|
payload = encode(args.value)
|
|
checked = encode_checked(args.value)
|
|
back = decode_checked(checked)
|
|
print(f"payload={payload} checked={checked} decoded={back} equal={back == args.value}")
|
|
if back != args.value:
|
|
return 1
|
|
except Base32Error as exc:
|
|
print(f"INVALID: {exc}", file=sys.stderr)
|
|
return 2
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(_main())
|