Skip to main content

client_core/
recovery.rs

1//! BIP39 24-word encoding of the user's AES-256 encryption key.
2//!
3//! The 32-byte key from `crypto::generate_aes_key()` is encoded as 24
4//! English words (256 bits of entropy + 8-bit BIP39 checksum). Users
5//! display this once via `cinch auth recovery show` and re-import it on
6//! a new device with `cinch auth recovery restore`.
7//!
8//! No KDF — the AES key is already high-entropy random bytes from
9//! `OsRng`. BIP39 is used purely as a human-friendly transport
10//! encoding with a built-in checksum that catches typos.
11
12use bip39::{Language, Mnemonic};
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16pub enum RecoveryError {
17    #[error("recovery code must be 24 words, got {0}")]
18    WrongWordCount(usize),
19    #[error("recovery code checksum failed — check for typos")]
20    BadChecksum,
21    #[error("unrecognized word in recovery code: {0}")]
22    UnknownWord(String),
23    #[error("invalid recovery code: {0}")]
24    Invalid(String),
25}
26
27/// Encode a 32-byte AES key as a 24-word BIP39 phrase (English wordlist,
28/// space-separated, lowercase).
29pub fn key_to_words(key: &[u8; 32]) -> String {
30    Mnemonic::from_entropy_in(Language::English, key)
31        .expect("32 bytes is always valid BIP39 entropy")
32        .to_string()
33}
34
35/// Decode a 24-word BIP39 phrase back into the 32-byte AES key.
36///
37/// Accepts any whitespace separator and any case. Validates word count
38/// and BIP39 checksum.
39pub fn words_to_key(phrase: &str) -> Result<[u8; 32], RecoveryError> {
40    let normalized = phrase
41        .split_whitespace()
42        .map(|w| w.to_lowercase())
43        .collect::<Vec<_>>()
44        .join(" ");
45    let word_count = normalized.split_whitespace().count();
46    if word_count != 24 {
47        return Err(RecoveryError::WrongWordCount(word_count));
48    }
49    let mnemonic = Mnemonic::parse_in_normalized(Language::English, &normalized).map_err(|e| {
50        use bip39::Error as BE;
51        match e {
52            BE::InvalidChecksum => RecoveryError::BadChecksum,
53            BE::UnknownWord(idx) => {
54                let word = normalized
55                    .split_whitespace()
56                    .nth(idx)
57                    .unwrap_or("?")
58                    .to_string();
59                RecoveryError::UnknownWord(word)
60            }
61            other => RecoveryError::Invalid(other.to_string()),
62        }
63    })?;
64    let (entropy_bytes, entropy_len) = mnemonic.to_entropy_array();
65    if entropy_len != 32 {
66        return Err(RecoveryError::Invalid(format!(
67            "expected 32 bytes of entropy, got {}",
68            entropy_len
69        )));
70    }
71    let mut out = [0u8; 32];
72    out.copy_from_slice(&entropy_bytes[..32]);
73    Ok(out)
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn roundtrip_random_key() {
82        let key = crate::crypto::generate_aes_key();
83        let phrase = key_to_words(&key);
84        assert_eq!(phrase.split_whitespace().count(), 24);
85        let decoded = words_to_key(&phrase).expect("roundtrip");
86        assert_eq!(decoded, key);
87    }
88
89    #[test]
90    fn all_zero_key_known_vector() {
91        let key = [0u8; 32];
92        let phrase = key_to_words(&key);
93        // BIP39 spec: 256 bits of zero entropy → "abandon" × 23 + "art".
94        assert_eq!(
95            phrase,
96            "abandon abandon abandon abandon abandon abandon abandon abandon \
97             abandon abandon abandon abandon abandon abandon abandon abandon \
98             abandon abandon abandon abandon abandon abandon abandon art"
99        );
100        assert_eq!(words_to_key(&phrase).unwrap(), key);
101    }
102
103    #[test]
104    fn accepts_mixed_case_and_extra_whitespace() {
105        let key = [0u8; 32];
106        let messy = "  Abandon ABANDON abandon abandon abandon abandon abandon abandon \
107                     abandon  abandon abandon abandon abandon abandon abandon abandon \
108                     abandon abandon abandon abandon abandon abandon abandon ART  ";
109        assert_eq!(words_to_key(messy).unwrap(), key);
110    }
111
112    #[test]
113    fn rejects_wrong_word_count() {
114        let err = words_to_key("abandon abandon abandon").unwrap_err();
115        assert!(matches!(err, RecoveryError::WrongWordCount(3)));
116    }
117
118    #[test]
119    fn rejects_bad_checksum() {
120        // Swap the last word ("art") for another valid wordlist entry
121        // that breaks the checksum.
122        let bad = "abandon abandon abandon abandon abandon abandon abandon abandon \
123                   abandon abandon abandon abandon abandon abandon abandon abandon \
124                   abandon abandon abandon abandon abandon abandon abandon abandon";
125        let err = words_to_key(bad).unwrap_err();
126        assert!(matches!(err, RecoveryError::BadChecksum));
127    }
128
129    #[test]
130    fn rejects_unknown_word() {
131        let bad = "zzzzz abandon abandon abandon abandon abandon abandon abandon \
132                   abandon abandon abandon abandon abandon abandon abandon abandon \
133                   abandon abandon abandon abandon abandon abandon abandon art";
134        let err = words_to_key(bad).unwrap_err();
135        assert!(matches!(err, RecoveryError::UnknownWord(ref w) if w == "zzzzz"));
136    }
137}