Skip to main content

murk_cli/
recovery.rs

1use bech32::{Bech32, Hrp};
2use zeroize::Zeroizing;
3
4/// Errors that can occur during recovery phrase operations.
5#[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
20/// The Bech32 human-readable prefix for age secret keys.
21/// age uses lowercase internally, then uppercases the full string for display.
22const AGE_SECRET_KEY_HRP: Hrp = Hrp::parse_unchecked("age-secret-key-");
23
24/// Generate a new age keypair and return the BIP39 24-word mnemonic,
25/// secret key string, and public key string.
26///
27/// 24 BIP39 words encode 256 bits (32 bytes) — exactly the size of an
28/// age x25519 secret key. The mnemonic is a direct encoding of the key
29/// bytes with no derivation step. Same words, same key, always.
30///
31/// The mnemonic and secret key are returned in `Zeroizing` wrappers so the
32/// plaintext is cleared from memory when dropped.
33pub 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
49/// Re-derive the BIP39 24-word mnemonic from an existing MURK_KEY.
50/// Decodes the Bech32 key back to raw bytes, then encodes as a mnemonic.
51///
52/// Plugin identities (`AGE-PLUGIN-*`) have no recovery phrase because BIP39
53/// words encode the raw 32 key bytes and hardware-backed keys never leave
54/// the device. Back up a second hardware device as a vault recipient instead.
55pub 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    // age keys are uppercase; bech32 decoding requires lowercase.
68    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
77/// Recover an age secret key from a BIP39 24-word mnemonic phrase.
78/// Returns the same MURK_KEY that was originally generated.
79pub 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
87/// Bech32-encode raw key bytes as an AGE-SECRET-KEY-1... string.
88/// This matches exactly how the age crate encodes keys internally.
89fn 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    // Validate by round-tripping through the age crate.
96    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    // ── New edge-case tests ──
151
152    #[test]
153    fn recover_wrong_word_count() {
154        // 12 valid BIP39 words instead of 24 — should fail.
155        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        // 24 nonsense words — should fail.
161        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        // Same entropy → same key, verified via phrase roundtrip.
183        let (phrase, key, _) = generate().unwrap();
184        let recovered = recover(&phrase).unwrap();
185        assert_eq!(key, recovered);
186        // And the phrase from that key matches.
187        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}