use bech32::{Bech32, Hrp};
#[derive(Debug)]
pub enum RecoveryError {
Bip39(String),
InvalidKey(String),
}
impl std::fmt::Display for RecoveryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RecoveryError::Bip39(msg) => write!(f, "BIP39 error: {msg}"),
RecoveryError::InvalidKey(msg) => write!(f, "invalid key: {msg}"),
}
}
}
const AGE_SECRET_KEY_HRP: Hrp = Hrp::parse_unchecked("age-secret-key-");
pub fn generate() -> Result<(String, String, String), RecoveryError> {
let entropy: [u8; 32] = rand::random();
let mnemonic =
bip39::Mnemonic::from_entropy(&entropy).map_err(|e| RecoveryError::Bip39(e.to_string()))?;
let secret_key = bytes_to_age_key(&entropy)?;
let identity = crate::crypto::parse_identity(&secret_key)
.map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
let pubkey = identity
.pubkey_string()
.map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
Ok((mnemonic.to_string(), secret_key, pubkey))
}
pub fn phrase_from_key(secret_key: &str) -> Result<String, RecoveryError> {
let lowercase = secret_key.to_lowercase();
let (_, key_bytes) =
bech32::decode(&lowercase).map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
let mnemonic = bip39::Mnemonic::from_entropy(&key_bytes)
.map_err(|e| RecoveryError::Bip39(e.to_string()))?;
Ok(mnemonic.to_string())
}
pub fn recover(phrase: &str) -> Result<String, RecoveryError> {
let mnemonic = bip39::Mnemonic::parse_in_normalized(bip39::Language::English, phrase)
.map_err(|e| RecoveryError::Bip39(e.to_string()))?;
let entropy = mnemonic.to_entropy();
bytes_to_age_key(&entropy)
}
fn bytes_to_age_key(key_bytes: &[u8]) -> Result<String, RecoveryError> {
let encoded = bech32::encode::<Bech32>(AGE_SECRET_KEY_HRP, key_bytes)
.map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
let key_str = encoded.to_uppercase();
crate::crypto::parse_identity(&key_str)
.map_err(|e| RecoveryError::InvalidKey(e.to_string()))?;
Ok(key_str)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_produces_valid_mnemonic_and_key() {
let (phrase, secret_key, pubkey) = generate().unwrap();
assert_eq!(phrase.split_whitespace().count(), 24);
assert!(secret_key.starts_with("AGE-SECRET-KEY-1"));
assert!(pubkey.starts_with("age1"));
}
#[test]
fn recover_roundtrip() {
let (phrase, original_key, _) = generate().unwrap();
let recovered_key = recover(&phrase).unwrap();
assert_eq!(original_key, recovered_key);
}
#[test]
fn same_phrase_same_key() {
let (phrase, key1, _) = generate().unwrap();
let key2 = recover(&phrase).unwrap();
let key3 = recover(&phrase).unwrap();
assert_eq!(key1, key2);
assert_eq!(key2, key3);
}
#[test]
fn different_phrases_different_keys() {
let (_, key1, _) = generate().unwrap();
let (_, key2, _) = generate().unwrap();
assert_ne!(key1, key2);
}
#[test]
fn phrase_from_key_roundtrip() {
let (original_phrase, secret_key, _) = generate().unwrap();
let recovered_phrase = phrase_from_key(&secret_key).unwrap();
assert_eq!(original_phrase, recovered_phrase);
}
#[test]
fn invalid_phrase_fails() {
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());
}
#[test]
fn recover_wrong_word_count() {
assert!(recover("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").is_err());
}
#[test]
fn recover_gibberish_words() {
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";
assert!(recover(words).is_err());
}
#[test]
fn recover_empty_string() {
assert!(recover("").is_err());
}
#[test]
fn phrase_from_key_invalid_key() {
assert!(phrase_from_key("not-a-valid-key").is_err());
}
#[test]
fn phrase_from_key_empty() {
assert!(phrase_from_key("").is_err());
}
#[test]
fn generate_key_is_deterministic_from_entropy() {
let (phrase, key, _) = generate().unwrap();
let recovered = recover(&phrase).unwrap();
assert_eq!(key, recovered);
let phrase_back = phrase_from_key(&key).unwrap();
assert_eq!(phrase, phrase_back);
}
#[test]
fn recovery_error_display() {
let e = RecoveryError::Bip39("bad mnemonic".into());
assert!(e.to_string().contains("bad mnemonic"));
let e = RecoveryError::InvalidKey("not a key".into());
assert!(e.to_string().contains("not a key"));
}
}