bip39_check/
lib.rs

1//! # bip39-check
2//!
3//! EN — Simple BIP-39 mnemonic validator: detects language, checks word count and checksum.
4//! Does not derive seeds/keys. Useful for onboarding, QA, support and SDKs.
5//!
6//! PT — Validador simples de mnemonics BIP-39: detecta idioma, confere contagem e checksum.
7//! Não deriva seed/chaves. Útil para onboarding, QA, suporte e SDKs.
8//!
9//! ## Example / Exemplo
10//! ```rust
11//! use bip39_check::{validate, is_valid};
12//!
13//! let ok = "legal winner thank year wave sausage worth useful legal winner thank yellow";
14//! assert!(is_valid(ok));
15//!
16//! let report = validate(ok).unwrap();
17//! println!("language: {:?}, words: {}, entropy: {} bits",
18//!          report.language, report.word_count, report.entropy_bits.unwrap());
19//! ```
20
21use unicode_normalization::UnicodeNormalization;
22
23/// EN — Supported BIP-39 wordlists (enable languages via `bip39` features).
24/// PT — Wordlists BIP-39 suportadas (habilite idiomas via features do `bip39`).
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Language {
27    English,
28    Spanish,
29    French,
30    Italian,
31    Portuguese,
32    Japanese,
33    Korean,
34    ChineseSimplified,
35    ChineseTraditional,
36    Czech,
37}
38
39impl Language {
40    /// EN — All supported languages (mirrors enabled `bip39` features).
41    /// PT — Todos os idiomas suportados (espelha as features habilitadas do `bip39`).
42    pub fn all() -> &'static [Language] {
43        &[
44            Language::English,
45            Language::Spanish,
46            Language::French,
47            Language::Italian,
48            Language::Portuguese,
49            Language::Japanese,
50            Language::Korean,
51            Language::ChineseSimplified,
52            Language::ChineseTraditional,
53            Language::Czech,
54        ]
55    }
56
57    /// EN — Convert to `bip39::Language`.
58    /// PT — Converte para `bip39::Language`.
59    fn to_bip39(self) -> bip39::Language {
60        match self {
61            Language::English => bip39::Language::English,
62            Language::Spanish => bip39::Language::Spanish,
63            Language::French => bip39::Language::French,
64            Language::Italian => bip39::Language::Italian,
65            Language::Portuguese => bip39::Language::Portuguese,
66            Language::Japanese => bip39::Language::Japanese,
67            Language::Korean => bip39::Language::Korean,
68            Language::ChineseSimplified => bip39::Language::SimplifiedChinese,
69            Language::ChineseTraditional => bip39::Language::TraditionalChinese,
70            Language::Czech => bip39::Language::Czech,
71        }
72    }
73}
74
75/// EN — Successful validation report.
76/// PT — Relatório de validação bem-sucedida.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Report {
79    /// EN Detected language (if any). PT Idioma detectado (se houver).
80    pub language: Option<Language>,
81    /// EN Number of words. PT Número de palavras.
82    pub word_count: usize,
83    /// EN Entropy size in bits (128/160/192/224/256). PT Bits de entropia.
84    pub entropy_bits: Option<usize>,
85    /// EN NFKD-normalized phrase. PT Frase normalizada (NFKD).
86    pub normalized: String,
87}
88
89/// EN — Validation error with kind and optional details.
90/// PT — Erro de validação com tipo e detalhes opcionais.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct CheckError {
93    pub kind: ErrorKind,
94    pub details: Option<String>,
95}
96
97impl std::fmt::Display for CheckError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        use ErrorKind::*;
100        match &self.kind {
101            // Mantemos mensagens em PT — diretas e úteis para o usuário final.
102            Empty => write!(f, "mnemonic vazio")?,
103            InvalidWordCount { got } => write!(f, "contagem de palavras inválida: {}", got)?,
104            UnknownOrMixedWords => write!(f, "palavras desconhecidas ou mistura de idiomas")?,
105            BadChecksum => write!(f, "checksum inválido")?,
106            UnicodeSuspicious => write!(f, "unicode suspeito após normalização")?,
107        }
108        if let Some(d) = &self.details {
109            write!(f, " ({})", d)?;
110        }
111        Ok(())
112    }
113}
114
115impl std::error::Error for CheckError {}
116
117/// EN — Error categories returned by `validate`.
118/// PT — Categorias de erro retornadas por `validate`.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum ErrorKind {
121    Empty,
122    InvalidWordCount { got: usize },
123    UnknownOrMixedWords,
124    BadChecksum,
125    UnicodeSuspicious,
126}
127
128/// EN Validate a BIP-39 mnemonic.
129/// - Normalizes to NFKD
130/// - Checks word count {12,15,18,21,24}
131/// - Tries enabled languages and verifies checksum
132/// - Returns `Report` or `CheckError`
133///
134/// PT Valida um mnemonic BIP-39.
135/// - Normaliza para NFKD
136/// - Checa contagem {12,15,18,21,24}
137/// - Tenta idiomas habilitados e verifica checksum
138/// - Retorna `Report` ou `CheckError`
139pub fn validate(phrase: &str) -> Result<Report, CheckError> {
140    // 1) Normalize to NFKD / Normaliza para NFKD
141    let normalized = phrase.nfkd().collect::<String>().trim().to_string();
142    if normalized.is_empty() {
143        return Err(CheckError { kind: ErrorKind::Empty, details: None });
144    }
145
146    // 2) Check word count / Checa contagem de palavras
147    let words: Vec<&str> = normalized.split_whitespace().collect();
148    let wc = words.len();
149    const ALLOWED: [usize; 5] = [12, 15, 18, 21, 24];
150    if !ALLOWED.contains(&wc) {
151        return Err(CheckError {
152            kind: ErrorKind::InvalidWordCount { got: wc },
153            details: Some("válidos: 12, 15, 18, 21, 24".into()),
154        });
155    }
156
157    // 3) Basic suspicious Unicode check / Checagem básica de Unicode suspeito
158    if normalized.chars().any(|c| {
159        (c as u32) < 0x20 || (0x202A..=0x202E).contains(&(c as u32))
160    }) {
161        return Err(CheckError { kind: ErrorKind::UnicodeSuspicious, details: None });
162    }
163
164    // 4) Try each language and verify checksum / Testa idiomas e verifica checksum
165    let mut saw_checksum_err = false;
166    for lang in Language::all() {
167        match bip39::Mnemonic::parse_in_normalized(lang.to_bip39(), &normalized) {
168            Ok(m) => {
169                let entropy_bits = m.to_entropy().len() * 8;
170                return Ok(Report {
171                    language: Some(*lang),
172                    word_count: wc,
173                    entropy_bits: Some(entropy_bits),
174                    normalized,
175                });
176            }
177            Err(e) => {
178                // Try to distinguish unknown words vs checksum error for better messages
179                // Tenta diferenciar palavras desconhecidas de checksum ruim para mensagem melhor
180                if e.to_string().to_lowercase().contains("checksum") {
181                    saw_checksum_err = true;
182                }
183            }
184        }
185    }
186
187    if saw_checksum_err {
188        Err(CheckError { kind: ErrorKind::BadChecksum, details: None })
189    } else {
190        Err(CheckError { kind: ErrorKind::UnknownOrMixedWords, details: None })
191    }
192}
193
194/// EN Fast boolean check using `validate`.
195/// PT Verificação booleana rápida usando `validate`.
196pub fn is_valid(phrase: &str) -> bool {
197    validate(phrase).is_ok()
198}