"""
Base64 and Crockford Base32 encoding utilities.
This module provides encoding utilities including standard Base64 and
Crockford's Base32, which is human-friendly (avoids ambiguous characters
like 0/O and 1/I/L).
Example
-------
Using standard Base64::
from tet.util.base64 import Base64
encoded = Base64.encode(b"hello")
decoded = Base64.decode(encoded)
# Generate random Base64 characters
random_str = Base64.generate_characters(16)
Using Crockford Base32::
from tet.util.base64 import CrockfordBase32
encoded = CrockfordBase32.encode(b"hello")
decoded = CrockfordBase32.decode(encoded)
# Crockford Base32 is case-insensitive and handles ambiguous chars
CrockfordBase32.decode("O1L") # Treated as "011"
"""
import base64
import secrets
import string
maketrans = bytes.maketrans
[docs]
class BaseCodec:
"""Base class for encoding codecs."""
#: Number of bits each encoded character carries (log2 of the alphabet
#: size). Subclasses with power-of-two alphabets set this explicitly.
bits_per_char = None
[docs]
@classmethod
def generate_characters(cls, length):
"""Generate a random string of ``length`` characters in this codec's
alphabet, drawn from a cryptographically secure source.
Random bytes are generated with :func:`secrets.token_bytes`, run
through the codec's own :meth:`encode`, and truncated to ``length``.
Each encoded character carries ``cls.bits_per_char`` bits, so
``ceil(length * bits_per_char / 8)`` input bytes always encode to at
least ``length`` data characters. Padding (if any) only trails the
data, so the truncated slice never includes it.
"""
if length <= 0:
return ""
n_bytes = (length * cls.bits_per_char + 7) // 8
encoded = cls.encode(secrets.token_bytes(n_bytes))
if isinstance(encoded, bytes):
encoded = encoded.decode("ascii")
return encoded[:length]
[docs]
class Base64(BaseCodec):
"""Standard Base64 encoding."""
chars = string.ascii_letters + string.digits + "+/"
bits_per_char = 6
padding = True
[docs]
@classmethod
def encode(cls, string):
"""Encode bytes to Base64."""
return base64.b64encode(string)
[docs]
@classmethod
def normalize(cls, string):
"""Normalize input (no-op for standard Base64)."""
return string
[docs]
@classmethod
def decode(cls, string):
"""Decode Base64 to bytes."""
return base64.b64decode(string)
_std_b32_to_crockford_b32 = maketrans(
b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"
)
_crockford_b32_to_std_b32 = maketrans(
b"0OI1L23456789ABCDEFGHJKMNPQRSTVWXYZ", b"AABBBCDEFGHIJKLMNOPQRSTUVWXYZ234567"
)
_normalize_crockford_b32 = maketrans(
b"OIL" + string.ascii_lowercase.encode(), b"011" + string.ascii_uppercase.encode()
)
[docs]
class CrockfordBase32(BaseCodec):
"""
Crockford's Base32 encoding.
Human-friendly encoding that avoids ambiguous characters (0/O, 1/I/L).
Case-insensitive and handles common transcription errors.
"""
chars = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
bits_per_char = 5
padding = False
[docs]
@classmethod
def encode(cls, string, normalize=True, validate=False):
"""Encode bytes to Crockford Base32."""
if isinstance(string, str):
string = string.encode()
return (
base64.b32encode(string).translate(_std_b32_to_crockford_b32, b"=").decode()
)
[docs]
@classmethod
def decode(cls, string, normalize=True, validate=False):
"""Decode Crockford Base32 to bytes."""
if normalize:
string = cls.normalize(string)
if isinstance(string, str):
string = string.encode()
# Ensure the mandatory padding is correct:
b32 = string.upper()
b32 += b"=" * ((8 - len(b32) % 8) % 8)
return base64.b32decode(b32.translate(_crockford_b32_to_std_b32))
[docs]
@classmethod
def normalize(cls, string):
"""Normalize input by handling ambiguous characters (O->0, I/L->1)."""
if isinstance(string, str):
string = string.encode()
string = string.translate(_normalize_crockford_b32)
return string.decode()