Remove wallet hot signer mnemonics on wallet delete.

Mnemonics are now stored with the following format:
mnemonic-fingerprint-checksum-timstamp.txt

checksum and timestamp are the new way to identify wallets.

When deleting wallet user expects any mnemonic related
to the wallet to be removed.

For legacy wallets, any mnemonic-fingerprint.txt file will be deleted.
This commit is contained in:
edouardparis 2025-05-16 16:40:08 +02:00
parent b6f45a42ee
commit 5baf281204
4 changed files with 265 additions and 16 deletions

View File

@ -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(())
}

View File

@ -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");

View File

@ -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<secp256k1::All>,
@ -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<i64>,
) -> 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(())
}

View File

@ -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<impl secp256k1::Signing>,
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<Self, Self::Err> {
// 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::<i64>()
.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());
}
}