metamorphic_crypto/
recovery.rs1use zeroize::Zeroize;
7
8use crate::CryptoError;
9use crate::b64;
10use crate::secretbox::{decrypt_secretbox_to_string, encrypt_secretbox_string};
11
12const ALPHABET: &[u8; 32] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
14
15#[derive(Debug, Clone)]
17pub struct RecoveryKey {
18 pub recovery_key: String,
20 pub recovery_secret_b64: String,
22}
23
24pub fn generate_recovery_key() -> Result<RecoveryKey, CryptoError> {
29 let mut secret = [0u8; 32];
30 getrandom::getrandom(&mut secret).expect("OS CSPRNG unavailable");
31
32 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 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 let recovery_secret_b64 = recovery_key_to_secret(&recovery_key)?;
49
50 Ok(RecoveryKey {
51 recovery_key,
52 recovery_secret_b64,
53 })
54}
55
56pub 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
77pub 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
85pub 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
93fn 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 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}