Skip to main content

murk_cli/
recovery.rs

1use bech32::{Bech32, Hrp};
2
3/// Errors that can occur during recovery phrase operations.
4#[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
19/// The Bech32 human-readable prefix for age secret keys.
20/// age uses lowercase internally, then uppercases the full string for display.
21const AGE_SECRET_KEY_HRP: Hrp = Hrp::parse_unchecked("age-secret-key-");
22
23/// Generate a new age keypair and return the BIP39 24-word mnemonic,
24/// secret key string, and public key string.
25///
26/// 24 BIP39 words encode 256 bits (32 bytes) — exactly the size of an
27/// age x25519 secret key. The mnemonic is a direct encoding of the key
28/// bytes with no derivation step. Same words, same key, always.
29pub 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
45/// Re-derive the BIP39 24-word mnemonic from an existing MURK_KEY.
46/// Decodes the Bech32 key back to raw bytes, then encodes as a mnemonic.
47pub fn phrase_from_key(secret_key: &str) -> Result<String, RecoveryError> {
48    // age keys are uppercase; bech32 decoding requires lowercase.
49    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
57/// Recover an age secret key from a BIP39 24-word mnemonic phrase.
58/// Returns the same MURK_KEY that was originally generated.
59pub 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
67/// Bech32-encode raw key bytes as an AGE-SECRET-KEY-1... string.
68/// This matches exactly how the age crate encodes keys internally.
69fn 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    // Validate by round-tripping through the age crate.
76    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    // ── New edge-case tests ──
131
132    #[test]
133    fn recover_wrong_word_count() {
134        // 12 valid BIP39 words instead of 24 — should fail.
135        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        // 24 nonsense words — should fail.
141        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}