use crate::error::{CryptoError, Result};
use sha3::{Digest, Sha3_256};
use std::sync::LazyLock;
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhraseLength {
Words12,
Words24,
}
impl PhraseLength {
pub fn entropy_bytes(self) -> usize {
match self {
PhraseLength::Words12 => 16,
PhraseLength::Words24 => 32,
}
}
pub fn checksum_bits(self) -> usize {
self.entropy_bytes() * 8 / 32
}
pub fn total_bits(self) -> usize {
self.entropy_bytes() * 8 + self.checksum_bits()
}
pub fn words(self) -> usize {
self.total_bits() / 11
}
pub fn wordlist_size(self) -> usize {
2048
}
}
const DEFAULT_WORDLIST_STR: &str = include_str!("default_wordlist.txt");
const DEFAULT_WORDLIST_LEN: usize = 2048;
#[derive(Debug, Clone)]
pub struct UnicodeWordlist {
entries: Box<[char]>,
}
impl UnicodeWordlist {
pub fn new(entries: &[char]) -> Result<Self> {
if entries.is_empty() {
return Err(CryptoError::InvalidParameter(
"wordlist must not be empty".into(),
));
}
if !entries.len().is_power_of_two() {
return Err(CryptoError::InvalidParameter(format!(
"wordlist size {} is not a power of 2 \
(v0.1 supports 16, 32, 64, 128, 256, 512, 1024, 2048, 4096)",
entries.len()
)));
}
let mut seen = std::collections::HashSet::new();
for &c in entries {
if !seen.insert(c) {
return Err(CryptoError::InvalidParameter(format!(
"duplicate codepoint U+{:04X} in wordlist",
c as u32
)));
}
}
Ok(Self {
entries: entries.to_vec().into_boxed_slice(),
})
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn as_slice(&self) -> &[char] {
&self.entries
}
}
impl Default for UnicodeWordlist {
fn default() -> Self {
DEFAULT_WORDLIST.clone()
}
}
impl UnicodeWordlist {
pub fn bits_per_word(&self) -> u32 {
(self.entries.len() as u32).trailing_zeros()
}
pub fn get(&self, index: usize) -> Option<char> {
self.entries.get(index).copied()
}
pub fn index_of(&self, codepoint: char) -> Option<usize> {
self.entries.iter().position(|&c| c == codepoint)
}
}
pub static DEFAULT_WORDLIST: LazyLock<UnicodeWordlist> = LazyLock::new(|| {
let chars: Vec<char> = DEFAULT_WORDLIST_STR.chars().collect();
assert_eq!(
chars.len(),
DEFAULT_WORDLIST_LEN,
"default_wordlist.txt must contain exactly {DEFAULT_WORDLIST_LEN} codepoints, got {}",
chars.len()
);
UnicodeWordlist::new(&chars).expect("default wordlist is internally valid (power-of-2, dedup)")
});
#[derive(Debug, Clone, PartialEq)]
pub struct EntropyReport {
pub total_bits: f64,
pub is_safe: bool,
}
pub const MIN_SAFE_BITS: f64 = 128.0;
fn extract_bits_msb(bytes: &[u8], start_bit: usize, end_bit: usize) -> u32 {
debug_assert!(start_bit <= end_bit);
let n = end_bit - start_bit;
let mut out: u32 = 0;
for i in 0..n {
let bit_pos = start_bit + i;
let byte_idx = bit_pos / 8;
let bit_in_byte = 7 - (bit_pos % 8);
let bit: u32 = if byte_idx < bytes.len() {
((bytes[byte_idx] >> bit_in_byte) & 1).into()
} else {
0
};
out = (out << 1) | bit;
}
out
}
fn insert_bits_msb(bytes: &mut Vec<u8>, value: u32, start_bit: usize, n_bits: usize) {
for i in 0..n_bits {
let bit_pos = start_bit + i;
let byte_idx = bit_pos / 8;
let bit_in_byte = 7 - (bit_pos % 8);
let bit = ((value >> (n_bits - 1 - i)) & 1) as u8;
while bytes.len() <= byte_idx {
bytes.push(0);
}
if bit == 1 {
bytes[byte_idx] |= 1 << bit_in_byte;
}
}
}
fn reject_class(c: char) -> Option<&'static str> {
let cp = c as u32;
if cp == 0x200D {
return Some("ZWJ");
}
if (0xFE00..=0xFE0F).contains(&cp) {
return Some("VARIATION_SELECTOR");
}
if (0x0300..=0x036F).contains(&cp) {
return Some("COMBINING_MARK");
}
if cp == 0x200E || cp == 0x200F {
return Some("BIDI_MARK");
}
if (0x202A..=0x202E).contains(&cp) || (0x2066..=0x2069).contains(&cp) {
return Some("BIDI_MARK");
}
if cp == 0x200B || cp == 0x200C || cp == 0xFEFF || (0x2060..=0x2064).contains(&cp) {
return Some("DEFAULT_IGNORABLE");
}
if cp == 0x180E {
return Some("DEFAULT_IGNORABLE");
}
if (0x206A..=0x206F).contains(&cp) {
return Some("BIDI_MARK");
}
if cp == 0x061C {
return Some("BIDI_MARK");
}
if c.is_control() {
return Some("CONTROL");
}
None
}
fn reject_non_canonical(c: char) -> Option<&'static str> {
let mut multi = 0usize;
for _x in c.nfkc() {
multi += 1;
}
if multi != 1 {
Some("NFKC canonical form has multiple codepoints (silent malleability vector)")
} else {
None
}
}
fn validate_codepoint(c: char) -> Result<()> {
if let Some(class) = reject_class(c) {
return Err(CryptoError::RejectedCodepointClass {
codepoint: c,
class,
});
}
if let Some(reason) = reject_non_canonical(c) {
return Err(CryptoError::InvalidNormalization {
codepoint: c,
reason,
});
}
Ok(())
}
fn validate_input_chars(chars: &[char]) -> Result<()> {
for &c in chars {
validate_codepoint(c)?;
}
Ok(())
}
pub fn encode_phrase(
entropy: &[u8],
list: &UnicodeWordlist,
len: PhraseLength,
) -> Result<Vec<char>> {
if entropy.len() != len.entropy_bytes() {
return Err(CryptoError::InvalidKeyLength {
algorithm: "UnicodeCipher",
expected: len.entropy_bytes(),
got: entropy.len(),
});
}
if list.len() != len.wordlist_size() {
return Err(CryptoError::InvalidParameter(format!(
"encode_phrase: wordlist size {} does not match required {}",
list.len(),
len.wordlist_size()
)));
}
let mut hasher = Sha3_256::new();
hasher.update(entropy);
let checksum_full: [u8; 32] = hasher.finalize().into();
let mut combined = entropy.to_vec();
let cs_bytes = (len.checksum_bits() + 7) / 8;
combined.extend_from_slice(&checksum_full[..cs_bytes]);
let n_words = len.words();
let mut out = Vec::with_capacity(n_words);
for i in 0..n_words {
let start_bit = i * 11;
let end_bit = start_bit + 11;
let idx = extract_bits_msb(&combined, start_bit, end_bit) as usize;
let c = list.get(idx).ok_or_else(|| {
CryptoError::InvalidParameter(format!(
"internal: extracted index {idx} out of wordlist range {}",
list.len()
))
})?;
out.push(c);
}
Ok(out)
}
pub fn decode_phrase(phrase: &[char], list: &UnicodeWordlist) -> Result<Vec<u8>> {
let len = match phrase.len() {
12 => PhraseLength::Words12,
24 => PhraseLength::Words24,
n => {
return Err(CryptoError::InvalidParameter(format!(
"phrase length {n} is not 12 or 24 (cannot disambiguate without length field)"
)))
}
};
validate_input_chars(phrase)?;
if list.len() != len.wordlist_size() {
return Err(CryptoError::InvalidParameter(format!(
"decode_phrase: wordlist size {} does not match required {}",
list.len(),
len.wordlist_size()
)));
}
let total_bits = len.total_bits();
let mut bits: Vec<u8> = Vec::new();
for (i, &c) in phrase.iter().enumerate() {
let idx = list.index_of(c).ok_or_else(|| {
CryptoError::InvalidParameter(format!(
"codepoint {:?} (U+{:04X}) at position {i} not found in wordlist",
c, c as u32
))
})? as u32;
insert_bits_msb(&mut bits, idx, i * 11, 11);
}
debug_assert!(bits.len() >= (total_bits + 7) / 8);
let entropy_bytes_len = len.entropy_bytes();
let mut entropy_bytes = vec![0u8; entropy_bytes_len];
let entropy_bits = entropy_bytes_len * 8;
for i in 0..entropy_bits {
let byte_idx = i / 8;
let bit_in_byte = 7 - (i % 8);
if byte_idx < bits.len() {
let bit = (bits[byte_idx] >> bit_in_byte) & 1;
entropy_bytes[byte_idx] |= bit << bit_in_byte;
}
}
let mut hasher = Sha3_256::new();
hasher.update(&entropy_bytes);
let got_full: [u8; 32] = hasher.finalize().into();
let mut expected_val: u32 = 0;
let mut got_val: u32 = 0;
for i in 0..len.checksum_bits() {
let idx = i / 8;
let bit_in_byte = 7 - (i % 8);
expected_val = (expected_val << 1) | (((got_full[idx] >> bit_in_byte) & 1) as u32);
let cs_byte = entropy_bytes_len + idx;
debug_assert!(
cs_byte < bits.len(),
"bits buffer shorter than expected for checksum comparison (entropy_bytes_len={entropy_bytes_len}, cs_byte={cs_byte}, bits.len()={})",
bits.len()
);
got_val = (got_val << 1) | (((bits[cs_byte] >> bit_in_byte) & 1) as u32);
}
if expected_val != got_val {
return Err(CryptoError::ChecksumMismatch {
expected: expected_val,
got: got_val,
});
}
Ok(entropy_bytes)
}
pub fn encode_phrase_custom(
entropy: &[u8],
alphabet: &[char],
want_words: usize,
) -> Result<(Vec<char>, EntropyReport)> {
if alphabet.is_empty() {
return Err(CryptoError::InvalidParameter(
"custom alphabet must not be empty".into(),
));
}
if !alphabet.len().is_power_of_two() {
return Err(CryptoError::InvalidParameter(format!(
"custom alphabet size {} is not a power of 2 (v0.1 supports 16, 32, 64, 128, 256, 512, 1024, 2048, 4096)",
alphabet.len()
)));
}
if want_words == 0 {
return Err(CryptoError::InvalidParameter(
"want_words must be > 0".into(),
));
}
validate_input_chars(alphabet)?;
let bits_per_word = (alphabet.len() as u32).trailing_zeros() as usize;
let total_bits = bits_per_word * want_words;
let total_bits_f = total_bits as f64;
if entropy.len() * 8 < total_bits {
return Err(CryptoError::InvalidParameter(format!(
"custom-alphabet phrase needs {total_bits} bits; entropy is only {} bits",
entropy.len() * 8
)));
}
if total_bits_f < MIN_SAFE_BITS {
return Err(CryptoError::InsufficientEntropy {
got_bits: total_bits_f,
required_min: MIN_SAFE_BITS,
});
}
let mut out = Vec::with_capacity(want_words);
for i in 0..want_words {
let start_bit = i * bits_per_word;
let end_bit = (i + 1) * bits_per_word;
let idx = extract_bits_msb(entropy, start_bit, end_bit) as usize;
let c = alphabet.get(idx).copied().ok_or_else(|| {
CryptoError::InvalidParameter(format!(
"internal: extracted index {idx} out of alphabet range {}",
alphabet.len()
))
})?;
out.push(c);
}
Ok((
out,
EntropyReport {
total_bits: total_bits_f,
is_safe: total_bits_f >= MIN_SAFE_BITS,
},
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phrase_length_shape_constants() {
assert_eq!(PhraseLength::Words12.words(), 12);
assert_eq!(PhraseLength::Words24.words(), 24);
assert_eq!(PhraseLength::Words12.entropy_bytes(), 16);
assert_eq!(PhraseLength::Words24.entropy_bytes(), 32);
assert_eq!(PhraseLength::Words12.checksum_bits(), 4);
assert_eq!(PhraseLength::Words24.checksum_bits(), 8);
assert_eq!(PhraseLength::Words12.total_bits(), 132);
assert_eq!(PhraseLength::Words24.total_bits(), 264);
assert_eq!(PhraseLength::Words12.wordlist_size(), 2048);
assert_eq!(PhraseLength::Words24.wordlist_size(), 2048);
}
#[test]
fn default_wordlist_has_2048_unique_entries() {
let list = UnicodeWordlist::default();
assert_eq!(list.len(), 2048);
assert_eq!(list.bits_per_word(), 11);
let mut sorted: Vec<char> = list.as_slice().to_vec();
sorted.sort();
let n0 = sorted.len();
sorted.dedup();
assert_eq!(n0, sorted.len(), "default wordlist has duplicates");
}
#[test]
fn default_wordlist_passes_own_filters() {
let list = UnicodeWordlist::default();
for &c in list.as_slice() {
validate_codepoint(c).expect("default wordlist contains a forbidden codepoint");
}
}
#[test]
fn wordlist_rejects_empty() {
let err = UnicodeWordlist::new(&[]).unwrap_err();
assert!(matches!(err, CryptoError::InvalidParameter(_)));
}
#[test]
fn wordlist_rejects_non_power_of_two() {
let chars: Vec<char> = (0u32..100)
.map(|i| char::from_u32(0xE000 + i).unwrap())
.collect();
let err = UnicodeWordlist::new(&chars).unwrap_err();
assert!(matches!(err, CryptoError::InvalidParameter(_)));
}
#[test]
fn wordlist_rejects_duplicates() {
let chars: Vec<char> = vec!['a', 'a', 'b', 'c']; let err = UnicodeWordlist::new(&chars).unwrap_err();
assert!(matches!(err, CryptoError::InvalidParameter(_)));
}
#[test]
fn encode_decode_roundtrip_12() {
let entropy = [0xABu8; 16];
let phrase =
encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words12).unwrap();
assert_eq!(phrase.len(), 12);
let decoded = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap();
assert_eq!(decoded, entropy);
}
#[test]
fn encode_decode_roundtrip_24() {
let entropy = [0x42u8; 32];
let phrase =
encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words24).unwrap();
assert_eq!(phrase.len(), 24);
let decoded = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap();
assert_eq!(decoded, entropy);
}
#[test]
fn encode_decode_various_entropies() {
for byte in 0u8..=8 {
let entropy = [byte; 32];
let phrase =
encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words24)
.unwrap();
assert_eq!(phrase.len(), 24);
let round = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap();
assert_eq!(round, entropy);
}
}
#[test]
fn encode_rejects_wrong_entropy_length() {
let err = encode_phrase(
&[1u8; 16],
&UnicodeWordlist::default(),
PhraseLength::Words24,
)
.unwrap_err();
assert!(matches!(
err,
CryptoError::InvalidKeyLength {
algorithm: "UnicodeCipher",
expected: 32,
got: 16,
}
));
}
#[test]
fn decode_rejects_unexpected_phrase_length() {
let phrase: Vec<char> = std::iter::repeat('─').take(15).collect();
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(err, CryptoError::InvalidParameter(_)));
}
#[test]
fn decode_rejects_checksum_tamper() {
let entropy = [7u8; 32];
let mut phrase =
encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words24).unwrap();
let list = UnicodeWordlist::default();
let new_c = list.get(1000).unwrap();
phrase[5] = new_c;
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(err, CryptoError::ChecksumMismatch { .. }));
}
#[test]
fn decoder_rejects_zwj() {
let phrase: Vec<char> = std::iter::repeat('\u{200D}').take(24).collect();
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::RejectedCodepointClass {
codepoint: '\u{200D}',
class: "ZWJ",
}
));
}
#[test]
fn decoder_rejects_variation_selector() {
for ch in ['\u{FE0E}', '\u{FE0F}'] {
let phrase: Vec<char> = std::iter::repeat(ch).take(24).collect();
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::RejectedCodepointClass {
class: "VARIATION_SELECTOR",
..
}
));
}
}
#[test]
fn decoder_rejects_combining_mark() {
let phrase: Vec<char> = std::iter::repeat('\u{0301}').take(24).collect();
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::RejectedCodepointClass {
class: "COMBINING_MARK",
..
}
));
}
#[test]
fn decoder_rejects_bidi_mark() {
for ch in ['\u{200E}', '\u{200F}'] {
let phrase: Vec<char> = std::iter::repeat(ch).take(24).collect();
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::RejectedCodepointClass {
class: "BIDI_MARK",
..
}
));
}
}
#[test]
fn decoder_rejects_default_ignorable() {
let phrase: Vec<char> = std::iter::repeat('\u{200B}').take(24).collect();
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::RejectedCodepointClass {
class: "DEFAULT_IGNORABLE",
..
}
));
}
#[test]
fn decoder_rejects_control_char() {
let phrase: Vec<char> = std::iter::repeat('\u{0000}').take(24).collect();
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::RejectedCodepointClass {
class: "CONTROL",
..
}
));
}
#[test]
fn custom_alphabet_power_of_2_roundtrip_1024() {
let alphabet: Vec<char> = (0u32..1024)
.map(|i| char::from_u32(0xE000 + i).unwrap())
.collect();
let entropy = [0xCDu8; 30];
let (phrase, report) = encode_phrase_custom(&entropy, &alphabet, 24).unwrap();
assert_eq!(phrase.len(), 24);
assert_eq!(report.total_bits, 24.0 * 10.0);
assert!(report.is_safe);
let mut bits = vec![0u8; entropy.len()];
let list = UnicodeWordlist::new(&alphabet).unwrap();
let bits_per_word = list.bits_per_word() as usize;
for (i, &c) in phrase.iter().enumerate() {
let idx = list.index_of(c).unwrap() as u32;
for j in 0..bits_per_word {
let bit = ((idx >> (bits_per_word - 1 - j)) & 1) as u8;
let byte_idx = (i * bits_per_word + j) / 8;
let bit_in_byte = 7 - ((i * bits_per_word + j) % 8);
if bit == 1 {
bits[byte_idx] |= 1 << bit_in_byte;
}
}
}
assert_eq!(bits, entropy);
}
#[test]
fn custom_alphabet_floor_rejection_16() {
let alphabet: Vec<char> = (0u32..16)
.map(|i| char::from_u32(0xE000 + i).unwrap())
.collect();
let entropy = [0u8; 16];
let err = encode_phrase_custom(&entropy, &alphabet, 12).unwrap_err();
assert!(matches!(
err,
CryptoError::InsufficientEntropy {
got_bits,
required_min
} if got_bits < required_min
));
}
#[test]
fn custom_alphabet_non_power_of_two_rejected() {
let alphabet: Vec<char> = (0u32..100)
.map(|i| char::from_u32(0xE000 + i).unwrap())
.collect();
let entropy = [0u8; 64];
let err = encode_phrase_custom(&entropy, &alphabet, 24).unwrap_err();
assert!(matches!(err, CryptoError::InvalidParameter(_)));
}
#[test]
fn custom_alphabet_rejects_zwj_in_alphabet() {
let mut alphabet: Vec<char> = (0u32..16)
.map(|i| char::from_u32(0xE000 + i).unwrap())
.collect();
alphabet[3] = '\u{200D}'; let entropy = [0u8; 32];
let err = encode_phrase_custom(&entropy, &alphabet, 24).unwrap_err();
assert!(matches!(
err,
CryptoError::RejectedCodepointClass { class: "ZWJ", .. }
));
}
#[test]
fn extract_and_insert_bits_roundtrip() {
let src: [u8; 32] = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54,
0x32, 0x10, 0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x11, 0x22, 0x33,
0x44, 0x55, 0x66, 0x77,
];
let mut dst: Vec<u8> = vec![0u8; 32];
let bits_per_word = 11usize;
let n_words = 24;
for i in 0..n_words {
let start = i * bits_per_word;
let end = start + bits_per_word;
let v = extract_bits_msb(&src, start, end);
insert_bits_msb(&mut dst, v, start, bits_per_word);
}
for i in 0..n_words {
let start = i * bits_per_word;
let end = start + bits_per_word;
let from_src = extract_bits_msb(&src, start, end);
let from_dst = extract_bits_msb(&dst, start, end);
assert_eq!(from_src, from_dst, "round-trip bit mismatch at chunk {i}");
}
}
#[test]
fn encode_decode_roundtrip_zero_24() {
let entropy = [0u8; 32];
let phrase =
encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words24).unwrap();
let decoded = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap();
assert_eq!(decoded, entropy);
}
#[test]
fn decode_rejects_checksum_tamper_12() {
let entropy = [0x33u8; 16];
let mut phrase =
encode_phrase(&entropy, &UnicodeWordlist::default(), PhraseLength::Words12).unwrap();
let list = UnicodeWordlist::default();
let new_c = list.get(1000).unwrap();
phrase[4] = new_c;
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(err, CryptoError::ChecksumMismatch { .. }));
}
#[test]
fn decoder_rejects_arabic_letter_mark() {
let phrase: Vec<char> = std::iter::repeat('\u{061C}').take(24).collect();
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::RejectedCodepointClass {
class: "BIDI_MARK",
..
}
));
}
#[test]
fn decoder_rejects_mongolian_vowel_sep() {
let phrase: Vec<char> = std::iter::repeat('\u{180E}').take(24).collect();
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::RejectedCodepointClass {
class: "DEFAULT_IGNORABLE",
..
}
));
}
#[test]
fn decoder_rejects_bidi_isolate() {
let phrase: Vec<char> = std::iter::repeat('\u{2066}').take(24).collect();
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::RejectedCodepointClass {
class: "BIDI_MARK",
..
}
));
}
#[test]
fn decoder_rejects_combined_valid_and_zwj() {
let mut phrase: Vec<char> = UnicodeWordlist::default()
.as_slice()
.iter()
.take(12)
.copied()
.collect();
phrase.extend(std::iter::repeat('\u{200D}').take(12));
let err = decode_phrase(&phrase, &UnicodeWordlist::default()).unwrap_err();
assert!(matches!(
err,
CryptoError::RejectedCodepointClass {
codepoint: '\u{200D}',
class: "ZWJ",
}
));
}
#[test]
fn custom_alphabet_rejects_short_entropy() {
let alphabet: Vec<char> = (0u32..256)
.map(|i| char::from_u32(0xE000 + i).unwrap())
.collect();
let short_entropy = [0u8; 16];
let err = encode_phrase_custom(&short_entropy, &alphabet, 24).unwrap_err();
assert!(matches!(err, CryptoError::InvalidParameter(_)));
}
}