recode-stamper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- recode_stamper/__init__.py +778 -0
- recode_stamper/wordlist4096.txt +4096 -0
- recode_stamper-0.1.0.dist-info/METADATA +82 -0
- recode_stamper-0.1.0.dist-info/RECORD +8 -0
- recode_stamper-0.1.0.dist-info/WHEEL +5 -0
- recode_stamper-0.1.0.dist-info/entry_points.txt +3 -0
- recode_stamper-0.1.0.dist-info/licenses/LICENSE +21 -0
- recode_stamper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
"""
|
|
2
|
+
How to run:
|
|
3
|
+
|
|
4
|
+
python recode.py --encode "test!"
|
|
5
|
+
python recode.py --decode "africa eatery gloved cupid brevity aardvark aardvark aardvark aardvark aardvark aardvark aardvark"
|
|
6
|
+
|
|
7
|
+
python recode.py encode "MyPassword123!"
|
|
8
|
+
python recode.py decode "artist scavenge intimate airdrop humdrum curfew hexagon candy comedy bunion brevity aardvark"
|
|
9
|
+
|
|
10
|
+
python recode.py
|
|
11
|
+
|
|
12
|
+
The script encodes UTF-8 text into a 12- or 24-word mnemonic and decodes it back.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import hashlib
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Recode:
|
|
21
|
+
"""
|
|
22
|
+
RECODE FORMAT
|
|
23
|
+
=============
|
|
24
|
+
|
|
25
|
+
Wordlist:
|
|
26
|
+
Exactly 4096 unique words.
|
|
27
|
+
Each word represents exactly 12 bits.
|
|
28
|
+
|
|
29
|
+
12 plates:
|
|
30
|
+
144 bits total
|
|
31
|
+
8 bits = data length
|
|
32
|
+
8 bits = checksum
|
|
33
|
+
128 bits = data + zero padding
|
|
34
|
+
|
|
35
|
+
Maximum data: 16 bytes
|
|
36
|
+
|
|
37
|
+
24 plates:
|
|
38
|
+
288 bits total
|
|
39
|
+
8 bits = data length
|
|
40
|
+
24 bits = checksum
|
|
41
|
+
256 bits = data + zero padding
|
|
42
|
+
|
|
43
|
+
Maximum data: 32 bytes
|
|
44
|
+
|
|
45
|
+
Automatic selection:
|
|
46
|
+
0..16 bytes -> 12 plates
|
|
47
|
+
17..32 bytes -> 24 plates
|
|
48
|
+
33+ bytes -> ERROR
|
|
49
|
+
|
|
50
|
+
The mnemonic is always exactly 12 or 24 words.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
WORD_COUNT = 4096
|
|
54
|
+
WORD_BITS = 12
|
|
55
|
+
|
|
56
|
+
VALID_PLATES = (12, 24)
|
|
57
|
+
|
|
58
|
+
FORMAT = {
|
|
59
|
+
12: {
|
|
60
|
+
"length_bits": 8,
|
|
61
|
+
"checksum_bits": 8,
|
|
62
|
+
"data_bits": 128,
|
|
63
|
+
},
|
|
64
|
+
24: {
|
|
65
|
+
"length_bits": 8,
|
|
66
|
+
"checksum_bits": 24,
|
|
67
|
+
"data_bits": 256,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# ======================================================
|
|
72
|
+
# INITIALIZATION
|
|
73
|
+
# ======================================================
|
|
74
|
+
|
|
75
|
+
def __init__(self, words):
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------
|
|
78
|
+
# Validate wordlist size
|
|
79
|
+
# --------------------------------------------------
|
|
80
|
+
|
|
81
|
+
if len(words) != self.WORD_COUNT:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Expected exactly {self.WORD_COUNT} words, "
|
|
84
|
+
f"got {len(words)}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Normalize words
|
|
88
|
+
self.words = [
|
|
89
|
+
word.strip().lower()
|
|
90
|
+
for word in words
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------
|
|
94
|
+
# Validate words
|
|
95
|
+
# --------------------------------------------------
|
|
96
|
+
|
|
97
|
+
if any(not word for word in self.words):
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"Wordlist contains an empty word"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if len(set(self.words)) != self.WORD_COUNT:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
"Wordlist contains duplicate words"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------
|
|
108
|
+
# Create canonical wordlist fingerprint
|
|
109
|
+
#
|
|
110
|
+
# Each word is followed by \n so that word boundaries
|
|
111
|
+
# are unambiguous.
|
|
112
|
+
# --------------------------------------------------
|
|
113
|
+
|
|
114
|
+
h = hashlib.sha256()
|
|
115
|
+
|
|
116
|
+
for word in self.words:
|
|
117
|
+
h.update(
|
|
118
|
+
word.encode("utf-8")
|
|
119
|
+
)
|
|
120
|
+
h.update(b"\n")
|
|
121
|
+
|
|
122
|
+
self.words_checksum = h.digest()
|
|
123
|
+
|
|
124
|
+
# --------------------------------------------------
|
|
125
|
+
# Word -> index
|
|
126
|
+
# --------------------------------------------------
|
|
127
|
+
|
|
128
|
+
self.word_to_index = {
|
|
129
|
+
word: index
|
|
130
|
+
for index, word in enumerate(self.words)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# ======================================================
|
|
134
|
+
# BASIC HELPERS
|
|
135
|
+
# ======================================================
|
|
136
|
+
|
|
137
|
+
def index_to_bits(self, index):
|
|
138
|
+
"""
|
|
139
|
+
Convert a word index 0..4095 into exactly 12 bits.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
if not isinstance(index, int):
|
|
143
|
+
raise TypeError(
|
|
144
|
+
"index must be an integer"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if not 0 <= index < self.WORD_COUNT:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"Invalid word index: {index}"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return format(
|
|
153
|
+
index,
|
|
154
|
+
"012b"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def bits_to_index(self, bits):
|
|
158
|
+
"""
|
|
159
|
+
Convert exactly 12 bits into a word index.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
if len(bits) != self.WORD_BITS:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
"Expected exactly 12 bits"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if any(
|
|
168
|
+
bit not in "01"
|
|
169
|
+
for bit in bits
|
|
170
|
+
):
|
|
171
|
+
raise ValueError(
|
|
172
|
+
"Bits must contain only 0 or 1"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return int(bits, 2)
|
|
176
|
+
|
|
177
|
+
# ======================================================
|
|
178
|
+
# FORMAT HELPERS
|
|
179
|
+
# ======================================================
|
|
180
|
+
|
|
181
|
+
def max_data_bytes(self, plates):
|
|
182
|
+
"""
|
|
183
|
+
Maximum data capacity for a given format.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
if plates not in self.VALID_PLATES:
|
|
187
|
+
raise ValueError(
|
|
188
|
+
"plates must be exactly 12 or 24"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
self.FORMAT[plates]["data_bits"]
|
|
193
|
+
// 8
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def validate_plates(self, plates):
|
|
197
|
+
"""
|
|
198
|
+
Validate that plates is exactly 12 or 24.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
if plates not in self.VALID_PLATES:
|
|
202
|
+
raise ValueError(
|
|
203
|
+
"plates must be exactly 12 or 24"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# ======================================================
|
|
207
|
+
# CHECKSUM
|
|
208
|
+
# ======================================================
|
|
209
|
+
|
|
210
|
+
def checksum(
|
|
211
|
+
self,
|
|
212
|
+
data,
|
|
213
|
+
length,
|
|
214
|
+
checksum_bits,
|
|
215
|
+
):
|
|
216
|
+
"""
|
|
217
|
+
Calculate checksum.
|
|
218
|
+
|
|
219
|
+
Input to SHA-256:
|
|
220
|
+
|
|
221
|
+
domain separator
|
|
222
|
+
length
|
|
223
|
+
data
|
|
224
|
+
wordlist fingerprint
|
|
225
|
+
|
|
226
|
+
The wordlist fingerprint binds the mnemonic
|
|
227
|
+
to the exact ordered wordlist.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
if not isinstance(data, bytes):
|
|
231
|
+
raise TypeError(
|
|
232
|
+
"data must be bytes"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if not 0 <= length <= 255:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
"length must fit into 8 bits"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if length != len(data):
|
|
241
|
+
raise ValueError(
|
|
242
|
+
"length does not match data length"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if checksum_bits not in (8, 24):
|
|
246
|
+
raise ValueError(
|
|
247
|
+
"checksum_bits must be 8 or 24"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
h = hashlib.sha256()
|
|
251
|
+
|
|
252
|
+
# Explicit domain separator
|
|
253
|
+
h.update(b"RECODE-V1")
|
|
254
|
+
|
|
255
|
+
# Length
|
|
256
|
+
h.update(
|
|
257
|
+
bytes([length])
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Data
|
|
261
|
+
h.update(data)
|
|
262
|
+
|
|
263
|
+
# Exact wordlist fingerprint
|
|
264
|
+
h.update(
|
|
265
|
+
self.words_checksum
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
digest = h.digest()
|
|
269
|
+
|
|
270
|
+
# Number of bytes needed
|
|
271
|
+
checksum_bytes = (
|
|
272
|
+
checksum_bits // 8
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
value = int.from_bytes(
|
|
276
|
+
digest[:checksum_bytes],
|
|
277
|
+
"big"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return format(
|
|
281
|
+
value,
|
|
282
|
+
f"0{checksum_bits}b"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# ======================================================
|
|
286
|
+
# ENCODE
|
|
287
|
+
# ======================================================
|
|
288
|
+
|
|
289
|
+
def encode(self, data, plates=None):
|
|
290
|
+
"""
|
|
291
|
+
Encode bytes into exactly 12 or 24 words.
|
|
292
|
+
|
|
293
|
+
plates=None:
|
|
294
|
+
Automatically select the smallest format.
|
|
295
|
+
|
|
296
|
+
plates=12:
|
|
297
|
+
Exactly 12 words.
|
|
298
|
+
|
|
299
|
+
plates=24:
|
|
300
|
+
Exactly 24 words.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
if not isinstance(data, bytes):
|
|
304
|
+
raise TypeError(
|
|
305
|
+
"data must be bytes"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# --------------------------------------------------
|
|
309
|
+
# Automatic format selection
|
|
310
|
+
# --------------------------------------------------
|
|
311
|
+
|
|
312
|
+
if plates is None:
|
|
313
|
+
|
|
314
|
+
if len(data) <= self.max_data_bytes(12):
|
|
315
|
+
plates = 12
|
|
316
|
+
|
|
317
|
+
elif len(data) <= self.max_data_bytes(24):
|
|
318
|
+
plates = 24
|
|
319
|
+
|
|
320
|
+
else:
|
|
321
|
+
raise ValueError(
|
|
322
|
+
f"Data too long: {len(data)} bytes. "
|
|
323
|
+
f"Maximum is "
|
|
324
|
+
f"{self.max_data_bytes(24)} bytes."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# --------------------------------------------------
|
|
328
|
+
# Validate requested format
|
|
329
|
+
# --------------------------------------------------
|
|
330
|
+
|
|
331
|
+
self.validate_plates(plates)
|
|
332
|
+
|
|
333
|
+
fmt = self.FORMAT[plates]
|
|
334
|
+
|
|
335
|
+
max_bytes = (
|
|
336
|
+
fmt["data_bits"] // 8
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# --------------------------------------------------
|
|
340
|
+
# Validate capacity
|
|
341
|
+
# --------------------------------------------------
|
|
342
|
+
|
|
343
|
+
if len(data) > max_bytes:
|
|
344
|
+
raise ValueError(
|
|
345
|
+
f"{plates} plates support maximum "
|
|
346
|
+
f"{max_bytes} bytes, "
|
|
347
|
+
f"got {len(data)}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
length = len(data)
|
|
351
|
+
|
|
352
|
+
# --------------------------------------------------
|
|
353
|
+
# LENGTH
|
|
354
|
+
# --------------------------------------------------
|
|
355
|
+
|
|
356
|
+
length_bits = format(
|
|
357
|
+
length,
|
|
358
|
+
"08b"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# --------------------------------------------------
|
|
362
|
+
# CHECKSUM
|
|
363
|
+
# --------------------------------------------------
|
|
364
|
+
|
|
365
|
+
checksum_bits = self.checksum(
|
|
366
|
+
data=data,
|
|
367
|
+
length=length,
|
|
368
|
+
checksum_bits=fmt["checksum_bits"],
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# --------------------------------------------------
|
|
372
|
+
# DATA -> BITS
|
|
373
|
+
# --------------------------------------------------
|
|
374
|
+
|
|
375
|
+
data_bits = "".join(
|
|
376
|
+
format(byte, "08b")
|
|
377
|
+
for byte in data
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# --------------------------------------------------
|
|
381
|
+
# ZERO PADDING
|
|
382
|
+
# --------------------------------------------------
|
|
383
|
+
|
|
384
|
+
padding_length = (
|
|
385
|
+
fmt["data_bits"]
|
|
386
|
+
- len(data_bits)
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
data_bits += (
|
|
390
|
+
"0" * padding_length
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# --------------------------------------------------
|
|
394
|
+
# BUILD PAYLOAD
|
|
395
|
+
# --------------------------------------------------
|
|
396
|
+
|
|
397
|
+
bits = (
|
|
398
|
+
length_bits
|
|
399
|
+
+ checksum_bits
|
|
400
|
+
+ data_bits
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
expected_bits = (
|
|
404
|
+
plates * self.WORD_BITS
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# --------------------------------------------------
|
|
408
|
+
# Internal consistency check
|
|
409
|
+
# --------------------------------------------------
|
|
410
|
+
|
|
411
|
+
if len(bits) != expected_bits:
|
|
412
|
+
raise RuntimeError(
|
|
413
|
+
"Internal format error: "
|
|
414
|
+
f"expected {expected_bits} bits, "
|
|
415
|
+
f"got {len(bits)}"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# --------------------------------------------------
|
|
419
|
+
# BITS -> WORDS
|
|
420
|
+
# --------------------------------------------------
|
|
421
|
+
|
|
422
|
+
mnemonic = []
|
|
423
|
+
|
|
424
|
+
for position in range(
|
|
425
|
+
0,
|
|
426
|
+
expected_bits,
|
|
427
|
+
self.WORD_BITS
|
|
428
|
+
):
|
|
429
|
+
|
|
430
|
+
chunk = bits[
|
|
431
|
+
position:
|
|
432
|
+
position + self.WORD_BITS
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
index = self.bits_to_index(
|
|
436
|
+
chunk
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
mnemonic.append(
|
|
440
|
+
self.words[index]
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# --------------------------------------------------
|
|
444
|
+
# Final validation
|
|
445
|
+
# --------------------------------------------------
|
|
446
|
+
|
|
447
|
+
if len(mnemonic) != plates:
|
|
448
|
+
raise RuntimeError(
|
|
449
|
+
"Internal error: "
|
|
450
|
+
"incorrect mnemonic length"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
return mnemonic
|
|
454
|
+
|
|
455
|
+
# ======================================================
|
|
456
|
+
# DECODE
|
|
457
|
+
# ======================================================
|
|
458
|
+
|
|
459
|
+
def decode(self, mnemonic):
|
|
460
|
+
"""
|
|
461
|
+
Decode exactly 12 or 24 words.
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
if not isinstance(mnemonic, (list, tuple)):
|
|
465
|
+
raise TypeError(
|
|
466
|
+
"mnemonic must be a list or tuple"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
if not mnemonic:
|
|
470
|
+
raise ValueError(
|
|
471
|
+
"Empty mnemonic"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# --------------------------------------------------
|
|
475
|
+
# Exact mnemonic length
|
|
476
|
+
# --------------------------------------------------
|
|
477
|
+
|
|
478
|
+
plates = len(mnemonic)
|
|
479
|
+
|
|
480
|
+
self.validate_plates(plates)
|
|
481
|
+
|
|
482
|
+
fmt = self.FORMAT[plates]
|
|
483
|
+
|
|
484
|
+
# --------------------------------------------------
|
|
485
|
+
# WORDS -> BITS
|
|
486
|
+
# --------------------------------------------------
|
|
487
|
+
|
|
488
|
+
bits = []
|
|
489
|
+
|
|
490
|
+
for position, word in enumerate(
|
|
491
|
+
mnemonic,
|
|
492
|
+
start=1
|
|
493
|
+
):
|
|
494
|
+
|
|
495
|
+
if not isinstance(word, str):
|
|
496
|
+
raise TypeError(
|
|
497
|
+
f"Word at position {position} "
|
|
498
|
+
f"must be a string"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
word = word.strip().lower()
|
|
502
|
+
|
|
503
|
+
if word not in self.word_to_index:
|
|
504
|
+
raise ValueError(
|
|
505
|
+
f"Unknown word at position "
|
|
506
|
+
f"{position}: {word!r}"
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
index = self.word_to_index[word]
|
|
510
|
+
|
|
511
|
+
bits.append(
|
|
512
|
+
self.index_to_bits(index)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
bits = "".join(bits)
|
|
516
|
+
|
|
517
|
+
expected_bits = (
|
|
518
|
+
plates * self.WORD_BITS
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
if len(bits) != expected_bits:
|
|
522
|
+
raise RuntimeError(
|
|
523
|
+
"Internal error: "
|
|
524
|
+
"incorrect bit length"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# --------------------------------------------------
|
|
528
|
+
# READ LENGTH
|
|
529
|
+
# --------------------------------------------------
|
|
530
|
+
|
|
531
|
+
offset = 0
|
|
532
|
+
|
|
533
|
+
length_bits = bits[
|
|
534
|
+
offset:
|
|
535
|
+
offset + 8
|
|
536
|
+
]
|
|
537
|
+
|
|
538
|
+
offset += 8
|
|
539
|
+
|
|
540
|
+
length = int(
|
|
541
|
+
length_bits,
|
|
542
|
+
2
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
max_bytes = (
|
|
546
|
+
fmt["data_bits"] // 8
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
if length > max_bytes:
|
|
550
|
+
raise ValueError(
|
|
551
|
+
f"Invalid data length: "
|
|
552
|
+
f"{length} bytes for "
|
|
553
|
+
f"{plates} plates"
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# --------------------------------------------------
|
|
557
|
+
# READ CHECKSUM
|
|
558
|
+
# --------------------------------------------------
|
|
559
|
+
|
|
560
|
+
checksum_size = fmt[
|
|
561
|
+
"checksum_bits"
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
stored_checksum = bits[
|
|
565
|
+
offset:
|
|
566
|
+
offset + checksum_size
|
|
567
|
+
]
|
|
568
|
+
|
|
569
|
+
offset += checksum_size
|
|
570
|
+
|
|
571
|
+
# --------------------------------------------------
|
|
572
|
+
# READ DATA AREA
|
|
573
|
+
# --------------------------------------------------
|
|
574
|
+
|
|
575
|
+
data_area_bits = bits[
|
|
576
|
+
offset:
|
|
577
|
+
offset + fmt["data_bits"]
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
if len(data_area_bits) != fmt["data_bits"]:
|
|
581
|
+
raise ValueError(
|
|
582
|
+
"Invalid data area"
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
# --------------------------------------------------
|
|
586
|
+
# ACTUAL DATA
|
|
587
|
+
# --------------------------------------------------
|
|
588
|
+
|
|
589
|
+
actual_data_bits = data_area_bits[
|
|
590
|
+
:length * 8
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
# --------------------------------------------------
|
|
594
|
+
# ZERO PADDING
|
|
595
|
+
# --------------------------------------------------
|
|
596
|
+
|
|
597
|
+
padding_bits = data_area_bits[
|
|
598
|
+
length * 8:
|
|
599
|
+
]
|
|
600
|
+
|
|
601
|
+
if any(
|
|
602
|
+
bit != "0"
|
|
603
|
+
for bit in padding_bits
|
|
604
|
+
):
|
|
605
|
+
raise ValueError(
|
|
606
|
+
"INVALID PADDING"
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
# --------------------------------------------------
|
|
610
|
+
# BITS -> BYTES
|
|
611
|
+
# --------------------------------------------------
|
|
612
|
+
|
|
613
|
+
data = bytes(
|
|
614
|
+
int(
|
|
615
|
+
actual_data_bits[i:i + 8],
|
|
616
|
+
2
|
|
617
|
+
)
|
|
618
|
+
for i in range(
|
|
619
|
+
0,
|
|
620
|
+
len(actual_data_bits),
|
|
621
|
+
8
|
|
622
|
+
)
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# --------------------------------------------------
|
|
626
|
+
# CHECKSUM VALIDATION
|
|
627
|
+
# --------------------------------------------------
|
|
628
|
+
|
|
629
|
+
expected_checksum = self.checksum(
|
|
630
|
+
data=data,
|
|
631
|
+
length=length,
|
|
632
|
+
checksum_bits=checksum_size,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
if stored_checksum != expected_checksum:
|
|
636
|
+
raise ValueError(
|
|
637
|
+
"INVALID CHECKSUM"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
return data
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# ==========================================================
|
|
644
|
+
# LOAD REAL WORDLIST
|
|
645
|
+
# ==========================================================
|
|
646
|
+
|
|
647
|
+
with open(
|
|
648
|
+
Path(__file__).resolve().with_name("wordlist4096.txt"),
|
|
649
|
+
"r",
|
|
650
|
+
encoding="utf-8"
|
|
651
|
+
) as f:
|
|
652
|
+
words = f.read().splitlines()
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
r = Recode(words)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
# ==========================================================
|
|
659
|
+
# CLI
|
|
660
|
+
# ==========================================================
|
|
661
|
+
|
|
662
|
+
def parse_mnemonic_input(raw_text):
|
|
663
|
+
"""Normalize a CLI mnemonic string into a list of words."""
|
|
664
|
+
|
|
665
|
+
if raw_text is None:
|
|
666
|
+
raise ValueError("No text provided")
|
|
667
|
+
|
|
668
|
+
return [
|
|
669
|
+
part.strip().lower()
|
|
670
|
+
for part in raw_text.replace(
|
|
671
|
+
",",
|
|
672
|
+
" "
|
|
673
|
+
).split()
|
|
674
|
+
if part.strip()
|
|
675
|
+
]
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def build_parser():
|
|
679
|
+
"""Create the command-line parser."""
|
|
680
|
+
|
|
681
|
+
parser = argparse.ArgumentParser(
|
|
682
|
+
description="Encode and decode RECODE mnemonics."
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
parser.add_argument(
|
|
686
|
+
"--encode",
|
|
687
|
+
dest="encode_text",
|
|
688
|
+
help="UTF-8 string to encode into mnemonic words",
|
|
689
|
+
metavar="TEXT",
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
parser.add_argument(
|
|
693
|
+
"--decode",
|
|
694
|
+
dest="decode_text",
|
|
695
|
+
help="Mnemonic phrase to decode back into a UTF-8 string",
|
|
696
|
+
metavar="TEXT",
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
parser.add_argument(
|
|
700
|
+
"--plates",
|
|
701
|
+
type=int,
|
|
702
|
+
choices=(12, 24),
|
|
703
|
+
help="Force 12 or 24 plates when encoding",
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
subparsers = parser.add_subparsers(
|
|
707
|
+
dest="command"
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
encode_parser = subparsers.add_parser(
|
|
711
|
+
"encode",
|
|
712
|
+
help="encode a UTF-8 string",
|
|
713
|
+
)
|
|
714
|
+
encode_parser.add_argument(
|
|
715
|
+
"text",
|
|
716
|
+
help="UTF-8 string to encode",
|
|
717
|
+
)
|
|
718
|
+
encode_parser.add_argument(
|
|
719
|
+
"--plates",
|
|
720
|
+
type=int,
|
|
721
|
+
choices=(12, 24),
|
|
722
|
+
help="Force 12 or 24 plates when encoding",
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
decode_parser = subparsers.add_parser(
|
|
726
|
+
"decode",
|
|
727
|
+
help="decode a mnemonic phrase",
|
|
728
|
+
)
|
|
729
|
+
decode_parser.add_argument(
|
|
730
|
+
"text",
|
|
731
|
+
help="Mnemonic words separated by spaces",
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
return parser
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def main(argv=None):
|
|
738
|
+
"""Run the CLI."""
|
|
739
|
+
|
|
740
|
+
parser = build_parser()
|
|
741
|
+
args = parser.parse_args(argv)
|
|
742
|
+
|
|
743
|
+
if args.command == "encode":
|
|
744
|
+
text = args.text
|
|
745
|
+
plates = args.plates
|
|
746
|
+
mnemonic = r.encode(
|
|
747
|
+
text.encode("utf-8"),
|
|
748
|
+
plates=plates,
|
|
749
|
+
)
|
|
750
|
+
print(" ".join(mnemonic))
|
|
751
|
+
return 0
|
|
752
|
+
|
|
753
|
+
if args.command == "decode":
|
|
754
|
+
mnemonic = parse_mnemonic_input(args.text)
|
|
755
|
+
decoded = r.decode(mnemonic).decode("utf-8")
|
|
756
|
+
print(decoded)
|
|
757
|
+
return 0
|
|
758
|
+
|
|
759
|
+
if args.encode_text is not None:
|
|
760
|
+
mnemonic = r.encode(
|
|
761
|
+
args.encode_text.encode("utf-8"),
|
|
762
|
+
plates=args.plates,
|
|
763
|
+
)
|
|
764
|
+
print(" ".join(mnemonic))
|
|
765
|
+
return 0
|
|
766
|
+
|
|
767
|
+
if args.decode_text is not None:
|
|
768
|
+
mnemonic = parse_mnemonic_input(args.decode_text)
|
|
769
|
+
decoded = r.decode(mnemonic).decode("utf-8")
|
|
770
|
+
print(decoded)
|
|
771
|
+
return 0
|
|
772
|
+
|
|
773
|
+
parser.print_help()
|
|
774
|
+
return 0
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
if __name__ == "__main__":
|
|
778
|
+
raise SystemExit(main())
|