| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122 |
- """
- Pure-Python implementation of mnemonicode encoding/decoding.
- Mnemonicode encodes arbitrary bytes as a sequence of human-readable words.
- Each group of 4 bytes maps to 3 words; shorter trailing groups use fewer words.
- The 24-bit trailing case (3 bytes) uses a distinct set of 7 short words to
- signal that a remainder word ends the group.
- Word list: version 0.7, 1626 base words + 7 remainder words = 1633 total.
- """
- import json
- import sys
- from functools import lru_cache
- from pathlib import Path
- MN_BASE = 1626
- _WORDLIST_JSON = Path(__file__).parent / "wordlist.json"
- @lru_cache(maxsize=1)
- def _load() -> tuple[list[str], dict[str, int], str]:
- data = json.loads(_WORDLIST_JSON.read_text())
- words: list[str] = data["words"]
- index: dict[str, int] = {w: i for i, w in enumerate(words)}
- return words, index, data["version"]
- def wordlist_version() -> str:
- return _load()[2]
- def words_required(size: int) -> int:
- """Number of mnemonic words needed to encode `size` bytes."""
- return ((size + 1) * 3) // 4
- def encode(data: bytes) -> list[str]:
- """Encode bytes to a list of mnemonic words."""
- words, _, _ = _load()
- result = []
- for n in range(words_required(len(data))):
- grp = (n // 3) * 4
- remaining = min(len(data) - grp, 4)
- x = int.from_bytes(data[grp:grp + remaining], "little")
- pos = n % 3
- extra = 0
- if pos == 2:
- if remaining == 3:
- extra = MN_BASE # use one of the 7 short remainder words
- x //= MN_BASE * MN_BASE
- elif pos == 1:
- x //= MN_BASE
- result.append(words[x % MN_BASE + extra])
- return result
- def decode(encoded: list[str]) -> bytes:
- """Decode a list of mnemonic words back to bytes."""
- _, word_index, _ = _load()
- buf = bytearray(((len(encoded) // 3) + 1) * 4)
- offset = 0
- for word in encoded:
- try:
- idx = word_index[word.lower()]
- except KeyError:
- raise ValueError(f"unrecognized word: {word!r}") from None
- grp = offset & ~3
- x = int.from_bytes(buf[grp:grp + 4], "little")
- pos = offset % 4
- if pos == 3:
- raise ValueError("unexpected word after 24-bit remainder")
- elif pos == 0:
- x = idx
- offset += 1
- elif pos == 1:
- x += idx * MN_BASE
- offset += 1
- elif pos == 2:
- if idx >= MN_BASE: # 24-bit remainder word
- x += (idx - MN_BASE) * MN_BASE * MN_BASE
- offset += 1 # offset % 4 == 3 signals end-of-group next
- else:
- if idx > 1624 or (idx == 1624 and x > 1_312_671):
- raise ValueError("invalid encoding: value out of range")
- x += idx * MN_BASE * MN_BASE
- offset += 2 # skip to next group
- buf[grp:grp + 4] = x.to_bytes(4, "little")
- return bytes(buf[:offset])
- if __name__ == "__main__":
- import argparse
- mode = "decode" if "decode" in sys.argv[0] else "encode"
- parser = argparse.ArgumentParser(
- prog=sys.argv[0],
- description="Decode mnemonic words to bytes." if mode == "decode" else "Encode bytes to mnemonic words.",
- )
- parser.add_argument("-s", "--separator", default="-", help="word separator (default: -)")
- args = parser.parse_args()
- if mode == "encode":
- data = sys.stdin.buffer.read()
- print(args.separator.join(encode(data)))
- else:
- words = [w.strip() for w in sys.stdin.read().split(args.separator) if w.strip()]
- try:
- data = decode(words)
- except ValueError as e:
- print(f"error: {e}", file=sys.stderr)
- sys.exit(1)
- sys.stdout.buffer.write(data)
|