1use bech32::{Bech32, Hrp};
2
3#[derive(Debug)]
5pub enum RecoveryError {
6 Bip39(String),
7 InvalidKey(String),
8}
9
10impl std::fmt::Display for RecoveryError {
11 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12 match self {
13 RecoveryError::Bip39(msg) => write!(f, "BIP39 error: {msg}"),
14 RecoveryError::InvalidKey(msg) => write!(f, "invalid key: {msg}"),
15 }
16 }
17}
18
19const AGE_SECRET_KEY_HRP: Hrp = Hrp::parse_unchecked("age-secret-key-");
22
23pub fn generate() -> Result<(String, String, String), RecoveryError> {
30 let entropy: [u8; 32] = rand::random();
31 let mnemonic =
32 bip39::Mnemonic::from_entropy(&entropy).map_err(|e| RecoveryError::Bip39(e.to_string()))?;
33
34 let secret_key = bytes_to_age_key(&entropy)?;
35
36 let identity = crate::crypto::parse_identity(&secret_key)
37 .map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
38 let pubkey = identity
39 .pubkey_string()
40 .map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
41
42 Ok((mnemonic.to_string(), secret_key, pubkey))
43}
44
45pub fn phrase_from_key(secret_key: &str) -> Result<String, RecoveryError> {
48 let lowercase = secret_key.to_lowercase();
50 let (_, key_bytes) =
51 bech32::decode(&lowercase).map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
52 let mnemonic = bip39::Mnemonic::from_entropy(&key_bytes)
53 .map_err(|e| RecoveryError::Bip39(e.to_string()))?;
54 Ok(mnemonic.to_string())
55}
56
57pub fn recover(phrase: &str) -> Result<String, RecoveryError> {
60 let mnemonic = bip39::Mnemonic::parse_in_normalized(bip39::Language::English, phrase)
61 .map_err(|e| RecoveryError::Bip39(e.to_string()))?;
62
63 let entropy = mnemonic.to_entropy();
64 bytes_to_age_key(&entropy)
65}
66
67fn bytes_to_age_key(key_bytes: &[u8]) -> Result<String, RecoveryError> {
70 let encoded = bech32::encode::<Bech32>(AGE_SECRET_KEY_HRP, key_bytes)
71 .map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
72
73 let key_str = encoded.to_uppercase();
74
75 crate::crypto::parse_identity(&key_str)
77 .map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
78
79 Ok(key_str)
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn generate_produces_valid_mnemonic_and_key() {
88 let (phrase, secret_key, pubkey) = generate().unwrap();
89
90 assert_eq!(phrase.split_whitespace().count(), 24);
91 assert!(secret_key.starts_with("AGE-SECRET-KEY-1"));
92 assert!(pubkey.starts_with("age1"));
93 }
94
95 #[test]
96 fn recover_roundtrip() {
97 let (phrase, original_key, _) = generate().unwrap();
98 let recovered_key = recover(&phrase).unwrap();
99 assert_eq!(original_key, recovered_key);
100 }
101
102 #[test]
103 fn same_phrase_same_key() {
104 let (phrase, key1, _) = generate().unwrap();
105 let key2 = recover(&phrase).unwrap();
106 let key3 = recover(&phrase).unwrap();
107 assert_eq!(key1, key2);
108 assert_eq!(key2, key3);
109 }
110
111 #[test]
112 fn different_phrases_different_keys() {
113 let (_, key1, _) = generate().unwrap();
114 let (_, key2, _) = generate().unwrap();
115 assert_ne!(key1, key2);
116 }
117
118 #[test]
119 fn phrase_from_key_roundtrip() {
120 let (original_phrase, secret_key, _) = generate().unwrap();
121 let recovered_phrase = phrase_from_key(&secret_key).unwrap();
122 assert_eq!(original_phrase, recovered_phrase);
123 }
124
125 #[test]
126 fn invalid_phrase_fails() {
127 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());
128 }
129
130 #[test]
133 fn recover_wrong_word_count() {
134 assert!(recover("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").is_err());
136 }
137
138 #[test]
139 fn recover_gibberish_words() {
140 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";
142 assert!(recover(words).is_err());
143 }
144}