1use crate::error::{CryptoError, Result};
72use sha3::{Digest, Sha3_256};
73use std::sync::LazyLock;
74use unicode_normalization::UnicodeNormalization;
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum PhraseLength {
79 Words12,
81 Words24,
83}
84
85impl PhraseLength {
86 pub fn entropy_bytes(self) -> usize {
88 match self {
89 PhraseLength::Words12 => 16,
90 PhraseLength::Words24 => 32,
91 }
92 }
93
94 pub fn checksum_bits(self) -> usize {
96 self.entropy_bytes() * 8 / 32
97 }
98
99 pub fn total_bits(self) -> usize {
101 self.entropy_bytes() * 8 + self.checksum_bits()
102 }
103
104 pub fn words(self) -> usize {
106 self.total_bits() / 11
107 }
108
109 pub fn wordlist_size(self) -> usize {
111 2048
112 }
113}
114
115const DEFAULT_WORDLIST_STR: &str = include_str!("default_wordlist.txt");
121const DEFAULT_WORDLIST_LEN: usize = 2048;
122
123#[derive(Debug, Clone)]
131pub struct UnicodeWordlist {
132 entries: Box<[char]>,
133}
134
135impl UnicodeWordlist {
136 pub fn new(entries: &[char]) -> Result<Self> {
144 if entries.is_empty() {
145 return Err(CryptoError::InvalidParameter(
146 "wordlist must not be empty".into(),
147 ));
148 }
149 if !entries.len().is_power_of_two() {
150 return Err(CryptoError::InvalidParameter(format!(
151 "wordlist size {} is not a power of 2 \
152 (v0.1 supports 16, 32, 64, 128, 256, 512, 1024, 2048, 4096)",
153 entries.len()
154 )));
155 }
156 let mut seen = std::collections::HashSet::new();
157 for &c in entries {
158 if !seen.insert(c) {
159 return Err(CryptoError::InvalidParameter(format!(
160 "duplicate codepoint U+{:04X} in wordlist",
161 c as u32
162 )));
163 }
164 }
165 Ok(Self {
166 entries: entries.to_vec().into_boxed_slice(),
167 })
168 }
169
170 pub fn len(&self) -> usize {
172 self.entries.len()
173 }
174
175 pub fn is_empty(&self) -> bool {
177 self.entries.is_empty()
178 }
179
180 pub fn as_slice(&self) -> &[char] {
182 &self.entries
183 }
184}
185
186impl Default for UnicodeWordlist {
187 fn default() -> Self {
188 DEFAULT_WORDLIST.clone()
189 }
190}
191
192impl UnicodeWordlist {
193 pub fn bits_per_word(&self) -> u32 {
195 (self.entries.len() as u32).trailing_zeros()
197 }
198
199 pub fn get(&self, index: usize) -> Option<char> {
201 self.entries.get(index).copied()
202 }
203
204 pub fn index_of(&self, codepoint: char) -> Option<usize> {
206 self.entries.iter().position(|&c| c == codepoint)
207 }
208}
209
210pub static DEFAULT_WORDLIST: LazyLock<UnicodeWordlist> = LazyLock::new(|| {
215 let chars: Vec<char> = DEFAULT_WORDLIST_STR.chars().collect();
216 assert_eq!(
217 chars.len(),
218 DEFAULT_WORDLIST_LEN,
219 "default_wordlist.txt must contain exactly {DEFAULT_WORDLIST_LEN} codepoints, got {}",
220 chars.len()
221 );
222 UnicodeWordlist::new(&chars).expect("default wordlist is internally valid (power-of-2, dedup)")
223});
224
225#[derive(Debug, Clone, PartialEq)]
227pub struct EntropyReport {
228 pub total_bits: f64,
230 pub is_safe: bool,
232}
233
234pub const MIN_SAFE_BITS: f64 = 128.0;
238
239fn extract_bits_msb(bytes: &[u8], start_bit: usize, end_bit: usize) -> u32 {
248 debug_assert!(start_bit <= end_bit);
249 let n = end_bit - start_bit;
250 let mut out: u32 = 0;
251 for i in 0..n {
252 let bit_pos = start_bit + i;
253 let byte_idx = bit_pos / 8;
254 let bit_in_byte = 7 - (bit_pos % 8);
255 let bit: u32 = if byte_idx < bytes.len() {
256 ((bytes[byte_idx] >> bit_in_byte) & 1).into()
257 } else {
258 0
259 };
260 out = (out << 1) | bit;
261 }
262 out
263}
264
265fn insert_bits_msb(bytes: &mut Vec<u8>, value: u32, start_bit: usize, n_bits: usize) {
268 for i in 0..n_bits {
269 let bit_pos = start_bit + i;
270 let byte_idx = bit_pos / 8;
271 let bit_in_byte = 7 - (bit_pos % 8);
272 let bit = ((value >> (n_bits - 1 - i)) & 1) as u8;
273 while bytes.len() <= byte_idx {
274 bytes.push(0);
275 }
276 if bit == 1 {
277 bytes[byte_idx] |= 1 << bit_in_byte;
278 }
279 }
280}
281
282fn reject_class(c: char) -> Option<&'static str> {
291 let cp = c as u32;
292 if cp == 0x200D {
293 return Some("ZWJ");
294 }
295 if (0xFE00..=0xFE0F).contains(&cp) {
296 return Some("VARIATION_SELECTOR");
297 }
298 if (0x0300..=0x036F).contains(&cp) {
299 return Some("COMBINING_MARK");
300 }
301 if cp == 0x200E || cp == 0x200F {
302 return Some("BIDI_MARK");
303 }
304 if (0x202A..=0x202E).contains(&cp) || (0x2066..=0x2069).contains(&cp) {
305 return Some("BIDI_MARK");
306 }
307 if cp == 0x200B || cp == 0x200C || cp == 0xFEFF || (0x2060..=0x2064).contains(&cp) {
308 return Some("DEFAULT_IGNORABLE");
309 }
310 if cp == 0x180E {
312 return Some("DEFAULT_IGNORABLE");
313 }
314 if (0x206A..=0x206F).contains(&cp) {
317 return Some("BIDI_MARK");
318 }
319 if cp == 0x061C {
321 return Some("BIDI_MARK");
322 }
323 if c.is_control() {
324 return Some("CONTROL");
325 }
326 None
327}
328
329fn reject_non_canonical(c: char) -> Option<&'static str> {
341 let mut multi = 0usize;
342 for _x in c.nfkc() {
343 multi += 1;
344 }
345 if multi != 1 {
346 Some("NFKC canonical form has multiple codepoints (silent malleability vector)")
347 } else {
348 None
349 }
350}
351
352fn validate_codepoint(c: char) -> Result<()> {
353 if let Some(class) = reject_class(c) {
354 return Err(CryptoError::RejectedCodepointClass {
355 codepoint: c,
356 class,
357 });
358 }
359 if let Some(reason) = reject_non_canonical(c) {
360 return Err(CryptoError::InvalidNormalization {
361 codepoint: c,
362 reason,
363 });
364 }
365 Ok(())
366}
367
368fn validate_input_chars(chars: &[char]) -> Result<()> {
369 for &c in chars {
370 validate_codepoint(c)?;
371 }
372 Ok(())
373}
374
375pub fn encode_phrase(
392 entropy: &[u8],
393 list: &UnicodeWordlist,
394 len: PhraseLength,
395) -> Result<Vec<char>> {
396 if entropy.len() != len.entropy_bytes() {
397 return Err(CryptoError::InvalidKeyLength {
398 algorithm: "UnicodeCipher",
399 expected: len.entropy_bytes(),
400 got: entropy.len(),
401 });
402 }
403 if list.len() != len.wordlist_size() {
404 return Err(CryptoError::InvalidParameter(format!(
405 "encode_phrase: wordlist size {} does not match required {}",
406 list.len(),
407 len.wordlist_size()
408 )));
409 }
410
411 let mut hasher = Sha3_256::new();
414 hasher.update(entropy);
415 let checksum_full: [u8; 32] = hasher.finalize().into();
416
417 let mut combined = entropy.to_vec();
419 let cs_bytes = (len.checksum_bits() + 7) / 8;
420 combined.extend_from_slice(&checksum_full[..cs_bytes]);
421
422 let n_words = len.words();
423 let mut out = Vec::with_capacity(n_words);
424 for i in 0..n_words {
425 let start_bit = i * 11;
426 let end_bit = start_bit + 11;
427 let idx = extract_bits_msb(&combined, start_bit, end_bit) as usize;
428 let c = list.get(idx).ok_or_else(|| {
429 CryptoError::InvalidParameter(format!(
430 "internal: extracted index {idx} out of wordlist range {}",
431 list.len()
432 ))
433 })?;
434 out.push(c);
435 }
436 Ok(out)
437}
438
439pub fn decode_phrase(phrase: &[char], list: &UnicodeWordlist) -> Result<Vec<u8>> {
449 let len = match phrase.len() {
450 12 => PhraseLength::Words12,
451 24 => PhraseLength::Words24,
452 n => {
453 return Err(CryptoError::InvalidParameter(format!(
454 "phrase length {n} is not 12 or 24 (cannot disambiguate without length field)"
455 )))
456 }
457 };
458
459 validate_input_chars(phrase)?;
460
461 if list.len() != len.wordlist_size() {
462 return Err(CryptoError::InvalidParameter(format!(
463 "decode_phrase: wordlist size {} does not match required {}",
464 list.len(),
465 len.wordlist_size()
466 )));
467 }
468
469 let total_bits = len.total_bits();
471 let mut bits: Vec<u8> = Vec::new();
472 for (i, &c) in phrase.iter().enumerate() {
473 let idx = list.index_of(c).ok_or_else(|| {
474 CryptoError::InvalidParameter(format!(
475 "codepoint {:?} (U+{:04X}) at position {i} not found in wordlist",
476 c, c as u32
477 ))
478 })? as u32;
479 insert_bits_msb(&mut bits, idx, i * 11, 11);
480 }
481 debug_assert!(bits.len() >= (total_bits + 7) / 8);
482
483 let entropy_bytes_len = len.entropy_bytes();
485 let mut entropy_bytes = vec![0u8; entropy_bytes_len];
486 let entropy_bits = entropy_bytes_len * 8;
487 for i in 0..entropy_bits {
488 let byte_idx = i / 8;
489 let bit_in_byte = 7 - (i % 8);
490 if byte_idx < bits.len() {
491 let bit = (bits[byte_idx] >> bit_in_byte) & 1;
492 entropy_bytes[byte_idx] |= bit << bit_in_byte;
493 }
494 }
495
496 let mut hasher = Sha3_256::new();
498 hasher.update(&entropy_bytes);
499 let got_full: [u8; 32] = hasher.finalize().into();
500
501 let mut expected_val: u32 = 0;
504 let mut got_val: u32 = 0;
505 for i in 0..len.checksum_bits() {
506 let idx = i / 8;
507 let bit_in_byte = 7 - (i % 8);
508 expected_val = (expected_val << 1) | (((got_full[idx] >> bit_in_byte) & 1) as u32);
509 let cs_byte = entropy_bytes_len + idx;
514 debug_assert!(
520 cs_byte < bits.len(),
521 "bits buffer shorter than expected for checksum comparison (entropy_bytes_len={entropy_bytes_len}, cs_byte={cs_byte}, bits.len()={})",
522 bits.len()
523 );
524 got_val = (got_val << 1) | (((bits[cs_byte] >> bit_in_byte) & 1) as u32);
525 }
526 if expected_val != got_val {
527 return Err(CryptoError::ChecksumMismatch {
528 expected: expected_val,
529 got: got_val,
530 });
531 }
532
533 Ok(entropy_bytes)
534}
535
536pub fn encode_phrase_custom(
549 entropy: &[u8],
550 alphabet: &[char],
551 want_words: usize,
552) -> Result<(Vec<char>, EntropyReport)> {
553 if alphabet.is_empty() {
554 return Err(CryptoError::InvalidParameter(
555 "custom alphabet must not be empty".into(),
556 ));
557 }
558 if !alphabet.len().is_power_of_two() {
559 return Err(CryptoError::InvalidParameter(format!(
560 "custom alphabet size {} is not a power of 2 (v0.1 supports 16, 32, 64, 128, 256, 512, 1024, 2048, 4096)",
561 alphabet.len()
562 )));
563 }
564 if want_words == 0 {
565 return Err(CryptoError::InvalidParameter(
566 "want_words must be > 0".into(),
567 ));
568 }
569
570 validate_input_chars(alphabet)?;
571
572 let bits_per_word = (alphabet.len() as u32).trailing_zeros() as usize;
573 let total_bits = bits_per_word * want_words;
574 let total_bits_f = total_bits as f64;
575
576 if entropy.len() * 8 < total_bits {
580 return Err(CryptoError::InvalidParameter(format!(
581 "custom-alphabet phrase needs {total_bits} bits; entropy is only {} bits",
582 entropy.len() * 8
583 )));
584 }
585
586 if total_bits_f < MIN_SAFE_BITS {
587 return Err(CryptoError::InsufficientEntropy {
588 got_bits: total_bits_f,
589 required_min: MIN_SAFE_BITS,
590 });
591 }
592
593 let mut out = Vec::with_capacity(want_words);
596 for i in 0..want_words {
597 let start_bit = i * bits_per_word;
598 let end_bit = (i + 1) * bits_per_word;
599 let idx = extract_bits_msb(entropy, start_bit, end_bit) as usize;
600 let c = alphabet.get(idx).copied().ok_or_else(|| {
601 CryptoError::InvalidParameter(format!(
602 "internal: extracted index {idx} out of alphabet range {}",
603 alphabet.len()
604 ))
605 })?;
606 out.push(c);
607 }
608
609 Ok((
610 out,
611 EntropyReport {
612 total_bits: total_bits_f,
613 is_safe: total_bits_f >= MIN_SAFE_BITS,
614 },
615 ))
616}
617
618#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn phrase_length_shape_constants() {
628 assert_eq!(PhraseLength::Words12.words(), 12);
629 assert_eq!(PhraseLength::Words24.words(), 24);
630 assert_eq!(PhraseLength::Words12.entropy_bytes(), 16);
631 assert_eq!(PhraseLength::Words24.entropy_bytes(), 32);
632 assert_eq!(PhraseLength::Words12.checksum_bits(), 4);
633 assert_eq!(PhraseLength::Words24.checksum_bits(), 8);
634 assert_eq!(PhraseLength::Words12.total_bits(), 132);
635 assert_eq!(PhraseLength::Words24.total_bits(), 264);
636 assert_eq!(PhraseLength::Words12.wordlist_size(), 2048);
637 assert_eq!(PhraseLength::Words24.wordlist_size(), 2048);
638 }
639
640 #[test]
641 fn default_wordlist_has_2048_unique_entries() {
642 let list = UnicodeWordlist::default();
643 assert_eq!(list.len(), 2048);
644 assert_eq!(list.bits_per_word(), 11);
645 let mut sorted: Vec<char> = list.as_slice().to_vec();
646 sorted.sort();
647 let n0 = sorted.len();
648 sorted.dedup();
649 assert_eq!(n0, sorted.len(), "default wordlist has duplicates");
650 }
651
652 #[test]
653 fn default_wordlist_passes_own_filters() {
654 let list = UnicodeWordlist::default();
655 for &c in list.as_slice() {
656 validate_codepoint(c).expect("default wordlist contains a forbidden codepoint");
657 }
658 }
659
660 #[test]
661 fn wordlist_rejects_empty() {
662 let err = UnicodeWordlist::new(&[]).unwrap_err();
663 assert!(matches!(err, CryptoError::InvalidParameter(_)));
664 }
665
666 #[test]
667 fn wordlist_rejects_non_power_of_two() {
668 let chars: Vec<char> = (0u32..100)
669 .map(|i| char::from_u32(0xE000 + i).unwrap())
670 .collect();
671 let err = UnicodeWordlist::new(&chars).unwrap_err();
672 assert!(matches!(err, CryptoError::InvalidParameter(_)));
673 }
674
675 #[test]
676 fn wordlist_rejects_duplicates() {
677 let chars: Vec<char> = vec!['a', 'a', 'b', 'c']; let err = UnicodeWordlist::new(&chars).unwrap_err();
679 assert!(matches!(err, CryptoError::InvalidParameter(_)));
681 }
682
683 #[test]
684 fn encode_decode_roundtrip_12() {
685 let entropy = [0xABu8; 16];
686 let phrase =
687 encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words12).unwrap();
688 assert_eq!(phrase.len(), 12);
689 let decoded = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap();
690 assert_eq!(decoded, entropy);
691 }
692
693 #[test]
694 fn encode_decode_roundtrip_24() {
695 let entropy = [0x42u8; 32];
696 let phrase =
697 encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words24).unwrap();
698 assert_eq!(phrase.len(), 24);
699 let decoded = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap();
700 assert_eq!(decoded, entropy);
701 }
702
703 #[test]
704 fn encode_decode_various_entropies() {
705 for byte in 0u8..=8 {
708 let entropy = [byte; 32];
709 let phrase =
710 encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words24)
711 .unwrap();
712 assert_eq!(phrase.len(), 24);
713 let round = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap();
714 assert_eq!(round, entropy);
715 }
716 }
717
718 #[test]
719 fn encode_rejects_wrong_entropy_length() {
720 let err = encode_phrase(
721 &[1u8; 16],
722 &UnicodeWordlist::default(),
723 PhraseLength::Words24,
724 )
725 .unwrap_err();
726 assert!(matches!(
727 err,
728 CryptoError::InvalidKeyLength {
729 algorithm: "UnicodeCipher",
730 expected: 32,
731 got: 16,
732 }
733 ));
734 }
735
736 #[test]
737 fn decode_rejects_unexpected_phrase_length() {
738 let phrase: Vec<char> = std::iter::repeat('─').take(15).collect();
739 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
740 assert!(matches!(err, CryptoError::InvalidParameter(_)));
741 }
742
743 #[test]
744 fn decode_rejects_checksum_tamper() {
745 let entropy = [7u8; 32];
746 let mut phrase =
747 encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words24).unwrap();
748 let list = UnicodeWordlist::default();
750 let new_c = list.get(1000).unwrap();
751 phrase[5] = new_c;
752 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
753 assert!(matches!(err, CryptoError::ChecksumMismatch { .. }));
754 }
755
756 #[test]
757 fn decoder_rejects_zwj() {
758 let phrase: Vec<char> = std::iter::repeat('\u{200D}').take(24).collect();
759 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
760 assert!(matches!(
761 err,
762 CryptoError::RejectedCodepointClass {
763 codepoint: '\u{200D}',
764 class: "ZWJ",
765 }
766 ));
767 }
768
769 #[test]
770 fn decoder_rejects_variation_selector() {
771 for ch in ['\u{FE0E}', '\u{FE0F}'] {
772 let phrase: Vec<char> = std::iter::repeat(ch).take(24).collect();
773 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
774 assert!(matches!(
775 err,
776 CryptoError::RejectedCodepointClass {
777 class: "VARIATION_SELECTOR",
778 ..
779 }
780 ));
781 }
782 }
783
784 #[test]
785 fn decoder_rejects_combining_mark() {
786 let phrase: Vec<char> = std::iter::repeat('\u{0301}').take(24).collect();
787 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
788 assert!(matches!(
789 err,
790 CryptoError::RejectedCodepointClass {
791 class: "COMBINING_MARK",
792 ..
793 }
794 ));
795 }
796
797 #[test]
798 fn decoder_rejects_bidi_mark() {
799 for ch in ['\u{200E}', '\u{200F}'] {
800 let phrase: Vec<char> = std::iter::repeat(ch).take(24).collect();
801 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
802 assert!(matches!(
803 err,
804 CryptoError::RejectedCodepointClass {
805 class: "BIDI_MARK",
806 ..
807 }
808 ));
809 }
810 }
811
812 #[test]
813 fn decoder_rejects_default_ignorable() {
814 let phrase: Vec<char> = std::iter::repeat('\u{200B}').take(24).collect();
815 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
816 assert!(matches!(
817 err,
818 CryptoError::RejectedCodepointClass {
819 class: "DEFAULT_IGNORABLE",
820 ..
821 }
822 ));
823 }
824
825 #[test]
826 fn decoder_rejects_control_char() {
827 let phrase: Vec<char> = std::iter::repeat('\u{0000}').take(24).collect();
828 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
829 assert!(matches!(
830 err,
831 CryptoError::RejectedCodepointClass {
832 class: "CONTROL",
833 ..
834 }
835 ));
836 }
837
838 #[test]
839 fn custom_alphabet_power_of_2_roundtrip_1024() {
840 let alphabet: Vec<char> = (0u32..1024)
841 .map(|i| char::from_u32(0xE000 + i).unwrap())
842 .collect();
843 let entropy = [0xCDu8; 30];
845 let (phrase, report) = encode_phrase_custom(&entropy, &alphabet, 24).unwrap();
846 assert_eq!(phrase.len(), 24);
847 assert_eq!(report.total_bits, 24.0 * 10.0);
848 assert!(report.is_safe);
849
850 let mut bits = vec![0u8; entropy.len()];
852 let list = UnicodeWordlist::new(&alphabet).unwrap();
853 let bits_per_word = list.bits_per_word() as usize;
854 for (i, &c) in phrase.iter().enumerate() {
855 let idx = list.index_of(c).unwrap() as u32;
856 for j in 0..bits_per_word {
858 let bit = ((idx >> (bits_per_word - 1 - j)) & 1) as u8;
859 let byte_idx = (i * bits_per_word + j) / 8;
860 let bit_in_byte = 7 - ((i * bits_per_word + j) % 8);
861 if bit == 1 {
862 bits[byte_idx] |= 1 << bit_in_byte;
863 }
864 }
865 }
866 assert_eq!(bits, entropy);
867 }
868
869 #[test]
870 fn custom_alphabet_floor_rejection_16() {
871 let alphabet: Vec<char> = (0u32..16)
873 .map(|i| char::from_u32(0xE000 + i).unwrap())
874 .collect();
875 let entropy = [0u8; 16];
876 let err = encode_phrase_custom(&entropy, &alphabet, 12).unwrap_err();
877 assert!(matches!(
878 err,
879 CryptoError::InsufficientEntropy {
880 got_bits,
881 required_min
882 } if got_bits < required_min
883 ));
884 }
885
886 #[test]
887 fn custom_alphabet_non_power_of_two_rejected() {
888 let alphabet: Vec<char> = (0u32..100)
889 .map(|i| char::from_u32(0xE000 + i).unwrap())
890 .collect();
891 let entropy = [0u8; 64];
892 let err = encode_phrase_custom(&entropy, &alphabet, 24).unwrap_err();
893 assert!(matches!(err, CryptoError::InvalidParameter(_)));
894 }
895
896 #[test]
897 fn custom_alphabet_rejects_zwj_in_alphabet() {
898 let mut alphabet: Vec<char> = (0u32..16)
899 .map(|i| char::from_u32(0xE000 + i).unwrap())
900 .collect();
901 alphabet[3] = '\u{200D}'; let entropy = [0u8; 32];
903 let err = encode_phrase_custom(&entropy, &alphabet, 24).unwrap_err();
904 assert!(matches!(
905 err,
906 CryptoError::RejectedCodepointClass { class: "ZWJ", .. }
907 ));
908 }
909
910 #[test]
911 fn extract_and_insert_bits_roundtrip() {
912 let src: [u8; 32] = [
917 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54,
918 0x32, 0x10, 0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x11, 0x22, 0x33,
919 0x44, 0x55, 0x66, 0x77,
920 ];
921 let mut dst: Vec<u8> = vec![0u8; 32];
922 let bits_per_word = 11usize;
923 let n_words = 24;
924 for i in 0..n_words {
927 let start = i * bits_per_word;
928 let end = start + bits_per_word;
929 let v = extract_bits_msb(&src, start, end);
930 insert_bits_msb(&mut dst, v, start, bits_per_word);
931 }
932 for i in 0..n_words {
933 let start = i * bits_per_word;
934 let end = start + bits_per_word;
935 let from_src = extract_bits_msb(&src, start, end);
936 let from_dst = extract_bits_msb(&dst, start, end);
937 assert_eq!(from_src, from_dst, "round-trip bit mismatch at chunk {i}");
938 }
939 }
940
941 #[test]
942 fn encode_decode_roundtrip_zero_24() {
943 let entropy = [0u8; 32];
944 let phrase =
945 encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words24).unwrap();
946 let decoded = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap();
947 assert_eq!(decoded, entropy);
948 }
949
950 #[test]
951 fn decode_rejects_checksum_tamper_12() {
952 let entropy = [0x33u8; 16];
953 let mut phrase =
954 encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words12).unwrap();
955 let list = UnicodeWordlist::default();
956 let new_c = list.get(1000).unwrap();
957 phrase[4] = new_c;
958 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
959 assert!(matches!(err, CryptoError::ChecksumMismatch { .. }));
960 }
961
962 #[test]
963 fn decoder_rejects_arabic_letter_mark() {
964 let phrase: Vec<char> = std::iter::repeat('\u{061C}').take(24).collect();
965 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
966 assert!(matches!(
967 err,
968 CryptoError::RejectedCodepointClass {
969 class: "BIDI_MARK",
970 ..
971 }
972 ));
973 }
974
975 #[test]
976 fn decoder_rejects_mongolian_vowel_sep() {
977 let phrase: Vec<char> = std::iter::repeat('\u{180E}').take(24).collect();
978 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
979 assert!(matches!(
980 err,
981 CryptoError::RejectedCodepointClass {
982 class: "DEFAULT_IGNORABLE",
983 ..
984 }
985 ));
986 }
987
988 #[test]
989 fn decoder_rejects_bidi_isolate() {
990 let phrase: Vec<char> = std::iter::repeat('\u{2066}').take(24).collect();
991 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
992 assert!(matches!(
993 err,
994 CryptoError::RejectedCodepointClass {
995 class: "BIDI_MARK",
996 ..
997 }
998 ));
999 }
1000
1001 #[test]
1002 fn decoder_rejects_combined_valid_and_zwj() {
1003 let mut phrase: Vec<char> = UnicodeWordlist::default()
1007 .as_slice()
1008 .iter()
1009 .take(12)
1010 .copied()
1011 .collect();
1012 phrase.extend(std::iter::repeat('\u{200D}').take(12));
1013 let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
1014 assert!(matches!(
1015 err,
1016 CryptoError::RejectedCodepointClass {
1017 codepoint: '\u{200D}',
1018 class: "ZWJ",
1019 }
1020 ));
1021 }
1022
1023 #[test]
1024 fn custom_alphabet_rejects_short_entropy() {
1025 let alphabet: Vec<char> = (0u32..256)
1026 .map(|i| char::from_u32(0xE000 + i).unwrap())
1027 .collect();
1028 let short_entropy = [0u8; 16];
1030 let err = encode_phrase_custom(&short_entropy, &alphabet, 24).unwrap_err();
1031 assert!(matches!(err, CryptoError::InvalidParameter(_)));
1032 }
1033}