Skip to main content

origin_crypto_sdk/recovery/
unicode_cipher.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Unicode-based recovery phrases.
4//!
5//! Mirrors BIP-39's algorithm shape (11-bit word indices into a fixed
6//! 2048-entry wordlist, plus a truncated SHA-3-256 checksum) but uses a
7//! curated set of Unicode codepoints instead of an English wordlist.
8//!
9//! ## Why NOT BIP-39 compatible?
10//!
11//! Deliberately not BIP-39 compatible. BIP-39 uses an English wordlist
12//! and SHA-256; we use codepoints and SHA-3-256 to match the SDK's
13//! "prefer SHA-3" defaults. Phrases generated here are NOT recoverable
14//! by any other wallet. Recovery requires this module.
15//!
16//! ## Why use this?
17//!
18//! * Wordlist is Unicode codepoints — universally renderable on phones,
19//!   desktops, terminals (no font fragility)
20//! * No ZWJ / no skin-tone modifiers / no combining marks / no BIDI
21//!   markers — the curated wordlist has been filtered for normalization
22//!   safety; the decoder rejects non-canonical input
23//! * Codepoints are visually disjoint by Unicode-committee design
24//!   (geometric shapes, math operators, arrows, etc.)
25//! * SHA-3-256 is the SDK's preferred hash family
26//!
27//! ## Quick start
28//!
29//! ```ignore
30//! use origin_crypto_sdk::recovery::unicode_cipher::{
31//!     encode_phrase, decode_phrase, PhraseLength, UnicodeWordlist,
32//! };
33//!
34//! let entropy = [0x42u8; 32];     // 256-bit entropy for 24-word
35//! let phrase = encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words24)
36//!     .expect("valid entropy");
37//! assert_eq!(phrase.len(), 24);
38//! let decoded = decode_phrase(&phrase, &UnicodeWordlist::default()).expect("valid phrase");
39//! assert_eq!(decoded, entropy);
40//! ```
41//!
42//! ## Security notes
43//!
44//! * **Strict NFKC enforcement**: `decode_phrase` rejects any codepoint
45//!   whose NFKC canonical form differs from itself. We do NOT silently
46//!   canonicalize — that would enable malleability attacks.
47//! * **Default wordlist**: 2048 codepoints curated by
48//!   `scripts/generate_wordlist.py`. Power-of-2 sized so 11-bit
49//!   indexing works.
50//! * **Custom alphabets** must be power-of-2 sized in v0.1;
51//!   arbitrary sizes require a BigInt library and are deferred to v0.2.
52//! * **Truncated checksum is short**: 4 bits for 12-word, 8 bits for
53//!   24-word. Catches random typos with probability 1/16 and 1/256
54//!   respectively but is NOT a substitute for verifying the full
55//!   phrase via `decode_phrase` round-trip.
56//! * **No transport-layer NFKC normalization**: This codec matches phrase
57//!   codepoints to wordlist entries *exactly* (byte-for-byte), NOT by
58//!   Unicode canonical form. Recovery phrases must be transported
59//!   byte-faithful. Do NOT pass them through:
60//!   - text editors with smart-quote substitution or autocorrect,
61//!   - normalization pipelines (NFC, NFD, NFKC, NFKD),
62//!   - QR encoders or IME composers that substitute visually-equivalent
63//!     glyphs (e.g. `ⓕ` U+24D5 ↔ `f` U+0066).
64//!   Round-tripping critically assumes the codepoints in the encoded
65//!   phrase are bit-for-bit identical to those the encoder selected.
66//!   If you need NFKC-stable matching (a looser but more permissive
67//!   semantic), implement it in a separate decoder wrapper — DO NOT
68//!   add normalization to this module without also regenerating the
69//!   curated wordlist for the new matching policy.
70
71use crate::error::{CryptoError, Result};
72use sha3::{Digest, Sha3_256};
73use std::sync::LazyLock;
74use unicode_normalization::UnicodeNormalization;
75
76/// Allowed recovery phrase shapes.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum PhraseLength {
79    /// 12 words — 16 bytes entropy (128 bits) + 4 bits checksum = 132 bits / 11.
80    Words12,
81    /// 24 words — 32 bytes entropy (256 bits) + 8 bits checksum = 264 bits / 11.
82    Words24,
83}
84
85impl PhraseLength {
86    /// Number of bytes of entropy required for this length.
87    pub fn entropy_bytes(self) -> usize {
88        match self {
89            PhraseLength::Words12 => 16,
90            PhraseLength::Words24 => 32,
91        }
92    }
93
94    /// Number of checksum bits (= `entropy_bits / 32`).
95    pub fn checksum_bits(self) -> usize {
96        self.entropy_bytes() * 8 / 32
97    }
98
99    /// Total bits emitted (entropy + checksum); always a multiple of 11.
100    pub fn total_bits(self) -> usize {
101        self.entropy_bytes() * 8 + self.checksum_bits()
102    }
103
104    /// Number of characters in the resulting phrase.
105    pub fn words(self) -> usize {
106        self.total_bits() / 11
107    }
108
109    /// Wordlist size required for this phrase length (always 2048).
110    pub fn wordlist_size(self) -> usize {
111        2048
112    }
113}
114
115/// The SDK's curated default wordlist, embedded at compile time.
116///
117/// Generated by `scripts/generate_wordlist.py`. The file MUST contain
118/// exactly 2048 codepoints — enforced by an assertion at FIRST USE
119/// (not at compile time, because `include_str!` happens before that).
120const DEFAULT_WORDLIST_STR: &str = include_str!("default_wordlist.txt");
121const DEFAULT_WORDLIST_LEN: usize = 2048;
122
123/// A static, fixed-size wordlist of Unicode codepoints used as a
124/// recovery phrase alphabet.
125///
126/// The default wordlist ships with the SDK and is curated by
127/// `scripts/generate_wordlist.py`. Custom lists can be supplied for
128/// power-of-2 sized alphabets in v0.1 (see
129/// [`encode_phrase_custom`]).
130#[derive(Debug, Clone)]
131pub struct UnicodeWordlist {
132    entries: Box<[char]>,
133}
134
135impl UnicodeWordlist {
136    /// Build a wordlist from a slice of codepoints.
137    ///
138    /// # Errors
139    /// * `InvalidParameter` if `entries` is empty.
140    /// * `InvalidParameter` if `entries.len()` is not a power of 2
141    ///   (only power-of-2 sizes are supported in v0.1).
142    /// * `InvalidParameter` if `entries` contains duplicates.
143    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    /// Number of codepoints in the wordlist. Always a power of 2.
171    pub fn len(&self) -> usize {
172        self.entries.len()
173    }
174
175    /// True iff the wordlist has zero entries.
176    pub fn is_empty(&self) -> bool {
177        self.entries.is_empty()
178    }
179
180    /// Borrow the underlying slice of codepoints.
181    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    /// Number of bits per word (= log2(`len`)).
194    pub fn bits_per_word(&self) -> u32 {
195        // entries.len() is a power of 2; log2 is well-defined.
196        (self.entries.len() as u32).trailing_zeros()
197    }
198
199    /// Look up a codepoint by index.
200    pub fn get(&self, index: usize) -> Option<char> {
201        self.entries.get(index).copied()
202    }
203
204    /// Find the index of a codepoint. Returns `None` if absent.
205    pub fn index_of(&self, codepoint: char) -> Option<usize> {
206        self.entries.iter().position(|&c| c == codepoint)
207    }
208}
209
210/// The SDK's default 2048-codepoint wordlist.
211///
212/// Parsed from `default_wordlist.txt` at first use (cached for the
213/// process lifetime via [`std::sync::LazyLock`]).
214pub 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/// Report produced alongside custom-alphabet phrases.
226#[derive(Debug, Clone, PartialEq)]
227pub struct EntropyReport {
228    /// Total entropy bits encoded: `want_words × log2(alphabet.len())`.
229    pub total_bits: f64,
230    /// True iff `total_bits >= 128` (the SDK's custom-mode floor).
231    pub is_safe: bool,
232}
233
234/// Minimum entropy bits the custom-alphabet API will produce.
235/// Below this, `encode_phrase_custom` returns
236/// [`CryptoError::InsufficientEntropy`].
237pub const MIN_SAFE_BITS: f64 = 128.0;
238
239// ---------------------------------------------------------------------------
240// Bit-stream helpers
241// ---------------------------------------------------------------------------
242
243/// Extract bits `[start_bit, end_bit)` MSB-first, return as right-aligned.
244///
245/// Bits numbered 0 = MSB of byte 0; `bit_in_byte = 7 - (bit_pos % 8)`.
246/// Bits beyond the input buffer are treated as 0.
247fn 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
265/// Insert `n_bits` bits of `value` (high bits first) into `bytes` at
266/// bit position `start_bit`. Grows `bytes` as required.
267fn 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
282// ---------------------------------------------------------------------------
283// Codepoint rejection
284// ---------------------------------------------------------------------------
285
286/// Classify a codepoint into a forbidden class, if any.
287///
288/// Rejects: ZWJ, Variation Selectors, Combining Diacritical Marks,
289/// BIDI marks, default-ignorable invisibles, control characters.
290fn 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    // U+180E MONGOLIAN VOWEL SEPARATOR (default-ignorable)
311    if cp == 0x180E {
312        return Some("DEFAULT_IGNORABLE");
313    }
314    // U+206A..=U+206F additional BIDI / format controls (Arabic,
315    // Persian, Mongolian block-inverts etc.)
316    if (0x206A..=0x206F).contains(&cp) {
317        return Some("BIDI_MARK");
318    }
319    // U+061C ARABIC LETTER MARK
320    if cp == 0x061C {
321        return Some("BIDI_MARK");
322    }
323    if c.is_control() {
324        return Some("CONTROL");
325    }
326    None
327}
328
329/// Reject a codepoint whose NFKC canonical form decomposes into MULTIPLE
330/// codepoints.
331///
332/// A multi-codepoint NFKC decomposition (e.g., ligatures that split into
333/// base + combining mark) means the user could type the same visual
334/// symbol using two different byte sequences — silent recovery failure.
335///
336/// A single-codepoint NFKC that maps to a *different* single codepoint
337/// (e.g., `ⓕ` U+24D5 ↔ `f` U+0066) is NOT rejected because in our curated
338/// non-Latin default wordlist the NFKC partner is absent, so a manual
339/// typing collision is not realizable.
340fn 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
375// ---------------------------------------------------------------------------
376// Public API
377// ---------------------------------------------------------------------------
378
379/// Encode `len`-word recovery phrase from raw entropy.
380///
381/// # Arguments
382/// * `entropy` — exactly `len.entropy_bytes()` bytes.
383/// * `list` — the wordlist; must have 2048 entries for [`PhraseLength::Words12`] /
384///   [`PhraseLength::Words24`].
385/// * `len` — required phrase length.
386///
387/// # Errors
388/// * [`CryptoError::InvalidKeyLength`] if `entropy.len()` mismatches `len`.
389/// * [`CryptoError::InvalidParameter`] if `list.len() != 2048`.
390/// * [`CryptoError::InvalidParameter`] if `list` is not internally valid.
391pub 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    // Compute SHA-3-256 of the entropy (full 32 bytes; we read the
412    // leading `checksum_bits` bits only).
413    let mut hasher = Sha3_256::new();
414    hasher.update(entropy);
415    let checksum_full: [u8; 32] = hasher.finalize().into();
416
417    // Build combined bit-stream: entropy || checksum (top bits first).
418    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
439/// Decode a phrase back to entropy bytes.
440///
441/// # Errors
442/// * [`CryptoError::InvalidParameter`] if `phrase.len() != 12 && != 24`
443///   (no length field is stored in the phrase).
444/// * [`CryptoError::RejectedCodepointClass`] / [`CryptoError::InvalidNormalization`]
445///   if any char fails normalization or class checks.
446/// * [`CryptoError::InvalidParameter`] if a codepoint is absent from the wordlist.
447/// * [`CryptoError::ChecksumMismatch`] if the truncated checksum does not match.
448pub 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    // Reconstruct the combined bit-stream from phrase indices.
470    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    // Extract entropy bytes (top `entropy_bits` bits).
484    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    // Recompute SHA-3-256 checksum and compare top `checksum_bits` bits.
497    let mut hasher = Sha3_256::new();
498    hasher.update(&entropy_bytes);
499    let got_full: [u8; 32] = hasher.finalize().into();
500
501    // Compare only the top `checksum_bits` bits.
502    // (We reconstruct from the reconstructed entropy; expected = same.)
503    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        // We don't actually have an "expected" independent of the
510        // entropy — by construction SHA-3-256(entropy) is our
511        // checksum. We compare against the bits decoded from the
512        // phrase's checksum tail.
513        let cs_byte = entropy_bytes_len + idx;
514        // `bits` is grown by `insert_bits_msb` to fit all `total_bits` (264 for
515        // Words24, 132 for Words12), so `bits.len()` is always >= entropy_bytes_len + 1.
516        // The conditional that previously guarded this indexing was dead defensive
517        // code (always in bounds for both phrase lengths); replaced with a
518        // debug_assert so miscalculations surface in tests.
519        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
536/// Encode entropy using a custom alphabet.
537///
538/// Power-of-2 sized alphabets only (16, 32, 64, ..., 4096) in v0.1.
539/// The resulting entropy bits are reported in [`EntropyReport`] and
540/// the function refuses to produce phrases below 128-bit entropy.
541///
542/// # Errors
543/// * [`CryptoError::InvalidParameter`] if `alphabet` is empty or its
544///   size is not a power of 2.
545/// * [`CryptoError::InsufficientEntropy`] if `want_words × log2(|A|) < 128`.
546/// * [`CryptoError::RejectedCodepointClass`] / [`CryptoError::InvalidNormalization`]
547///   if any codepoint fails the validation policy.
548pub 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    // Reject short entropy: extracting bits beyond the buffer would
577    // silently substitute zeros and produce a wrong phrase. The custom
578    // mode has no checksum so the user will not catch this later.
579    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    // Custom alphabet doesn't include a checksum in v0.1 — the user is
594    // taking on that responsibility by accepting a different format.
595    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// ---------------------------------------------------------------------------
619// Tests
620// ---------------------------------------------------------------------------
621
622#[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']; // not power-of-2 anyway
678        let err = UnicodeWordlist::new(&chars).unwrap_err();
679        // This attempt is non-power-of-2 and duplicate; we report the power-of-2 failure first.
680        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        // Pattern-check across lots of deterministic inputs (using the
706        // SDK's test_utils, NOT a real RNG).
707        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        // Pick a different in-wordlist codepoint and substitute it.
749        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        // 24 words × 10 bits = 240 bits. Use 30 bytes of entropy.
844        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        // Manually decode to validate the algorithm.
851        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            // Insert bits at position i * bits_per_word .. + bits_per_word
857            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        // 16-char alphabet × 12 words = 12 × 4 = 48 bits. Below 128-bit floor.
872        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}'; // ZWJ inside alphabet
902        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        // Use TWO disjoint buffers: read from `src`, write into `dst`
913        // starting at an offset, then verify the dst window contains
914        // the same bits we extracted. Replaces the tautological
915        // "read/write same bit positions" version.
916        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        // We splice extracted bits back in at the SAME positions — but
925        // verify by extracting again from dst and comparing.
926        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        // First 12 chars are valid wordlist entries; remaining 12 are
1004        // ZWJ. Validator short-circuits at the first rejection, which
1005        // should be index 12.
1006        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        // 24 words × 8 bits = 192 bits. Provide only 16 bytes (128 bits).
1029        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}