Skip to main content

metamorphic_crypto/
recovery.rs

1//! Recovery key generation and encoding.
2//!
3//! A recovery key is a human-readable string derived from 32 random bytes, encoded
4//! with a custom base32 alphabet (no I/O/0/1) as 13 hyphen-separated groups.
5
6use zeroize::Zeroize;
7
8use crate::CryptoError;
9use crate::b64;
10use crate::secretbox::{decrypt_secretbox_to_string, encrypt_secretbox_string};
11
12/// Custom base32 alphabet (32 chars, excludes I/O/0/1 to avoid confusion).
13const ALPHABET: &[u8; 32] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
14
15/// A recovery key with its derived 32-byte secret.
16#[derive(Debug, Clone)]
17pub struct RecoveryKey {
18    /// Human-readable key (13 hyphen-separated groups, 64 chars total).
19    pub recovery_key: String,
20    /// Base64-encoded 32-byte secret derived from the key (for encrypt/hash).
21    pub recovery_secret_b64: String,
22}
23
24/// Generate a new recovery key from 32 OS-random bytes.
25///
26/// Returns the human-readable key and the derived secret (base64). The secret is
27/// obtained by roundtripping through `recovery_key_to_secret` to ensure canonical form.
28pub fn generate_recovery_key() -> Result<RecoveryKey, CryptoError> {
29    let mut secret = [0u8; 32];
30    getrandom::getrandom(&mut secret).expect("OS CSPRNG unavailable");
31
32    // Encode each byte as two base32 chars
33    let mut encoded = String::with_capacity(64);
34    for &byte in &secret {
35        encoded.push(ALPHABET[(byte % 32) as usize] as char);
36        encoded.push(ALPHABET[((byte >> 3) % 32) as usize] as char);
37    }
38    secret.zeroize();
39
40    // Format: 12 groups of 5 + 1 group of 4 = 64 chars
41    let groups: Vec<&str> = (0..64)
42        .step_by(5)
43        .map(|i| &encoded[i..encoded.len().min(i + 5)])
44        .collect();
45    let recovery_key = groups.join("-");
46
47    // Derive canonical secret by roundtripping
48    let recovery_secret_b64 = recovery_key_to_secret(&recovery_key)?;
49
50    Ok(RecoveryKey {
51        recovery_key,
52        recovery_secret_b64,
53    })
54}
55
56/// Re-derive the 32-byte secret (base64) from a human-readable recovery key.
57///
58/// Case-insensitive. Strips hyphens.
59pub fn recovery_key_to_secret(recovery_key: &str) -> Result<String, CryptoError> {
60    let stripped: String = recovery_key.replace('-', "").to_uppercase();
61    if stripped.len() != 64 {
62        return Err(CryptoError::InvalidRecoveryKey);
63    }
64
65    let chars = stripped.as_bytes();
66    let mut bytes = Vec::with_capacity(32);
67
68    for pair in chars.chunks_exact(2) {
69        let idx1 = alphabet_index(pair[0])?;
70        let idx2 = alphabet_index(pair[1])?;
71        bytes.push((idx1 & 0x1f) | ((idx2 & 0x1f) << 3));
72    }
73
74    Ok(b64::encode(&bytes))
75}
76
77/// Encrypt a private key (base64 string) with the recovery secret for backup.
78pub fn encrypt_private_key_for_recovery(
79    pk_b64: &str,
80    secret_b64: &str,
81) -> Result<String, CryptoError> {
82    encrypt_secretbox_string(pk_b64, secret_b64)
83}
84
85/// Decrypt a private key using the recovery secret.
86pub fn decrypt_private_key_with_recovery(
87    ct_b64: &str,
88    secret_b64: &str,
89) -> Result<String, CryptoError> {
90    decrypt_secretbox_to_string(ct_b64, secret_b64)
91}
92
93/// Find a character's index in our base32 alphabet.
94fn alphabet_index(ch: u8) -> Result<u8, CryptoError> {
95    ALPHABET
96        .iter()
97        .position(|&c| c == ch)
98        .map(|i| i as u8)
99        .ok_or(CryptoError::InvalidRecoveryKey)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::keys::generate_keypair;
106
107    #[test]
108    fn format_13_groups() {
109        let rk = generate_recovery_key().unwrap();
110        let groups: Vec<&str> = rk.recovery_key.split('-').collect();
111        assert_eq!(groups.len(), 13);
112        for (i, g) in groups.iter().enumerate() {
113            if i < 12 {
114                assert_eq!(g.len(), 5);
115            } else {
116                assert_eq!(g.len(), 4);
117            }
118        }
119    }
120
121    #[test]
122    fn roundtrip() {
123        let rk = generate_recovery_key().unwrap();
124        let secret = recovery_key_to_secret(&rk.recovery_key).unwrap();
125        assert_eq!(secret, rk.recovery_secret_b64);
126        assert_eq!(b64::decode(&secret).unwrap().len(), 32);
127    }
128
129    #[test]
130    fn case_insensitive() {
131        let rk = generate_recovery_key().unwrap();
132        let lower = recovery_key_to_secret(&rk.recovery_key.to_lowercase()).unwrap();
133        assert_eq!(lower, rk.recovery_secret_b64);
134    }
135
136    #[test]
137    fn encrypt_decrypt_private_key() {
138        let kp = generate_keypair();
139        let rk = generate_recovery_key().unwrap();
140        let ct =
141            encrypt_private_key_for_recovery(&kp.private_key, &rk.recovery_secret_b64).unwrap();
142        let pt = decrypt_private_key_with_recovery(&ct, &rk.recovery_secret_b64).unwrap();
143        assert_eq!(pt, kp.private_key);
144    }
145
146    #[test]
147    fn only_valid_chars() {
148        let rk = generate_recovery_key().unwrap();
149        for ch in rk.recovery_key.replace('-', "").bytes() {
150            assert!(ALPHABET.contains(&ch), "invalid char: {}", ch as char);
151        }
152    }
153
154    #[test]
155    fn invalid_char_rejected() {
156        // 'I' and 'O' are not in the alphabet
157        assert!(
158            recovery_key_to_secret(
159                "IIIII-OOOOO-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE-FFFFF-GGGGG-HHHHH-JJJJJ-KKKKK-LLLL"
160            )
161            .is_err()
162        );
163    }
164}