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.
@@ -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())