diff --git a/liana-gui/src/delete.rs b/liana-gui/src/delete.rs index dd3c27b1..dcd6b285 100644 --- a/liana-gui/src/delete.rs +++ b/liana-gui/src/delete.rs @@ -4,6 +4,7 @@ use crate::{ app::settings::{self, SettingsError, WalletId}, dir::NetworkDirectory, services::connect::client::cache::{self, ConnectCacheError}, + signer, }; pub enum DeleteError { @@ -78,5 +79,12 @@ pub async fn delete_wallet( .await .map_err(DeleteError::Connect)?; + signer::delete_wallet_mnemonics( + network_dir, + &wallet_id.descriptor_checksum, + wallet_id.timestamp, + ) + .map_err(DeleteError::Io)?; + Ok(()) } diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index e1776897..bbc200cb 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -413,7 +413,7 @@ pub async fn install_local_wallet( let wallet_settings = WalletSettings { name: wallet_name(descriptor), pinned_at: wallet_id.timestamp, - descriptor_checksum: wallet_id.descriptor_checksum, + descriptor_checksum: wallet_id.descriptor_checksum.clone(), keys: ctx.keys.values().cloned().collect(), hardware_wallets, remote_backend_auth: None, @@ -449,7 +449,14 @@ pub async fn install_local_wallet( signer .lock() .unwrap() - .store(&ctx.liana_directory, cfg.bitcoin_config.network) + .store( + &ctx.liana_directory, + cfg.bitcoin_config.network, + &wallet_id.descriptor_checksum, + wallet_id + .timestamp + .expect("Every new wallet have now a timestamp"), + ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; info!("Hot signer mnemonic stored"); @@ -457,7 +464,14 @@ pub async fn install_local_wallet( if let Some(signer) = &ctx.recovered_signer { signer - .store(&ctx.liana_directory, cfg.bitcoin_config.network) + .store( + &ctx.liana_directory, + cfg.bitcoin_config.network, + &wallet_id.descriptor_checksum, + wallet_id + .timestamp + .expect("Every new wallet have now a timestamp"), + ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; info!("Recovered signer mnemonic stored"); @@ -517,7 +531,14 @@ pub async fn create_remote_wallet( signer .lock() .unwrap() - .store(&ctx.liana_directory, ctx.network) + .store( + &ctx.liana_directory, + ctx.network, + &wallet_id.descriptor_checksum, + wallet_id + .timestamp + .expect("Every new wallet have now a timestamp"), + ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; info!("Hot signer mnemonic stored"); @@ -525,7 +546,14 @@ pub async fn create_remote_wallet( if let Some(signer) = &ctx.recovered_signer { signer - .store(&ctx.liana_directory, ctx.network) + .store( + &ctx.liana_directory, + ctx.network, + &wallet_id.descriptor_checksum, + wallet_id + .timestamp + .expect("Every new wallet have now a timestamp"), + ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; info!("Recovered signer mnemonic stored"); @@ -646,7 +674,14 @@ pub async fn import_remote_wallet( if let Some(signer) = &ctx.recovered_signer { signer - .store(&ctx.liana_directory, ctx.network) + .store( + &ctx.liana_directory, + ctx.network, + &wallet_id.descriptor_checksum, + wallet_id + .timestamp + .expect("Every new wallet have now a timestamp"), + ) .map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?; info!("Recovered signer mnemonic stored"); diff --git a/liana-gui/src/signer.rs b/liana-gui/src/signer.rs index be237ead..16842367 100644 --- a/liana-gui/src/signer.rs +++ b/liana-gui/src/signer.rs @@ -1,4 +1,5 @@ pub use liana::signer::SignerError; +use std::str::FromStr; use liana::{ miniscript::bitcoin::{ @@ -6,10 +7,10 @@ use liana::{ psbt::Psbt, secp256k1, Network, }, - signer::HotSigner, + signer::{self, HotSigner}, }; -use crate::dir::LianaDirectory; +use crate::dir::{LianaDirectory, NetworkDirectory}; pub struct Signer { curve: secp256k1::Secp256k1, @@ -62,7 +63,52 @@ impl Signer { &self, datadir_root: &LianaDirectory, network: Network, + checksum: &str, + timestamp: i64, ) -> Result<(), SignerError> { - self.key.store(datadir_root.path(), network, &self.curve) + self.key.store( + datadir_root.path(), + network, + &self.curve, + Some((checksum.to_string(), timestamp)), + ) } } + +pub fn delete_wallet_mnemonics( + network_directory: &NetworkDirectory, + descriptor_checksum: &str, + pinned_at: Option, +) -> Result<(), std::io::Error> { + let folder = network_directory + .path() + .join(signer::MNEMONICS_FOLDER_NAME) + .to_path_buf(); + if folder.exists() { + for entry in std::fs::read_dir(&folder)? { + let path = entry?.path(); + if let Some(filename) = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|s| signer::MnemonicFileName::from_str(s).ok()) + { + match (pinned_at, filename.descriptor_info) { + // legacy wallet, we delete any mnemonic-{}.txt + (None, None) => { + std::fs::remove_file(&path)?; + } + // we delete any mnemonic-fg-sum-tim.txt that matches the descriptor_checksum + // and timestamp + (Some(t), Some(info)) => { + if info.0 == descriptor_checksum && t == info.1 { + std::fs::remove_file(&path)?; + } + } + _ => { // The file is not related to the wallet} + } + } + } + } + } + Ok(()) +} diff --git a/liana/src/signer.rs b/liana/src/signer.rs index bbf1b2ce..2d88b967 100644 --- a/liana/src/signer.rs +++ b/liana/src/signer.rs @@ -15,7 +15,7 @@ use std::{ use miniscript::bitcoin::{ self, - bip32::{self, Error as Bip32Error}, + bip32::{self, Error as Bip32Error, Fingerprint}, ecdsa, hashes::Hash, key::TapTweak, @@ -186,22 +186,26 @@ impl HotSigner { /// Store the mnemonic in a file within the given "data directory". /// The file is stored within a "mnemonics" folder, with the filename set to the fingerprint of /// the master xpub corresponding to this mnemonic. + /// returns the filename pub fn store( &self, datadir_root: &path::Path, network: bitcoin::Network, secp: &secp256k1::Secp256k1, + descriptor_info: Option<(String, i64)>, ) -> Result<(), SignerError> { - let mut mnemonics_folder = Self::mnemonics_folder(datadir_root, network); + let mnemonics_folder = Self::mnemonics_folder(datadir_root, network); if !mnemonics_folder.exists() { create_dir(&mnemonics_folder).map_err(SignerError::MnemonicStorage)?; } // This will fail if a file with this fingerprint exists already. - mnemonics_folder.push(format!("mnemonic-{:x}.txt", self.fingerprint(secp))); - let mnemonic_path = mnemonics_folder; - let mut mnemonic_file = - create_file(&mnemonic_path).map_err(SignerError::MnemonicStorage)?; + let filename = MnemonicFileName { + fingerprint: self.fingerprint(secp), + descriptor_info, + }; + let mut mnemonic_file = create_file(&mnemonics_folder.join(filename.to_string())) + .map_err(SignerError::MnemonicStorage)?; mnemonic_file .write_all(self.mnemonic_str().as_bytes()) .map_err(SignerError::MnemonicStorage)?; @@ -404,6 +408,95 @@ impl HotSigner { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MnemonicFileName { + pub fingerprint: Fingerprint, + pub descriptor_info: Option<(String, i64)>, // (descriptor_checksum, timestamp) +} + +impl fmt::Display for MnemonicFileName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.descriptor_info { + Some((checksum, timestamp)) => { + write!( + f, + "mnemonic-{}-{}-{}.txt", + self.fingerprint, checksum, timestamp + ) + } + None => { + write!(f, "mnemonic-{}.txt", self.fingerprint) + } + } + } +} + +#[derive(Debug)] +pub enum MnemonicFileNameError { + InvalidFormat, + InvalidFingerprint, + InvalidTimestamp, +} + +impl fmt::Display for MnemonicFileNameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MnemonicFileNameError::InvalidFormat => write!(f, "Invalid mnemonic file name format"), + MnemonicFileNameError::InvalidFingerprint => write!(f, "Invalid fingerprint format"), + MnemonicFileNameError::InvalidTimestamp => write!(f, "Invalid timestamp format"), + } + } +} + +impl std::error::Error for MnemonicFileNameError {} + +// Implementation of FromStr for MnemonicFileName +impl FromStr for MnemonicFileName { + type Err = MnemonicFileNameError; + + fn from_str(s: &str) -> Result { + // Check if the string starts with "mnemonic-" and ends with ".txt" + if !s.starts_with("mnemonic-") || !s.ends_with(".txt") { + return Err(MnemonicFileNameError::InvalidFormat); + } + + let content = s + .strip_prefix("mnemonic-") + .expect("Already checked") + .strip_suffix(".txt") + .expect("Already checked"); + + let parts: Vec<&str> = content.split('-').collect(); + match parts.len() { + 1 => { + // Only fingerprint + let fingerprint = Fingerprint::from_str(parts[0]) + .map_err(|_| MnemonicFileNameError::InvalidFingerprint)?; + + Ok(MnemonicFileName { + fingerprint, + descriptor_info: None, + }) + } + 3 => { + // Fingerprint + checksum + timestamp + let fingerprint = Fingerprint::from_str(parts[0]) + .map_err(|_| MnemonicFileNameError::InvalidFingerprint)?; + + let timestamp = parts[2] + .parse::() + .map_err(|_| MnemonicFileNameError::InvalidTimestamp)?; + + Ok(MnemonicFileName { + fingerprint, + descriptor_info: Some((parts[1].to_string(), timestamp)), + }) + } + _ => Err(MnemonicFileNameError::InvalidFormat), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -471,7 +564,7 @@ mod tests { let words_set: HashSet<_> = (0..10) .map(|_| { let signer = HotSigner::generate(network).unwrap(); - signer.store(&tmp_dir, network, &secp).unwrap(); + signer.store(&tmp_dir, network, &secp, None).unwrap(); signer.words() }) .collect(); @@ -1087,4 +1180,71 @@ mod tests { ); } } + + #[test] + fn test_mnemonic_filename() { + // Test to_string with descriptor info + let fingerprint = Fingerprint::from_str("abcd1234").unwrap(); + let filename_with_info = MnemonicFileName { + fingerprint, + descriptor_info: Some(("def456".to_string(), 1620000000)), + }; + + assert_eq!( + filename_with_info.to_string(), + "mnemonic-abcd1234-def456-1620000000.txt" + ); + + // Test to_string without descriptor info + let filename_without_info = MnemonicFileName { + fingerprint, + descriptor_info: None, + }; + + assert_eq!(filename_without_info.to_string(), "mnemonic-abcd1234.txt"); + + // Test from_str with descriptor info + let input_with_info = "mnemonic-abcd1234-def456-1620000000.txt"; + let parsed_with_info = MnemonicFileName::from_str(input_with_info).unwrap(); + + assert_eq!(parsed_with_info.fingerprint, fingerprint); + assert_eq!( + parsed_with_info.descriptor_info, + Some(("def456".to_string(), 1620000000)) + ); + + // Test from_str without descriptor info + let input_without_info = "mnemonic-abcd1234.txt"; + let parsed_without_info = MnemonicFileName::from_str(input_without_info).unwrap(); + + assert_eq!(parsed_without_info.fingerprint, fingerprint); + assert_eq!(parsed_without_info.descriptor_info, None); + + // Test roundtrip with descriptor info + let roundtrip_with_info = + MnemonicFileName::from_str(&filename_with_info.to_string()).unwrap(); + assert_eq!(filename_with_info, roundtrip_with_info); + + // Test roundtrip without descriptor info + let roundtrip_without_info = + MnemonicFileName::from_str(&filename_without_info.to_string()).unwrap(); + assert_eq!(filename_without_info, roundtrip_without_info); + + // Test error cases + + // Missing prefix + assert!(MnemonicFileName::from_str("abcd1234.txt").is_err()); + + // Missing suffix + assert!(MnemonicFileName::from_str("mnemonic-abcd1234").is_err()); + + // Wrong number of parts + assert!(MnemonicFileName::from_str("mnemonic-abcd1234-def456.txt").is_err()); + + // Invalid fingerprint (assuming Fingerprint::from_str fails for "invalid") + assert!(MnemonicFileName::from_str("mnemonic-invalid-def456-1620000000.txt").is_err()); + + // Invalid timestamp + assert!(MnemonicFileName::from_str("mnemonic-abcd1234-def456-notanumber.txt").is_err()); + } }