1use bech32::{Bech32, Hrp};
2use zeroize::Zeroizing;
3
4#[derive(Debug)]
6pub enum RecoveryError {
7 Bip39(String),
8 InvalidKey(String),
9}
10
11impl std::fmt::Display for RecoveryError {
12 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13 match self {
14 RecoveryError::Bip39(msg) => write!(f, "BIP39 error: {msg}"),
15 RecoveryError::InvalidKey(msg) => write!(f, "invalid key: {msg}"),
16 }
17 }
18}
19
20const AGE_SECRET_KEY_HRP: Hrp = Hrp::parse_unchecked("age-secret-key-");
23
24pub fn generate() -> Result<(Zeroizing<String>, Zeroizing<String>, String), RecoveryError> {
34 let entropy = Zeroizing::new([0u8; 32].map(|_| rand::random::<u8>()));
35 let mnemonic = bip39::Mnemonic::from_entropy(&*entropy)
36 .map_err(|e| RecoveryError::Bip39(e.to_string()))?;
37
38 let secret_key = bytes_to_age_key(&*entropy)?;
39
40 let identity = crate::crypto::parse_identity(&secret_key)
41 .map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
42 let pubkey = identity
43 .pubkey_string()
44 .map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
45
46 Ok((Zeroizing::new(mnemonic.to_string()), secret_key, pubkey))
47}
48
49pub fn phrase_from_key(secret_key: &str) -> Result<Zeroizing<String>, RecoveryError> {
56 let trimmed = secret_key.trim();
57 if trimmed.to_ascii_uppercase().contains("AGE-PLUGIN-") {
58 return Err(RecoveryError::InvalidKey(
59 "plugin identities (YubiKey, Secure Enclave, FIDO2) do not have recovery phrases. \
60 BIP39 words encode the raw 32 key bytes, but hardware-backed keys never leave the \
61 device — there are no bytes to encode. Recovery means enrolling a backup hardware \
62 device at setup and adding its pubkey as a recipient with `murk authorize`"
63 .into(),
64 ));
65 }
66
67 let lowercase = Zeroizing::new(trimmed.to_lowercase());
69 let (_, key_bytes) =
70 bech32::decode(&lowercase).map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
71 let key_bytes = Zeroizing::new(key_bytes);
72 let mnemonic = bip39::Mnemonic::from_entropy(&key_bytes)
73 .map_err(|e| RecoveryError::Bip39(e.to_string()))?;
74 Ok(Zeroizing::new(mnemonic.to_string()))
75}
76
77pub fn recover(phrase: &str) -> Result<Zeroizing<String>, RecoveryError> {
80 let mnemonic = bip39::Mnemonic::parse_in_normalized(bip39::Language::English, phrase)
81 .map_err(|e| RecoveryError::Bip39(e.to_string()))?;
82
83 let entropy = Zeroizing::new(mnemonic.to_entropy());
84 bytes_to_age_key(&entropy)
85}
86
87fn bytes_to_age_key(key_bytes: &[u8]) -> Result<Zeroizing<String>, RecoveryError> {
90 let encoded = bech32::encode::<Bech32>(AGE_SECRET_KEY_HRP, key_bytes)
91 .map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
92
93 let key_str = Zeroizing::new(encoded.to_uppercase());
94
95 crate::crypto::parse_identity(&key_str)
97 .map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
98
99 Ok(key_str)
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn generate_produces_valid_mnemonic_and_key() {
108 let (phrase, secret_key, pubkey) = generate().unwrap();
109
110 assert_eq!(phrase.split_whitespace().count(), 24);
111 assert!(secret_key.starts_with("AGE-SECRET-KEY-1"));
112 assert!(pubkey.starts_with("age1"));
113 }
114
115 #[test]
116 fn recover_roundtrip() {
117 let (phrase, original_key, _) = generate().unwrap();
118 let recovered_key = recover(&phrase).unwrap();
119 assert_eq!(original_key, recovered_key);
120 }
121
122 #[test]
123 fn same_phrase_same_key() {
124 let (phrase, key1, _) = generate().unwrap();
125 let key2 = recover(&phrase).unwrap();
126 let key3 = recover(&phrase).unwrap();
127 assert_eq!(key1, key2);
128 assert_eq!(key2, key3);
129 }
130
131 #[test]
132 fn different_phrases_different_keys() {
133 let (_, key1, _) = generate().unwrap();
134 let (_, key2, _) = generate().unwrap();
135 assert_ne!(key1, key2);
136 }
137
138 #[test]
139 fn phrase_from_key_roundtrip() {
140 let (original_phrase, secret_key, _) = generate().unwrap();
141 let recovered_phrase = phrase_from_key(&secret_key).unwrap();
142 assert_eq!(original_phrase, recovered_phrase);
143 }
144
145 #[test]
146 fn invalid_phrase_fails() {
147 assert!(recover("amet sed ut sit dolor et magna vita ipsum quasi nemo enim ad ex in id est non vel rem sint cum").is_err());
148 }
149
150 #[test]
153 fn recover_wrong_word_count() {
154 assert!(recover("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").is_err());
156 }
157
158 #[test]
159 fn recover_gibberish_words() {
160 let words = "zzz yyy xxx www vvv uuu ttt sss rrr qqq ppp ooo nnn mmm lll kkk jjj iii hhh ggg fff eee ddd ccc";
162 assert!(recover(words).is_err());
163 }
164
165 #[test]
166 fn recover_empty_string() {
167 assert!(recover("").is_err());
168 }
169
170 #[test]
171 fn phrase_from_key_invalid_key() {
172 assert!(phrase_from_key("not-a-valid-key").is_err());
173 }
174
175 #[test]
176 fn phrase_from_key_empty() {
177 assert!(phrase_from_key("").is_err());
178 }
179
180 #[test]
181 fn generate_key_is_deterministic_from_entropy() {
182 let (phrase, key, _) = generate().unwrap();
184 let recovered = recover(&phrase).unwrap();
185 assert_eq!(key, recovered);
186 let phrase_back = phrase_from_key(&key).unwrap();
188 assert_eq!(phrase, phrase_back);
189 }
190
191 #[test]
192 fn recovery_error_display() {
193 let e = RecoveryError::Bip39("bad mnemonic".into());
194 assert!(e.to_string().contains("bad mnemonic"));
195
196 let e = RecoveryError::InvalidKey("not a key".into());
197 assert!(e.to_string().contains("not a key"));
198 }
199}