mnemonicode.py 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. """
  2. Pure-Python implementation of mnemonicode encoding/decoding.
  3. Mnemonicode encodes arbitrary bytes as a sequence of human-readable words.
  4. Each group of 4 bytes maps to 3 words; shorter trailing groups use fewer words.
  5. The 24-bit trailing case (3 bytes) uses a distinct set of 7 short words to
  6. signal that a remainder word ends the group.
  7. Word list: version 0.7, 1626 base words + 7 remainder words = 1633 total.
  8. """
  9. import json
  10. import sys
  11. from functools import lru_cache
  12. from pathlib import Path
  13. MN_BASE = 1626
  14. _WORDLIST_JSON = Path(__file__).parent / "wordlist.json"
  15. @lru_cache(maxsize=1)
  16. def _load() -> tuple[list[str], dict[str, int], str]:
  17. data = json.loads(_WORDLIST_JSON.read_text())
  18. words: list[str] = data["words"]
  19. index: dict[str, int] = {w: i for i, w in enumerate(words)}
  20. return words, index, data["version"]
  21. def wordlist_version() -> str:
  22. return _load()[2]
  23. def words_required(size: int) -> int:
  24. """Number of mnemonic words needed to encode `size` bytes."""
  25. return ((size + 1) * 3) // 4
  26. def encode(data: bytes) -> list[str]:
  27. """Encode bytes to a list of mnemonic words."""
  28. words, _, _ = _load()
  29. result = []
  30. for n in range(words_required(len(data))):
  31. grp = (n // 3) * 4
  32. remaining = min(len(data) - grp, 4)
  33. x = int.from_bytes(data[grp:grp + remaining], "little")
  34. pos = n % 3
  35. extra = 0
  36. if pos == 2:
  37. if remaining == 3:
  38. extra = MN_BASE # use one of the 7 short remainder words
  39. x //= MN_BASE * MN_BASE
  40. elif pos == 1:
  41. x //= MN_BASE
  42. result.append(words[x % MN_BASE + extra])
  43. return result
  44. def decode(encoded: list[str]) -> bytes:
  45. """Decode a list of mnemonic words back to bytes."""
  46. _, word_index, _ = _load()
  47. buf = bytearray(((len(encoded) // 3) + 1) * 4)
  48. offset = 0
  49. for word in encoded:
  50. try:
  51. idx = word_index[word.lower()]
  52. except KeyError:
  53. raise ValueError(f"unrecognized word: {word!r}") from None
  54. grp = offset & ~3
  55. x = int.from_bytes(buf[grp:grp + 4], "little")
  56. pos = offset % 4
  57. if pos == 3:
  58. raise ValueError("unexpected word after 24-bit remainder")
  59. elif pos == 0:
  60. x = idx
  61. offset += 1
  62. elif pos == 1:
  63. x += idx * MN_BASE
  64. offset += 1
  65. elif pos == 2:
  66. if idx >= MN_BASE: # 24-bit remainder word
  67. x += (idx - MN_BASE) * MN_BASE * MN_BASE
  68. offset += 1 # offset % 4 == 3 signals end-of-group next
  69. else:
  70. if idx > 1624 or (idx == 1624 and x > 1_312_671):
  71. raise ValueError("invalid encoding: value out of range")
  72. x += idx * MN_BASE * MN_BASE
  73. offset += 2 # skip to next group
  74. buf[grp:grp + 4] = x.to_bytes(4, "little")
  75. return bytes(buf[:offset])
  76. if __name__ == "__main__":
  77. import argparse
  78. mode = "decode" if "decode" in sys.argv[0] else "encode"
  79. parser = argparse.ArgumentParser(
  80. prog=sys.argv[0],
  81. description="Decode mnemonic words to bytes." if mode == "decode" else "Encode bytes to mnemonic words.",
  82. )
  83. parser.add_argument("-s", "--separator", default="-", help="word separator (default: -)")
  84. args = parser.parse_args()
  85. if mode == "encode":
  86. data = sys.stdin.buffer.read()
  87. print(args.separator.join(encode(data)))
  88. else:
  89. words = [w.strip() for w in sys.stdin.read().split(args.separator) if w.strip()]
  90. try:
  91. data = decode(words)
  92. except ValueError as e:
  93. print(f"error: {e}", file=sys.stderr)
  94. sys.exit(1)
  95. sys.stdout.buffer.write(data)