diff --git a/Cargo.lock b/Cargo.lock index baa321d3e..f52d778f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,7 +206,7 @@ dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand", + "rand 0.8.5", "raw-window-handle", "serde", "serde_repr", @@ -619,6 +619,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin-encrypted-backup" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b89ad3b53c1425530260f9080fe1a968816d762f9467c82d8e57a5d638b90aa" +dependencies = [ + "aes-gcm", + "miniscript", + "num_enum", + "rand 0.9.2", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -980,7 +992,7 @@ dependencies = [ "ctr", "hidapi", "k256", - "rand", + "rand 0.8.5", ] [[package]] @@ -1218,7 +1230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -1230,6 +1242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1513,7 +1526,7 @@ dependencies = [ "generic-array", "group", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -1689,7 +1702,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -2107,7 +2120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -3077,6 +3090,7 @@ dependencies = [ "async-trait", "backtrace", "base64 0.21.7", + "bitcoin-encrypted-backup", "bitcoin_hashes 0.12.0", "chrono", "dirs 3.0.2", @@ -4091,7 +4105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -4417,8 +4431,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -4428,7 +4452,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -4440,6 +4474,15 @@ dependencies = [ "getrandom 0.2.15", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.1", +] + [[package]] name = "range-alloc" version = "0.1.4" @@ -4484,7 +4527,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" dependencies = [ - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -5077,7 +5120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -6737,7 +6780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "zeroize", ] diff --git a/liana-gui/Cargo.toml b/liana-gui/Cargo.toml index 4e0db6573..331a2b059 100644 --- a/liana-gui/Cargo.toml +++ b/liana-gui/Cargo.toml @@ -59,6 +59,8 @@ fs2 = "0.4.3" # Used for opening URLs in browser open = "5.3" +encrypted_backup = { version = "0.0.1", default-features=false, features = ["miniscript_12_0"], package = "bitcoin-encrypted-backup" } + [target.'cfg(windows)'.dependencies] zip = { version = "0.6", default-features=false, features = ["bzip2", "deflate"] } diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs index 2105b725f..80bb5627c 100644 --- a/liana-gui/src/app/state/export.rs +++ b/liana-gui/src/app/state/export.rs @@ -76,15 +76,14 @@ impl ExportModal { ImportExportType::ExportPsbt(_) => "Export PSBT", ImportExportType::ExportXpub(_) => "Export Xpub", ImportExportType::ImportXpub(_) => "Import Xpub", - ImportExportType::ExportProcessBackup(..) | ImportExportType::ExportBackup(_) => { - "Export Backup" - } + ImportExportType::ExportProcessBackup(..) => "Export Backup", + ImportExportType::ExportEncryptedDescriptor(_) => "Export Encrypted Descriptor", ImportExportType::Descriptor(_) => "Export Descriptor", ImportExportType::ExportLabels => "Export Labels", ImportExportType::ImportPsbt(_) => "Import PSBT", ImportExportType::ImportDescriptor => "Import Descriptor", ImportExportType::ImportBackup { .. } => "Restore Backup", - ImportExportType::WalletFromBackup => "Import existing wallet from backup", + ImportExportType::FromBackup => "Import existing wallet from backup", } } @@ -105,13 +104,14 @@ impl ExportModal { .to_string(); format!("liana-{}.txt", checksum) } + ImportExportType::ExportEncryptedDescriptor(_) => "liana.bed".into(), ImportExportType::ImportPsbt(_) => "psbt.psbt".into(), ImportExportType::ImportDescriptor => "descriptor.txt".into(), ImportExportType::ExportLabels => format!("liana-labels-{date}.jsonl"), - ImportExportType::ExportBackup(_) | ImportExportType::ExportProcessBackup(..) => { + ImportExportType::ExportProcessBackup(..) => { format!("liana-backup-{date}.json") } - ImportExportType::WalletFromBackup | ImportExportType::ImportBackup { .. } => { + ImportExportType::FromBackup | ImportExportType::ImportBackup { .. } => { "liana-backup.json".to_string() } } @@ -191,8 +191,7 @@ impl ExportModal { ImportExportMessage::UpdateAliases(map.clone()).into() }); } - Progress::WalletFromBackup(_) => {} - Progress::Psbt(_) => {} + Progress::WalletFromBackup(_) | Progress::EncryptedFile(_) | Progress::Psbt(_) => {} }, ImportExportMessage::TimedOut => { self.stop(ImportExportState::TimedOut); diff --git a/liana-gui/src/app/state/settings/mod.rs b/liana-gui/src/app/state/settings/mod.rs index d8fecc7b9..a97a381e8 100644 --- a/liana-gui/src/app/state/settings/mod.rs +++ b/liana-gui/src/app/state/settings/mod.rs @@ -253,7 +253,9 @@ impl State for ImportExportSettingsState { return modal.update(m); }; } - Message::View(view::Message::Settings(view::SettingsMessage::ExportDescriptor)) => { + Message::View(view::Message::Settings( + view::SettingsMessage::ExportEncryptedDescriptor, + )) => { if self.modal.is_none() { let modal = ExportModal::new( Some(daemon), diff --git a/liana-gui/src/app/state/settings/wallet.rs b/liana-gui/src/app/state/settings/wallet.rs index 37b24798a..59b69720a 100644 --- a/liana-gui/src/app/state/settings/wallet.rs +++ b/liana-gui/src/app/state/settings/wallet.rs @@ -54,7 +54,7 @@ pub struct WalletSettingsState { modal: Modal, processing: bool, updated: bool, - config: Arc, + _config: Arc, } impl WalletSettingsState { @@ -73,7 +73,7 @@ impl WalletSettingsState { modal: Modal::None, processing: false, updated: false, - config, + _config: config, } } @@ -271,41 +271,20 @@ impl State for WalletSettingsState { Task::none() } } - Message::View(view::Message::Settings(view::SettingsMessage::ExportWallet)) => { + Message::View(view::Message::Settings( + view::SettingsMessage::ExportEncryptedDescriptor, + )) => { if self.modal.is_none() { - let datadir = cache.datadir_path.clone(); - let network = cache.network; - let config = self.config.clone(); - let wallet = self.wallet.clone(); - let daemon = daemon.clone(); + let descriptor = self.wallet.main_descriptor.clone(); let modal = ExportModal::new( Some(daemon), - ImportExportType::ExportProcessBackup(datadir, network, config, wallet), + ImportExportType::ExportEncryptedDescriptor(Box::new(descriptor)), ); let launch = modal.launch(true); self.modal = Modal::ImportExport(modal); - launch - } else { - Task::none() - } - } - Message::View(view::Message::Settings(view::SettingsMessage::ImportWallet)) => { - if self.modal.is_none() { - let modal = ExportModal::new( - Some(daemon), - ImportExportType::ImportBackup { - network_dir: cache.datadir_path.network_directory(cache.network), - wallet: self.wallet.clone(), - overwrite_labels: None, - overwrite_aliases: None, - }, - ); - let launch = modal.launch(false); - self.modal = Modal::ImportExport(modal); - launch - } else { - Task::none() + return launch; } + Task::none() } _ => match &mut self.modal { Modal::RegisterWallet(m) => m.update(daemon, cache, message), diff --git a/liana-gui/src/app/view/message.rs b/liana-gui/src/app/view/message.rs index 19dc39023..3c86f064f 100644 --- a/liana-gui/src/app/view/message.rs +++ b/liana-gui/src/app/view/message.rs @@ -100,7 +100,7 @@ pub enum SettingsMessage { RemoteBackendSettings(RemoteBackendSettingsMessage), EditWalletSettings, ImportExportSection, - ExportDescriptor, + ExportEncryptedDescriptor, ExportTransactions, ExportLabels, ExportWallet, diff --git a/liana-gui/src/app/view/settings/mod.rs b/liana-gui/src/app/view/settings/mod.rs index 7a931a165..6a43392d5 100644 --- a/liana-gui/src/app/view/settings/mod.rs +++ b/liana-gui/src/app/view/settings/mod.rs @@ -218,9 +218,9 @@ pub fn import_export<'a>(cache: &'a Cache, warning: Option<&Error>) -> Element<' let export_descriptor = export_section( "Descriptor only", - "Descriptor file only, to use with other wallets.", + "Plain-text descriptor file only, to use with other wallets.", icon::backup_icon(), - Message::Settings(SettingsMessage::ExportDescriptor), + Message::Settings(SettingsMessage::ExportEncryptedDescriptor), ); let export_transactions = export_section( @@ -238,14 +238,14 @@ pub fn import_export<'a>(cache: &'a Cache, warning: Option<&Error>) -> Element<' ); let export_wallet = export_section( - "Back up wallet", - "File with wallet info needed to restore on other devices (no private keys).", + "Export wallet", + "File with wallet info useful to sync labels and data on other devices.", icon::backup_icon(), Message::Settings(SettingsMessage::ExportWallet), ); let import_wallet = export_section( - "Restore wallet", + "Import wallet", "Upload a backup file to update wallet info.", icon::restore_icon(), Message::Settings(SettingsMessage::ImportWallet), @@ -1010,18 +1010,6 @@ pub fn wallet_settings<'a>( ) -> Element<'a, Message> { let header = header("Wallet", SettingsMessage::EditWalletSettings); - let import_export = Row::new() - .push( - button::secondary(Some(icon::backup_icon()), "Backup") - .on_press(Message::Settings(SettingsMessage::ExportWallet)), - ) - .push(Space::with_width(10)) - .push( - button::secondary(Some(icon::restore_icon()), "Restore") - .on_press(Message::Settings(SettingsMessage::ImportWallet)), - ) - .push(Space::with_width(Length::Fill)); - let descr = card::simple( Column::new() .push(text("Wallet descriptor:").bold()) @@ -1039,6 +1027,12 @@ pub fn wallet_settings<'a>( Row::new() .spacing(10) .push(Column::new().width(Length::Fill)) + .push( + button::secondary(Some(icon::backup_icon()), "Back up Descriptor") + .on_press(Message::Settings( + SettingsMessage::ExportEncryptedDescriptor, + )), + ) .push( button::secondary(Some(icon::clipboard_icon()), "Copy") .on_press(Message::Clipboard(descriptor.to_string())), @@ -1118,7 +1112,6 @@ pub fn wallet_settings<'a>( Column::new() .spacing(20) .push(header) - .push(import_export) .push(descr) .push( card::simple(display_policy( diff --git a/liana-gui/src/backup.rs b/liana-gui/src/backup.rs index 4c8214607..e0be0d6d5 100644 --- a/liana-gui/src/backup.rs +++ b/liana-gui/src/backup.rs @@ -22,13 +22,12 @@ use tokio::sync::mpsc::UnboundedSender; use crate::{ app::{ settings::{Settings, WalletSettings}, - wallet::{wallet_name, Wallet}, + wallet::Wallet, Config, }, daemon::{model::HistoryTransaction, Daemon, DaemonBackend, DaemonError}, dir::LianaDirectory, export::Progress, - installer::Context, services::connect::client::backend::api::DEFAULT_LIMIT, utils::now, VERSION, @@ -124,37 +123,6 @@ impl Backup { version: default_version(), } } - /// Create a Backup from the Installer context - /// - /// # Arguments - /// * `ctx` - the installer context - pub async fn from_installer_descriptor_step(ctx: Context) -> Result { - let descriptor = ctx.descriptor.clone().ok_or(Error::DescriptorMissing)?; - - let now = now().as_secs(); - let name = Some(wallet_name(&descriptor)); - - let mut account = Account::new(descriptor.to_string()); - account.name = name.clone(); - account.timestamp = Some(now); - account - .proprietary - .insert(LIANA_VERSION_KEY.to_string(), liana_version().into()); - - ctx.keys.iter().for_each(|(k, s)| { - account.keys.insert(*k, s.to_backup()); - }); - - Ok(Backup { - name, - alias: None, - accounts: vec![account], - network: ctx.network, - proprietary: serde_json::Map::new(), - date: Some(now), - version: 0, - }) - } /// Create a Backup from the Liana App context pub async fn from_app( diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs index 853b40ffd..38d4bad22 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -9,12 +9,13 @@ use std::{ time, }; +use encrypted_backup::{descriptor::dpk_to_pk, Decrypted, EncryptedBackup}; use tokio::sync::mpsc::{channel, unbounded_channel, Sender, UnboundedReceiver, UnboundedSender}; use async_hwi::bitbox::api::btc::Fingerprint; use chrono::{DateTime, Duration, Utc}; use liana::{ - descriptors::LianaDescriptor, + descriptors::{bip341_nums, LianaDescriptor}, miniscript::{ bitcoin::{Amount, Network, Psbt, Txid}, DescriptorPublicKey, @@ -122,11 +123,14 @@ pub enum Error { Bip329Export(String), BackupImport(String), Backup(backup::Error), + EncryptedBackup(encrypted_backup::Error), + EncryptionFailed, ParseXpub, XpubNetwork, TxidNotMatch, InsanePsbt, OutpointNotOwned, + UnknownFormat, } impl Display for Error { @@ -154,6 +158,9 @@ impl Display for Error { f, "Import failed. The PSBT either doesn't belong to the wallet or has already been spent." ), + Error::EncryptedBackup(e) => write!(f, "Failed to encrypt backup: {e:?}"), + Error::UnknownFormat => write!(f, "Format of the file unknown"), + Error::EncryptionFailed => write!(f, "Encryption failed, please contact Wizarsardine team.") } } } @@ -163,7 +170,7 @@ pub enum ImportExportType { Transactions, ExportPsbt(String), ExportXpub(String), - ExportBackup(String), + ExportEncryptedDescriptor(Box), ExportProcessBackup(LianaDirectory, Network, Arc, Arc), ImportBackup { network_dir: NetworkDirectory, @@ -171,7 +178,7 @@ pub enum ImportExportType { overwrite_labels: Option>, overwrite_aliases: Option>, }, - WalletFromBackup, + FromBackup, Descriptor(LianaDescriptor), ExportLabels, ImportPsbt(Option), @@ -184,15 +191,15 @@ impl ImportExportType { match self { ImportExportType::Transactions | ImportExportType::ExportPsbt(_) - | ImportExportType::ExportBackup(_) | ImportExportType::Descriptor(_) | ImportExportType::ExportProcessBackup(..) | ImportExportType::ExportXpub(_) + | ImportExportType::ExportEncryptedDescriptor(_) | ImportExportType::ExportLabels => "Export successful!", ImportExportType::ImportBackup { .. } | ImportExportType::ImportPsbt(_) | ImportExportType::ImportXpub(_) - | ImportExportType::WalletFromBackup + | ImportExportType::FromBackup | ImportExportType::ImportDescriptor => "Import successful", } } @@ -222,6 +229,12 @@ impl From for Error { } } +impl From for Error { + fn from(value: encrypted_backup::Error) -> Self { + Error::EncryptedBackup(value) + } +} + #[derive(Debug)] pub enum Status { Init, @@ -251,6 +264,7 @@ pub enum Progress { Backup, ), ), + EncryptedFile(Vec), } pub struct Export { @@ -295,7 +309,9 @@ impl Export { ImportExportType::ImportPsbt(txid) => import_psbt(daemon, &sender, path, txid).await, ImportExportType::ImportXpub(network) => import_xpub(&sender, path, network).await, ImportExportType::ImportDescriptor => import_descriptor(&sender, path).await, - ImportExportType::ExportBackup(str) => export_string(&sender, path, str).await, + ImportExportType::ExportEncryptedDescriptor(descr) => { + export_encrypted_descriptor(&sender, path, *descr).await + } ImportExportType::ExportXpub(xpub_str) => export_string(&sender, path, xpub_str).await, ImportExportType::ExportProcessBackup(datadir, network, config, wallet) => { app_backup_export( @@ -314,7 +330,7 @@ impl Export { wallet, .. } => import_backup(&network_dir, wallet, &sender, path, daemon).await, - ImportExportType::WalletFromBackup => wallet_from_backup(&sender, path).await, + ImportExportType::FromBackup => from_backup(&sender, path).await, } { if let Err(e) = sender.send(Progress::Error(e)) { tracing::error!("Import/Export fail to send msg: {}", e); @@ -582,6 +598,59 @@ pub async fn export_string( Ok(()) } +pub async fn export_encrypted_descriptor( + sender: &UnboundedSender, + path: PathBuf, + descr: LianaDescriptor, +) -> Result<(), Error> { + let descriptor = descr.descriptor(); + let bytes = EncryptedBackup::new().set_payload(descriptor)?.encrypt()?; + + send_progress!(sender, Progress(30.0)); + // verify we can decrypt with any keys from the descriptor + for key in descr.spendable_keys() { + let decrypted = EncryptedBackup::new() + .set_encrypted_payload(&bytes) + .map_err(|_| Error::EncryptionFailed)? + .set_keys(vec![dpk_to_pk(&key)]) + .decrypt() + .map_err(|_| Error::EncryptionFailed)?; + if let Decrypted::Descriptor(d) = decrypted { + if descr.to_string() != d.to_string() { + return Err(Error::EncryptionFailed); + } + } else { + return Err(Error::EncryptionFailed); + } + } + + // verify we can NOT decrypt w/ unspendable key or BIP341 NUMS + if let Some(unspendable) = descr.process_unspendable_key() { + let unspendable = dpk_to_pk(&unspendable); + let encrypted = EncryptedBackup::new() + .set_encrypted_payload(&bytes) + .map_err(|_| Error::EncryptionFailed)? + .set_keys(vec![unspendable]); + if encrypted.decrypt().is_ok() { + return Err(Error::EncryptionFailed); + } + } + let nums = bip341_nums(); + let encrypted = EncryptedBackup::new() + .set_encrypted_payload(&bytes) + .map_err(|_| Error::EncryptionFailed)? + .set_keys(vec![nums]); + if encrypted.decrypt().is_ok() { + return Err(Error::EncryptionFailed); + } + + let mut file = open_file_write(&path).await?; + file.write_all(&bytes)?; + send_progress!(sender, Progress(100.0)); + send_progress!(sender, Ended); + Ok(()) +} + pub async fn import_psbt( daemon: Option>, sender: &UnboundedSender, @@ -1027,22 +1096,33 @@ impl From for RestoreBackupError { } } -/// Create a wallet from a backup -/// - load backup from file -/// - extract descriptor -/// - extract network -/// - extract aliases -pub async fn wallet_from_backup( - sender: &UnboundedSender, - path: PathBuf, -) -> Result<(), Error> { - // Load backup from file +/// Try to import descriptor/backup from file, several input types are supported: +/// - encrypted file +/// - liana wallet backup +/// - plaintext descriptor +pub async fn from_backup(sender: &UnboundedSender, path: PathBuf) -> Result<(), Error> { + // Load file let mut file = File::open(path)?; - let mut backup_str = String::new(); - file.read_to_string(&mut backup_str)?; - backup_str = backup_str.trim().to_string(); + let mut bytes = vec![]; + if let Err(e) = file.read_to_end(&mut bytes) { + return Err(Error::Io(e.to_string())); + } + + // first we try to parse as an encrypted backup + if EncryptedBackup::new().set_encrypted_payload(&bytes).is_ok() { + send_progress!(sender, EncryptedFile(bytes)); + send_progress!(sender, Progress(100.0)); + send_progress!(sender, Ended); + return Ok(()); + } + let backup_str = match String::from_utf8(bytes) { + Ok(s) => s, + Err(_) => return Err(Error::UnknownFormat), + }; + + // else we try to parse as plaintetxt descriptor or backup file let backup: Result = serde_json::from_str(&backup_str); let backup = match backup { Ok(b) => b, @@ -1308,6 +1388,8 @@ pub async fn app_backup_export( mod tests { use std::env; + use encrypted_backup::Version; + use super::*; #[tokio::test] @@ -1414,4 +1496,40 @@ mod tests { let expected = "[c658b283/48'/1'/3'/2']tpubDFmeRMxr4X7dxNKxxBKWXu1rskHEQYB8vY5PYPmiB74EjyrE814HHpQzh2XEFpm3z5uJpk7Cjt2hmhcMYmBbot6CmRHn3CKK2K6vzLPBMbH".to_string(); assert_eq!(expected, parse_coldcard_xpub(raw).unwrap().to_string()); } + + #[test] + fn test_import_encrypted_descriptor_v0() { + let descr_str = "tr(tpubD6NzVbkrYhZ4XYBa9huubPUVRzmGGJoEjFF4i93okdJUBSiDenCbHMAZkjWYWiWoruNEgXouXEdKBL9gDWuxem4gwJMBEs3TVhhNw7AybcA/<0;1>/*,{and_v(v:multi_a(1,[bf3891f9/48'/1'/0'/2']tpubDF8FX2wbUi7rFS4xC8BfYDu6AScYFvRQBwouJeu2DzE55gJgQk2mSa56D9VbyU9YVdWNqWdFBqhWtV9ixZGbd83SRhr7EZUvGJ8QfYazwks/<2;3>/*,[08d94091/48'/1'/0'/2']tpubDEPcGiMo7Z9bwxKvqGU6Zwis2xoESJGKmbcX9Eu6puUgriny9UDCHCF1CpZyGT8s1Kj5diyT2kbe7tj1caWwVb2UYNF129rwNobcq4KTQbs/<2;3>/*),older(52596)),and_v(v:pk([bf3891f9/48'/1'/0'/2']tpubDF8FX2wbUi7rFS4xC8BfYDu6AScYFvRQBwouJeu2DzE55gJgQk2mSa56D9VbyU9YVdWNqWdFBqhWtV9ixZGbd83SRhr7EZUvGJ8QfYazwks/<0;1>/*),pk([08d94091/48'/1'/0'/2']tpubDEPcGiMo7Z9bwxKvqGU6Zwis2xoESJGKmbcX9Eu6puUgriny9UDCHCF1CpZyGT8s1Kj5diyT2kbe7tj1caWwVb2UYNF129rwNobcq4KTQbs/<0;1>/*))})#dn0kkwfc"; + + let descriptor = LianaDescriptor::from_str(descr_str).unwrap(); + let keys = descriptor + .spendable_keys() + .into_iter() + .map(|k| dpk_to_pk(&k)) + .collect::>(); + + let path = env::current_dir() + .unwrap() + .join("test_assets") + .join("v0.bed"); + + let mut file = File::open(path).unwrap(); + let mut bytes: Vec = vec![]; + file.read_to_end(&mut bytes).unwrap(); + + let backp = EncryptedBackup::new() + .set_encrypted_payload(&bytes) + .unwrap(); + + assert_eq!(backp.get_version(), Version::V0); + + for k in keys { + let descr = backp.clone().set_keys(vec![k]).decrypt().unwrap(); + if let Decrypted::Descriptor(d) = descr { + assert_eq!(d.to_string(), descriptor.to_string()); + } else { + panic!("not a descriptor") + } + } + } } diff --git a/liana-gui/src/installer/decrypt.rs b/liana-gui/src/installer/decrypt.rs new file mode 100644 index 000000000..a4ce7a375 --- /dev/null +++ b/liana-gui/src/installer/decrypt.rs @@ -0,0 +1,649 @@ +use std::{ + collections::{BTreeMap, HashSet}, + fmt::Debug, + str::FromStr, + sync::Arc, +}; + +use async_hwi::{bitbox::api::btc::Fingerprint, DeviceKind, Version, HWI}; +use encrypted_backup::{Decrypted, EncryptedBackup}; +use iced::{ + alignment, clipboard, + widget::{column, row, scrollable, Column, Space}, + Length, Task, +}; +use liana::{ + bip39::Mnemonic, + descriptors::LianaDescriptor, + miniscript::{ + bitcoin::{ + bip32::{self, DerivationPath}, + key::Secp256k1, + secp256k1, Network, + }, + DescriptorPublicKey, + }, +}; +use liana_ui::{ + component::{ + card, + form::Value, + modal::{self, widget_style, BTN_W}, + text::{self, p1_regular}, + }, + icon, + widget::{modal::Modal, Button, Container, Element}, +}; + +use crate::{ + app::state::export::ExportModal, + backup::Backup, + export::ImportExportType, + hw::{HardwareWallet, HardwareWallets}, + installer, + utils::{default_derivation_path, example_xpub}, +}; + +type FnMsg = fn() -> installer::Message; + +#[allow(unused, clippy::enum_variant_names)] +#[derive(Debug, Clone, Copy)] +pub enum Error { + InvalidEncoding, + InvalidType, + InvalidDescriptor, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focus { + None, + ImportXpub, + Xpub, + Mnemonic, +} + +pub struct DecryptModal { + network: Network, + error: Option, + bytes: Vec, + derivation_paths: HashSet, + cant_fetch: BTreeMap, + fetching: BTreeMap, + fetched: BTreeMap, + show_options: bool, + import_xpub_error: Option, + xpub: Value, + xpub_busy: bool, + mnemonic: Value, + mnemonic_busy: bool, + focus: Focus, + pub modal: Option, +} +impl Debug for DecryptModal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DecryptModal") + .field("error", &self.error) + .field("derivation_paths", &self.derivation_paths.len()) + .field("cant_fetch", &self.cant_fetch.len()) + .field("fetching", &self.fetching.len()) + .field("fetched", &self.fetched.len()) + .finish() + } +} + +#[derive(Debug, Clone)] +pub enum Decrypt { + Fetched(Fingerprint, String /* name */), + Backup(Backup), + Xpub(String), + PasteXpub, + SelectXpub, + XpubError(&'static str), + Mnemonic(String), + PasteMnemonic, + SelectMnemonic, + MnemonicStatus(Option<&'static str> /* error */, Option), + SelectImportXpub, + UnexpectedPayload(Decrypted), + InvalidDescriptor, + ContentNotSupported, + ShowOptions(bool), + Close, + CloseModal, + None, +} + +impl From for installer::Message { + fn from(value: Decrypt) -> Self { + installer::Message::Decrypt(value) + } +} + +pub fn decrypt_descriptor_with_pk(bytes: &[u8], pk: secp256k1::PublicKey) -> Option { + match EncryptedBackup::new() + .set_encrypted_payload(bytes) + .expect("sanitized") + .set_keys(vec![pk]) + .decrypt() + { + Ok(dec) => match dec { + Decrypted::Descriptor(d) => { + let descr = match LianaDescriptor::from_str(&d.to_string()) { + Ok(descr) => descr, + Err(_) => return Some(Decrypt::UnexpectedPayload(Decrypted::Descriptor(d))), + }; + let network = if descr.all_xpubs_net_is(Network::Bitcoin) { + Network::Bitcoin + } else { + Network::Signet + }; + Some(Decrypt::Backup(Backup::from_descriptor(descr, network))) + } + Decrypted::WalletBackup(backup_bytes) => { + let backup_str = String::from_utf8(backup_bytes.clone()).ok()?; + let backup: Backup = serde_json::from_str(&backup_str).ok()?; + if backup.accounts.len() != 1 { + return None; + } + let descriptor_str = &backup.accounts.first().expect("checked").descriptor; + let _ = match LianaDescriptor::from_str(descriptor_str) { + Ok(descr) => descr, + Err(_) => { + return Some(Decrypt::UnexpectedPayload(Decrypted::WalletBackup( + backup_bytes, + ))) + } + }; + Some(Decrypt::Backup(backup)) + } + decrypted => Some(Decrypt::UnexpectedPayload(decrypted)), + }, + Err(_) => None, + } +} + +impl DecryptModal { + pub fn new(bytes: Vec, network: Network) -> Self { + let mut error = None; + let derivation_paths = if let Some(backup) = + match EncryptedBackup::new().set_encrypted_payload(&bytes) { + Ok(b) => Some(b), + Err(_) => { + error = Some(Error::InvalidEncoding); + None + } + } { + backup.get_derivation_paths().into_iter().collect() + } else { + let mut h = HashSet::new(); + h.insert(default_derivation_path(Network::Bitcoin)); + h.insert(default_derivation_path(Network::Signet)); + h + }; + Self { + network, + error, + bytes, + derivation_paths, + cant_fetch: BTreeMap::new(), + fetching: BTreeMap::new(), + fetched: BTreeMap::new(), + show_options: false, + import_xpub_error: None, + xpub: Value::default(), + xpub_busy: false, + mnemonic: Value::default(), + mnemonic_busy: false, + focus: Focus::None, + modal: None, + } + } + pub fn update(&mut self, msg: Decrypt) -> Task { + match msg { + Decrypt::Fetched(fg, name) => { + self.fetching.remove(&fg); + self.fetched.insert(fg, name); + Task::none() + } + Decrypt::Backup(_) => { + tracing::error!( + "DecryptModal::update(Backup), this message must have been catched early" + ); + Task::none() + } + Decrypt::XpubError(s) => { + match self.focus { + Focus::ImportXpub => { + self.import_xpub_error = Some(s.to_string()); + } + Focus::Xpub => self.update_xpub_error(s), + Focus::Mnemonic | Focus::None => {} + } + Task::none() + } + + Decrypt::MnemonicStatus(s, fg) => { + self.update_mnemonic_state(s, fg); + Task::none() + } + Decrypt::UnexpectedPayload(p) => match p { + Decrypted::Descriptor(_) => { + tracing::error!("Descriptor decrypted but not a valid liana descriptor"); + Task::done(Decrypt::InvalidDescriptor.into()) + } + _ => { + tracing::error!("Content decrypted but type not supported"); + Task::done(Decrypt::ContentNotSupported.into()) + } + }, + Decrypt::ShowOptions(show) => { + self.show_options = show; + Task::none() + } + Decrypt::Xpub(value) => self.update_xpub(value), + Decrypt::SelectXpub => { + self.focus = Focus::Xpub; + self.import_xpub_error = None; + Task::none() + } + Decrypt::PasteXpub => clipboard::read().map(|m| { + if let Some(xpub) = m { + Decrypt::Xpub(xpub) + } else { + Decrypt::None + } + .into() + }), + Decrypt::Mnemonic(value) => self.update_mnemonic(value), + Decrypt::SelectMnemonic => { + self.focus = Focus::Mnemonic; + self.import_xpub_error = None; + Task::none() + } + Decrypt::PasteMnemonic => clipboard::read().map(|m| { + if let Some(mnemo) = m { + Decrypt::Mnemonic(mnemo) + } else { + Decrypt::None + } + .into() + }), + Decrypt::SelectImportXpub => { + self.focus = Focus::ImportXpub; + self.import_xpub_error = None; + let modal = ExportModal::new(None, ImportExportType::ImportXpub(self.network)); + let launch = modal.launch(false); + self.modal = Some(modal); + launch + } + Decrypt::CloseModal => { + self.modal = None; + Task::none() + } + Decrypt::None + | Decrypt::InvalidDescriptor + | Decrypt::ContentNotSupported + | Decrypt::Close => Task::none(), + } + } + pub fn view<'a>( + &'a self, + content: Element<'a, installer::Message>, + ) -> Element<'a, installer::Message> { + if let Some(mo) = &self.modal { + mo.view(content) + } else { + let modal = Modal::new(content, decrypt_view(self)); + modal.on_blur(Some(Decrypt::Close.into())).into() + } + } + #[allow(clippy::collapsible_match)] + fn fetch( + &self, + device: Arc, + fingerprint: Fingerprint, + name: String, + ) -> Task { + let derivation_paths = self.derivation_paths.clone(); + let bytes = self.bytes.clone(); + Task::perform( + async move { + for path in derivation_paths { + if let Ok(xpub) = device.get_extended_pubkey(&path).await { + let pk = xpub.public_key; + if let Some(d) = decrypt_descriptor_with_pk(&bytes, pk) { + if let d @ Decrypt::Backup(_) | d @ Decrypt::UnexpectedPayload(_) = d { + return d; + } + } + } else { + // FIXME: should we retry here? + tracing::error!( + "Fail to fetch xpub for {} {}", + device.device_kind(), + fingerprint + ); + } + } + Decrypt::Fetched(fingerprint, name) + }, + |m| m.into(), + ) + } + pub fn update_devices( + &mut self, + devices: &mut HardwareWallets, + ) -> Option> { + fn name(kind: DeviceKind, version: Option) -> String { + // FIXME: Capitalize first letter + if let Some(v) = version { + format!("{kind} {v}") + } else { + kind.to_string() + } + } + + let mut new_cant_fetch = BTreeMap::new(); + let mut to_fetch = vec![]; + for d in &devices.list { + match d { + HardwareWallet::Unsupported { + id, kind, version, .. + } => { + new_cant_fetch.insert(id.clone(), name(*kind, version.clone())); + } + HardwareWallet::Locked { id, kind, .. } => { + new_cant_fetch.insert(id.clone(), name(*kind, None)); + } + d => { + if let HardwareWallet::Supported { fingerprint, .. } = d { + if !self.fetched.contains_key(fingerprint) + && !self.fetching.contains_key(fingerprint) + { + to_fetch.push(d); + } + } + } + }; + } + self.cant_fetch = new_cant_fetch; + + let mut batch = vec![]; + for i in to_fetch { + if let HardwareWallet::Supported { + device, + kind, + fingerprint, + version, + .. + } = i + { + let name = name(*kind, version.clone()); + self.fetching.insert(*fingerprint, name.clone()); + batch.push(self.fetch(device.clone(), *fingerprint, name)); + } + } + (!batch.is_empty()).then(|| Task::batch(batch)) + } + fn update_xpub(&mut self, xpub: String) -> Task { + if self.xpub_busy { + return Task::none(); + } + self.xpub.value = xpub.clone(); + if xpub.is_empty() { + self.xpub.valid = true; + self.xpub.warning = None; + return Task::none(); + } + if let Ok(dpk) = DescriptorPublicKey::from_str(&xpub) { + self.xpub_busy = true; + self.xpub.warning = None; + self.xpub.valid = true; + let bytes = self.bytes.clone(); + Task::perform( + async move { + let pk = encrypted_backup::descriptor::dpk_to_pk(&dpk); + decrypt_descriptor_with_pk(&bytes, pk).unwrap_or(Decrypt::XpubError( + "Xpub is valid but cannot decrypt this file", + )) + }, + |m| m.into(), + ) + } else { + self.xpub.warning = Some("Invalid xpub"); + self.xpub.valid = false; + Task::none() + } + } + fn update_xpub_error(&mut self, error: &'static str) { + self.xpub.warning = Some(error); + self.xpub.valid = false; + self.xpub_busy = false; + } + fn update_mnemonic(&mut self, mnemonic: String) -> Task { + if self.mnemonic_busy { + return Task::none(); + } + self.mnemonic.value = mnemonic.clone(); + if mnemonic.is_empty() { + self.mnemonic.valid = true; + self.mnemonic.warning = None; + return Task::none(); + } + let bytes = self.bytes.clone(); + let deriv_paths = self.derivation_paths.clone(); + let network = self.network; + let seed = match Mnemonic::from_str(&mnemonic) { + Ok(m) => m, + Err(_) => { + self.mnemonic.valid = false; + self.mnemonic.warning = Some("Invalid mnemonic"); + return Task::none(); + } + } + .to_seed(""); + self.mnemonic.valid = true; + self.mnemonic.warning = None; + self.mnemonic_busy = true; + Task::perform( + async move { + let xpriv = bip32::Xpriv::new_master(network, &seed).expect("seed is 64 bytes"); + let secp = Secp256k1::new(); + let fingerprint = xpriv.fingerprint(&secp); + + let mut backup = None; + for path in deriv_paths { + let pk = xpriv + .derive_priv(&secp, &path) + .expect("cannot fail") + .private_key + .public_key(&secp); + if let Some(Decrypt::Backup(b)) = decrypt_descriptor_with_pk(&bytes, pk) { + backup = Some(Decrypt::Backup(b)); + } + } + backup.unwrap_or(Decrypt::MnemonicStatus( + Some("Mnemonic is valid but cannot decrypt the file"), + Some(fingerprint), + )) + }, + |m| m.into(), + ) + } + fn update_mnemonic_state(&mut self, error: Option<&'static str>, fg: Option) { + self.mnemonic_busy = false; + self.mnemonic.warning = error; + self.mnemonic.valid = false; + if let Some(fg) = fg { + self.fetched.insert(fg, "Mnemonic".to_string()); + } + self.mnemonic.warning = error; + } +} + +fn invalid_content(hint: &str) -> Container<'_, installer::Message> { + Container::new( + Column::new() + .spacing(5) + .push(Space::with_height(Length::Fill)) + .push( + row![ + Space::with_width(Length::Fill), + icon::warning_icon().size(250), + Space::with_width(Length::Fill), + ] + .align_y(alignment::Vertical::Center), + ) + .push(text::text(hint)) + .push(Space::with_height(Length::Fill)), + ) +} + +fn widget_signing_device( + name: String, + fingerprint: Option, + message: &str, +) -> Button<'_, installer::Message> { + let message = p1_regular(message); + let fg = if let Some(fg) = fingerprint { + format!("#{fg}") + } else { + " - ".to_string() + }; + let designation = + column![text::p1_bold(name), text::p1_regular(fg)].align_x(alignment::Horizontal::Center); + let row = row![ + Space::with_width(5), + designation, + message, + Space::with_width(Length::Fill) + ] + .align_y(alignment::Vertical::Center) + .spacing(10); + Button::new(row).style(widget_style).width(BTN_W) +} + +fn cant_fetch_device(name: String) -> Button<'static, installer::Message> { + let message = "Please unlock or open app on the device"; + widget_signing_device(name, None, message) +} + +fn fetching_device(name: String, fingerprint: Fingerprint) -> Button<'static, installer::Message> { + let message = "Try to decrypt with this device..."; + widget_signing_device(name, Some(fingerprint), message) +} + +fn fetched_device(name: String, fingerprint: Fingerprint) -> Button<'static, installer::Message> { + let message = "Failed to decrypt file with this device"; + widget_signing_device(name, Some(fingerprint), message) +} + +fn valid_content(state: &DecryptModal) -> Container<'static, installer::Message> { + let description = text::text("Plug in and unlock a hardware device belonging to this setup to automatically decrypt the backup"); + let mut devices = state + .fetching + .iter() + .map(|(fg, name)| fetching_device(name.clone(), *fg)) + .collect::>(); + for d in &state.cant_fetch { + devices.push(cant_fetch_device(d.1.clone())); + } + for (fg, name) in &state.fetched { + devices.push(fetched_device(name.clone(), *fg)); + } + let options_btn = modal::optional_section( + state.show_options, + "Other options".to_string(), + || Decrypt::ShowOptions(true).into(), + || Decrypt::ShowOptions(false).into(), + ); + + let mut col = Column::new().spacing(5).push(description); + for d in devices { + col = col.push(d); + } + col = col.push(Space::with_height(10)).push(options_btn); + if state.show_options { + col = col.push(optional_content(state)); + } + + Container::new(col) +} + +fn optional_content(state: &DecryptModal) -> Container<'static, installer::Message> { + let import = modal::button_entry( + Some(icon::import_icon()), + "Upload extended public key file", + None, + state.import_xpub_error.clone(), + Some(|| Decrypt::SelectImportXpub.into()), + ); + + let xpub = modal::collapsible_input_button( + state.focus == Focus::Xpub, + Some(icon::round_key_icon()), + "Paste an extended public key".to_string(), + example_xpub(state.network), + &state.xpub, + Some(|s| Decrypt::Xpub(s).into()), + Some(|| Decrypt::PasteXpub.into()), + || Decrypt::SelectXpub.into(), + ); + + let mnemonic = modal::collapsible_input_button( + state.focus == Focus::Mnemonic, + Some(icon::pencil_icon()), + "Enter mnemonic of one of the keys".to_string(), + "code code code code code code code code code code code brave".to_string(), + &state.mnemonic, + Some(|s| Decrypt::Mnemonic(s).into()), + Some(|| Decrypt::PasteMnemonic.into()), + || Decrypt::SelectMnemonic.into(), + ); + + let col = column![ + import, + Space::with_height(modal::V_SPACING), + xpub, + Space::with_height(modal::V_SPACING), + mnemonic + ]; + + Container::new(col) +} + +/// Return the modal view for an export task +pub fn decrypt_view<'a>(state: &DecryptModal) -> Container<'a, installer::Message> { + let header = modal::header( + Some("Decrypt backup file".to_string()), + None::, + Some(|| installer::Message::Decrypt(Decrypt::Close)), + ); + + let content = match state.error { + Some(e) => match e { + Error::InvalidEncoding => invalid_content( + "The file cannot be decoded properly, it seems no be an encrypted backup.", + ), + Error::InvalidType => invalid_content( + "The file have been decrypted but the content type is not supported.", + ), + Error::InvalidDescriptor => invalid_content( + "The file have been decrypted but the descriptor is not a valid Liana descriptor.", + ), + }, + None => valid_content(state), + }; + + let content = scrollable(content); + + let column = Column::new() + .push(header) + .push(content) + .spacing(5) + .align_x(alignment::Horizontal::Center); + + card::simple(column) + .width(Length::Fixed(modal::MODAL_WIDTH as f32)) + .height(Length::Fixed(450.0)) +} diff --git a/liana-gui/src/installer/message.rs b/liana-gui/src/installer/message.rs index 51b1f0242..0dd747864 100644 --- a/liana-gui/src/installer/message.rs +++ b/liana-gui/src/installer/message.rs @@ -1,9 +1,12 @@ -use liana::miniscript::{ - bitcoin::{ - bip32::{ChildNumber, Fingerprint}, - Network, +use liana::{ + descriptors::LianaDescriptor, + miniscript::{ + bitcoin::{ + bip32::{ChildNumber, Fingerprint}, + Network, + }, + DescriptorPublicKey, }, - DescriptorPublicKey, }; use std::collections::HashMap; @@ -17,11 +20,11 @@ use crate::{ settings::{self, ProviderKey}, view::Close, }, - backup::{self, Backup}, + backup::Backup, download::{DownloadError, Progress}, export::ImportExportMessage, hw::HardwareWalletMessage, - installer::descriptor::PathKind, + installer::{decrypt::Decrypt, descriptor::PathKind}, node::{ bitcoind::{Bitcoind, ConfigField, RpcAuthType}, electrum, NodeType, @@ -57,14 +60,15 @@ pub enum Message { DefineDescriptor(DefineDescriptor), ImportXpub(Fingerprint, Result), HardwareWallets(HardwareWalletMessage), + HardwareWalletUpdate, WalletRegistered(Result<(Fingerprint, Option<[u8; 32]>), Error>), MnemonicWord(usize, String), ImportMnemonic(bool), RedeemNextKey, KeyRedeemed(ProviderKey, Result<(), services::keys::Error>), AllKeysRedeemed, - BackupWallet, - ExportWallet(Result), + BackupDescriptor, + ExportEncryptedDescriptor(Result, encrypted_backup::Error>), ExportXpub(String), ImportExport(ImportExportMessage), ImportBackup, @@ -74,6 +78,8 @@ pub enum Message { OpenUrl(String), SelectKeySource(SelectKeySourceMessage), EditKeyAlias(EditKeyAliasMessage), + Decrypt(Decrypt), + None, } impl Close for Message { diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index c1379cb77..81e89db8d 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -1,4 +1,5 @@ mod context; +mod decrypt; mod descriptor; mod message; mod prompt; @@ -27,11 +28,10 @@ use crate::{ settings::{update_settings_file, AuthConfig, SettingsError, WalletId, WalletSettings}, wallet::wallet_name, }, - backup, daemon::{Daemon, DaemonError}, delete, dir::LianaDirectory, - hw::{HardwareWalletConfig, HardwareWallets}, + hw::{HardwareWalletConfig, HardwareWalletMessage, HardwareWallets}, services::{ self, connect::client::{ @@ -242,13 +242,31 @@ impl Installer { pub fn update(&mut self, message: Message) -> Task { match message { - Message::HardwareWallets(msg) => match self.hws.update(msg) { - Ok(cmd) => cmd.map(Message::HardwareWallets), - Err(e) => { - error!("{}", e); - Task::none() + Message::HardwareWallets(msg) => { + let update = matches!(&msg, &HardwareWalletMessage::List(_)); + match self.hws.update(msg) { + Ok(cmd) => { + let task_1 = cmd.map(Message::HardwareWallets); + let mut task_2 = Task::none(); + if update { + task_2 = self + .steps + .get_mut(self.current) + .expect("There is always a step") + .update( + &mut self.hws, + // We notify downstream that the the list have been updated + Message::HardwareWalletUpdate, + ); + } + Task::batch(vec![task_1, task_2]) + } + Err(e) => { + error!("{}", e); + Task::none() + } } - }, + } Message::Clipboard(s) => clipboard::write(s), Message::OpenUrl(url) => { if let Err(e) = open::that_detached(&url) { @@ -836,7 +854,7 @@ pub enum Error { CannotGetAvailablePort(String), Unexpected(String), HardwareWallet(async_hwi::Error), - Backup(backup::Error), + Backup(encrypted_backup::Error), } impl From for Error { diff --git a/liana-gui/src/installer/prompt.rs b/liana-gui/src/installer/prompt.rs index 6d5bbc506..566c2ffd0 100644 --- a/liana-gui/src/installer/prompt.rs +++ b/liana-gui/src/installer/prompt.rs @@ -1,5 +1,12 @@ -pub const BACKUP_DESCRIPTOR_MESSAGE: &str = "A backup of your wallet configuration is necessary to recover your funds. Please make sure to store your Wallet backup file (or alternatively to copy and paste the descriptor string) in one or more secure and accessible locations. You still need to back up your seed phrases too, since they are not included in the file."; -pub const BACKUP_DESCRIPTOR_HELP: &str = "In Bitcoin, the coins are locked using a Script (related to the 'address'). In order to recover your funds you need to know both the Scripts you have participated in (your 'addresses'), and be able to sign a transaction that spends from those. For the ability to sign, you back up your private key, this is your mnemonic ('seed words'). For finding the coins that belong to you, you back up a template of your Script (your 'addresses'), this is your descriptor, included in your wallet backup file. However, note the descriptor need not be stored as securely as the private key. A thief who steals your descriptor but not your private key cannot steal your funds."; +pub const BACKUP_DESCRIPTOR_MESSAGE: &str = "This backup is required to recover your funds. +Click “Back Up Descriptor” to download an encrypted file of your wallet configuration and store it in safe, accessible places. +You can also copy the plain-text descriptor string, but it’s less private. +⚠️ This file does not include your seed phrase(s). Back those up separately."; +pub const BACKUP_DESCRIPTOR_HELP: &str = "In Bitcoin, to spend from a wallet that isn't a standard single-key setup, you need both your private keys (usually stored as seed words) to sign transactions, and your wallet descriptor to locate your coins — like a map of your addresses. +Without the descriptor, your wallet may not find your coins — even if you still have the keys.
 +When you click “Back Up Descriptor”, Liana creates an encrypted file that can only be decrypted using one of your wallet’s public keys.
 +Liana handles this automatically during the restore of a wallet process by asking you to connect a device or enter a key. +This file is safer and more private than copying the descriptor manually."; pub const REGISTER_DESCRIPTOR_HELP: &str = "To be used with the wallet, a signing device needs the descriptor. If the descriptor contains one or more keys imported from an external signing device, the descriptor must be registered on it. Registration confirms that the device is able to handle the policy. Registration on a device is not a substitute for backing up the descriptor."; pub const MNEMONIC_HELP: &str = "A hot key generated on this computer was used for creating this wallet. It needs to be backed up. \n Keep it in a safe place. Never share it with anyone."; pub const RECOVER_MNEMONIC_HELP: &str = "If you were using a hot key (a key stored on the computer) in your wallet, you will need to recover it from mnemonics to be able to sign transactions again. Otherwise you can directly go the next step."; diff --git a/liana-gui/src/installer/step/backend.rs b/liana-gui/src/installer/step/backend.rs index f1a788ff2..28205982f 100644 --- a/liana-gui/src/installer/step/backend.rs +++ b/liana-gui/src/installer/step/backend.rs @@ -1,6 +1,10 @@ +use crate::installer::{ + decrypt::{Decrypt, DecryptModal}, + step::import_descriptor::ImportDescriptorModal, +}; use std::str::FromStr; -use iced::{Subscription, Task}; +use iced::Task; use liana::{descriptors::LianaDescriptor, miniscript::bitcoin::Network}; use liana_ui::{component::form, widget::Element}; @@ -25,6 +29,8 @@ use crate::{ }, }; +use super::import_descriptor::BACKUP_NETWORK_NOT_MATCH; + pub struct ChooseBackend { network: Network, remote_backend_is_selected: bool, @@ -451,7 +457,7 @@ pub struct ImportRemoteWallet { error: Option, backend: context::RemoteBackend, wallets: Vec, - modal: Option, + modal: ImportDescriptorModal, // wallet alias is stored here to be applied to context // and be modified in a following step wallet_alias: Option, @@ -468,7 +474,7 @@ impl ImportRemoteWallet { error: None, backend: context::RemoteBackend::Undefined, wallets: Vec::new(), - modal: None, + modal: ImportDescriptorModal::None, wallet_alias: None, } } @@ -505,39 +511,65 @@ impl Step for ImportRemoteWallet { } // form value is set as valid each time it is edited. // Verification of the values is happening when the user click on Next button. - fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Task { + fn update(&mut self, hws: &mut HardwareWallets, message: Message) -> Task { match message { Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportDescriptorFromFile) => { - let modal = ExportModal::new(None, ImportExportType::ImportDescriptor); + let modal = ExportModal::new(None, ImportExportType::FromBackup); let launch = modal.launch(false); - self.modal = Some(modal); + self.modal = ImportDescriptorModal::Export(modal); return launch; } Message::ImportExport(ImportExportMessage::Path(p)) => { - if let Some(modal) = self.modal.as_mut() { - return modal.update(ImportExportMessage::Path(p)); + if self.modal.is_some() { + return self + .modal + .update(Message::ImportExport(ImportExportMessage::Path(p))); } } - Message::ImportExport(ImportExportMessage::Close) => self.modal = None, - Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportExport(m)) => match m { - ImportExportMessage::Close => self.modal = None, - ImportExportMessage::Progress(Progress::Descriptor(d)) => { - self.modal = None; - return Task::batch([ - Task::done(Message::ImportRemoteWallet( - message::ImportRemoteWallet::ImportDescriptor(d.to_string()), - )), - Task::done(Message::ImportRemoteWallet( - message::ImportRemoteWallet::ConfirmDescriptor, - )), - ]); + Message::ImportExport(ImportExportMessage::Close) => { + self.modal = ImportDescriptorModal::None + } + Message::ImportExport(ImportExportMessage::Progress(Progress::EncryptedFile( + bytes, + ))) => { + self.modal = ImportDescriptorModal::Decrypt(DecryptModal::new(bytes, self.network)); + } + Message::ImportExport(m) => return self.modal.update(Message::ImportExport(m)), + Message::HardwareWalletUpdate => { + if let ImportDescriptorModal::Decrypt(modal) = &mut self.modal { + return modal.update_devices(hws).unwrap_or(Task::none()); } - m => { - if let Some(modal) = self.modal.as_mut() { - return modal.update(m); + } + Message::Decrypt(Decrypt::Close) => { + if matches!(self.modal, ImportDescriptorModal::Decrypt(_)) { + self.modal = ImportDescriptorModal::None; + } + } + Message::Decrypt(Decrypt::Backup(mut backup)) => { + let descriptor = backup.accounts.first().map(|acc| acc.descriptor.clone()); + if let Some(desc) = descriptor { + let network_matches = if self.network == Network::Bitcoin { + backup.network == Network::Bitcoin + } else { + backup.network != Network::Bitcoin + }; + if network_matches { + // NOTE: we need to overwrite w/ correct network for testnets + // as non Mainnet keys / descriptor are parsed as Signet + backup.network = self.network; + + self.imported_descriptor.value = desc; + self.modal = ImportDescriptorModal::None; + return Task::perform(async {}, |_| Message::Next); + } else { + self.modal = ImportDescriptorModal::None; + self.error = Some(BACKUP_NETWORK_NOT_MATCH.into()); } + } else { + self.modal = ImportDescriptorModal::None; + self.error = Some("Backup imported but descriptor missing!".into()); } - }, + } Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportDescriptor(desc)) => { self.imported_descriptor.value = desc; if !self.imported_descriptor.value.is_empty() { @@ -679,17 +711,8 @@ impl Step for ImportRemoteWallet { Task::none() } - fn subscription(&self, _hws: &HardwareWallets) -> iced::Subscription { - if let Some(modal) = &self.modal { - if let Some(sub) = modal.subscription() { - return sub.map(|m| { - Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportExport( - ImportExportMessage::Progress(m), - )) - }); - } - } - Subscription::none() + fn subscription(&self, hws: &HardwareWallets) -> iced::Subscription { + self.modal.subscriptions(hws) } fn apply(&mut self, ctx: &mut Context) -> bool { @@ -725,11 +748,7 @@ impl Step for ImportRemoteWallet { .map(|w| (&w.name, w.metadata.wallet_alias.as_ref())) .collect(), ); - if let Some(modal) = &self.modal { - modal.view(content) - } else { - content - } + self.modal.view(content) } } diff --git a/liana-gui/src/installer/step/descriptor/editor/key.rs b/liana-gui/src/installer/step/descriptor/editor/key.rs index db5301e05..15d670c21 100644 --- a/liana-gui/src/installer/step/descriptor/editor/key.rs +++ b/liana-gui/src/installer/step/descriptor/editor/key.rs @@ -1,3 +1,4 @@ +use crate::utils::example_xpub; use std::{ collections::HashMap, str::FromStr, @@ -39,7 +40,6 @@ use crate::{ installer::{ descriptor::{Key, KeySource}, message::{self, Message}, - view::editor::example_xpub, Error, PathKind, }, services::{ @@ -1523,19 +1523,6 @@ fn alias_already_exists( false } -pub fn default_derivation_path(network: Network) -> DerivationPath { - // Note that "m" is ignored when parsing string and could be removed: - // https://github.com/rust-bitcoin/rust-bitcoin/pull/2677 - DerivationPath::from_str({ - if network == Network::Bitcoin { - "m/48'/0'/0'/2'" - } else { - "m/48'/1'/0'/2'" - } - }) - .unwrap() -} - pub fn derivation_path(network: Network, account: ChildNumber) -> DerivationPath { assert!(account.is_hardened()); let network = if network == Network::Bitcoin { @@ -1575,6 +1562,8 @@ pub async fn get_extended_pubkey( #[cfg(test)] mod tests { + use crate::utils::default_derivation_path; + use super::*; #[test] diff --git a/liana-gui/src/installer/step/descriptor/mod.rs b/liana-gui/src/installer/step/descriptor/mod.rs index cea59a7b1..9c55bf110 100644 --- a/liana-gui/src/installer/step/descriptor/mod.rs +++ b/liana-gui/src/installer/step/descriptor/mod.rs @@ -17,11 +17,13 @@ use async_hwi::DeviceKind; use crate::{ app::{settings::KeySetting, state::export::ExportModal, wallet::wallet_name}, - backup::{self, Backup}, + backup::Backup, export::{ImportExportMessage, ImportExportType, Progress}, hw::{HardwareWallet, HardwareWallets}, installer::{ + decrypt::{Decrypt, DecryptModal}, message::{self, Message}, + step::import_descriptor::{ImportDescriptorModal, BACKUP_NETWORK_NOT_MATCH}, step::{Context, Step}, view, Error, }, @@ -31,7 +33,7 @@ pub struct ImportDescriptor { network: Network, wrong_network: bool, error: Option, - modal: Option, + modal: ImportDescriptorModal, imported_descriptor: form::Value, imported_backup: Option, imported_aliases: Option>, @@ -44,7 +46,7 @@ impl ImportDescriptor { imported_descriptor: form::Value::default(), wrong_network: false, error: None, - modal: None, + modal: ImportDescriptorModal::None, imported_backup: None, imported_aliases: None, } @@ -84,20 +86,12 @@ impl Step for ImportDescriptor { ctx.remote_backend.is_some() } - fn subscription(&self, _hws: &HardwareWallets) -> Subscription { - if let Some(modal) = &self.modal { - if let Some(sub) = modal.subscription() { - sub.map(|m| Message::ImportExport(ImportExportMessage::Progress(m))) - } else { - Subscription::none() - } - } else { - Subscription::none() - } + fn subscription(&self, hws: &HardwareWallets) -> Subscription { + self.modal.subscriptions(hws) } - fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Task { - match message { + fn update(&mut self, hws: &mut HardwareWallets, message: Message) -> Task { + let task = match message { Message::DefineDescriptor(message::DefineDescriptor::ImportDescriptor(desc)) => { // If user manually change the descriptor, then the imported backup // becomes invalid; @@ -107,16 +101,18 @@ impl Step for ImportDescriptor { } self.imported_descriptor.value = desc; self.check_descriptor(self.network); - } - Message::ImportExport(ImportExportMessage::Close) => { - self.modal = None; + None } Message::ImportBackup => { self.imported_backup = None; - let modal = ExportModal::new(None, ImportExportType::WalletFromBackup); + let modal = ExportModal::new(None, ImportExportType::FromBackup); let launch = modal.launch(false); - self.modal = Some(modal); - return launch; + self.modal = ImportDescriptorModal::Export(modal); + Some(launch) + } + Message::ImportExport(ImportExportMessage::Close) => { + self.modal = ImportDescriptorModal::None; + None } Message::ImportExport(ImportExportMessage::Progress(Progress::WalletFromBackup(r))) => { let (descriptor, network, aliases, backup) = r; @@ -126,8 +122,7 @@ impl Step for ImportDescriptor { self.imported_descriptor.value = descriptor.to_string(); self.imported_aliases = Some(aliases); } else { - self.error = - Some("Backup network do not match the selected network!".into()); + self.error = Some(BACKUP_NETWORK_NOT_MATCH.into()); } } else { // The backup have been inferred from a bare descriptor, we check whether @@ -137,20 +132,62 @@ impl Step for ImportDescriptor { self.imported_descriptor.value = descriptor.to_string(); self.imported_aliases = Some(aliases); } else { - self.error = - Some("Backup network do not match the selected network!".into()); + self.error = Some(BACKUP_NETWORK_NOT_MATCH.into()); } } + None } - Message::ImportExport(m) => { - if let Some(modal) = self.modal.as_mut() { - let task: Task = modal.update(m); - return task; - }; + Message::ImportExport(ImportExportMessage::Progress(Progress::EncryptedFile( + bytes, + ))) => { + self.modal = ImportDescriptorModal::Decrypt(DecryptModal::new(bytes, self.network)); + None } - _ => {} - } - Task::none() + Message::ImportExport(m) => Some(self.modal.update(Message::ImportExport(m))), + Message::HardwareWalletUpdate => { + if let ImportDescriptorModal::Decrypt(modal) = &mut self.modal { + modal.update_devices(hws) + } else { + None + } + } + Message::Decrypt(Decrypt::Close) => { + if matches!(self.modal, ImportDescriptorModal::Decrypt(_)) { + self.modal = ImportDescriptorModal::None; + } + None + } + Message::Decrypt(Decrypt::Backup(mut backup)) => { + let descriptor = backup.accounts.first().map(|acc| acc.descriptor.clone()); + if let Some(desc) = descriptor { + let network_matches = if self.network == Network::Bitcoin { + backup.network == Network::Bitcoin + } else { + backup.network != Network::Bitcoin + }; + if network_matches { + // NOTE: we need to overwrite w/ correct network for testnets + // as non Mainnet keys / descriptor are parsed as Signet + backup.network = self.network; + + self.imported_descriptor.value = desc; + self.imported_backup = Some(backup); + self.imported_aliases = None; + self.modal = ImportDescriptorModal::None; + } else { + self.modal = ImportDescriptorModal::None; + self.error = Some(BACKUP_NETWORK_NOT_MATCH.into()); + } + } else { + self.modal = ImportDescriptorModal::None; + self.error = Some("Backup imported but descriptor missing!".into()); + } + None + } + Message::Decrypt(msg) => Some(self.modal.update(Message::Decrypt(msg))), + _ => None, + }; + task.unwrap_or(Task::none()) } fn apply(&mut self, ctx: &mut Context) -> bool { @@ -199,11 +236,7 @@ impl Step for ImportDescriptor { self.wrong_network, self.error.as_ref(), ); - if let Some(modal) = &self.modal { - modal.view(content) - } else { - content - } + self.modal.view(content) } } @@ -412,29 +445,32 @@ impl Step for BackupDescriptor { return task; }; } - Message::BackupWallet => { + Message::BackupDescriptor => { if let (None, Some(ctx)) = (&self.modal, self.context.as_ref()) { - let ctx = ctx.clone(); + let descriptor = ctx.descriptor.clone(); return Task::perform( async move { - let backup = Backup::from_installer_descriptor_step(ctx).await?; - serde_json::to_string_pretty(&backup).map_err(|_| backup::Error::Json) + let descriptor = descriptor.ok_or(encrypted_backup::Error::String( + Box::new("Descriptor missing".to_string()), + ))?; + Ok(Box::new(descriptor)) }, - Message::ExportWallet, + Message::ExportEncryptedDescriptor, ); } } - Message::ExportWallet(str) => { + Message::ExportEncryptedDescriptor(bytes) => { if self.modal.is_none() { - let str = match str { - Ok(s) => s, + let bytes = match bytes { + Ok(b) => b, Err(e) => { tracing::error!("{e:?}"); self.error = Some(Error::Backup(e)); return Task::none(); } }; - let modal = ExportModal::new(None, ImportExportType::ExportBackup(str)); + let modal = + ExportModal::new(None, ImportExportType::ExportEncryptedDescriptor(bytes)); let launch = modal.launch(true); self.modal = Some(modal); return launch; diff --git a/liana-gui/src/installer/step/import_descriptor.rs b/liana-gui/src/installer/step/import_descriptor.rs new file mode 100644 index 000000000..442a4340e --- /dev/null +++ b/liana-gui/src/installer/step/import_descriptor.rs @@ -0,0 +1,109 @@ +use iced::{Subscription, Task}; +use liana_ui::widget::Element; + +use crate::{ + app::state::export::ExportModal, + export::{ImportExportMessage, Progress}, + hw::HardwareWallets, + installer::{ + self, + decrypt::{Decrypt, DecryptModal}, + }, +}; + +pub const BACKUP_NETWORK_NOT_MATCH: &str = "Backup network do not match the selected network!"; + +#[derive(Debug)] +pub enum ImportDescriptorModal { + None, + Export(ExportModal), + Decrypt(DecryptModal), +} + +impl ImportDescriptorModal { + pub fn subscriptions(&self, hws: &HardwareWallets) -> Subscription { + if let ImportDescriptorModal::Export(modal) = &self { + if let Some(sub) = modal.subscription() { + sub.map(|m| installer::Message::ImportExport(ImportExportMessage::Progress(m))) + } else { + Subscription::none() + } + } else if let ImportDescriptorModal::Decrypt(modal) = &self { + let mut batch = vec![hws.refresh().map(installer::Message::HardwareWallets)]; + if let Some(import_modal) = modal.modal.as_ref() { + if let Some(sub) = import_modal.subscription() { + batch.push(sub.map(|p| { + installer::Message::ImportExport(ImportExportMessage::Progress(p)) + })) + } + } + Subscription::batch(batch) + } else { + Subscription::none() + } + } + + pub fn view<'a>( + &'a self, + content: Element<'a, installer::Message>, + ) -> Element<'a, installer::Message> { + match &self { + ImportDescriptorModal::None => content, + ImportDescriptorModal::Export(modal) => modal.view(content), + ImportDescriptorModal::Decrypt(modal) => modal.view(content), + } + } + + pub fn update(&mut self, msg: installer::Message) -> Task { + match msg { + installer::Message::ImportExport(ImportExportMessage::Progress(Progress::Xpub( + xpub, + ))) => { + if let ImportDescriptorModal::Decrypt(modal) = self { + let _ = modal.update(Decrypt::CloseModal); + return modal.update(Decrypt::Xpub(xpub)); + } + } + installer::Message::ImportExport(m) => { + if let ImportDescriptorModal::Export(modal) = self { + let task: Task = modal.update(m); + return task; + } else if let ImportDescriptorModal::Decrypt(modal) = self { + if let Some(mo) = &mut modal.modal { + let task: Task = mo.update(m); + return task; + } + } + } + installer::Message::Decrypt(msg) => { + if let ImportDescriptorModal::Decrypt(modal) = self { + match msg { + Decrypt::Fetched(_, _) + | Decrypt::Xpub(_) + | Decrypt::XpubError(_) + | Decrypt::Mnemonic(_) + | Decrypt::MnemonicStatus(_, _) + | Decrypt::UnexpectedPayload(_) + | Decrypt::InvalidDescriptor + | Decrypt::ContentNotSupported + | Decrypt::PasteXpub + | Decrypt::SelectXpub + | Decrypt::PasteMnemonic + | Decrypt::SelectMnemonic + | Decrypt::SelectImportXpub + | Decrypt::None + | Decrypt::CloseModal + | Decrypt::ShowOptions(_) => return modal.update(msg), + Decrypt::Backup(_) | Decrypt::Close => {} + } + } + } + _ => {} + } + Task::none() + } + + pub fn is_some(&self) -> bool { + !matches!(self, ImportDescriptorModal::None) + } +} diff --git a/liana-gui/src/installer/step/mod.rs b/liana-gui/src/installer/step/mod.rs index 2fa2fafdf..b1dd13804 100644 --- a/liana-gui/src/installer/step/mod.rs +++ b/liana-gui/src/installer/step/mod.rs @@ -1,4 +1,5 @@ pub mod descriptor; +pub mod import_descriptor; mod backend; mod mnemonic; diff --git a/liana-gui/src/installer/step/share_xpubs.rs b/liana-gui/src/installer/step/share_xpubs.rs index 612eae172..8c7d8e4f7 100644 --- a/liana-gui/src/installer/step/share_xpubs.rs +++ b/liana-gui/src/installer/step/share_xpubs.rs @@ -17,13 +17,11 @@ use crate::{ hw::{HardwareWallet, HardwareWallets}, installer::{ message::Message, - step::{ - descriptor::editor::key::{default_derivation_path, get_extended_pubkey}, - Context, Step, - }, + step::{descriptor::editor::key::get_extended_pubkey, Context, Step}, view, Error, }, signer::Signer, + utils::default_derivation_path, }; pub struct HardwareWalletXpubs { diff --git a/liana-gui/src/installer/view/editor/mod.rs b/liana-gui/src/installer/view/editor/mod.rs index db1d405e8..f65486bb0 100644 --- a/liana-gui/src/installer/view/editor/mod.rs +++ b/liana-gui/src/installer/view/editor/mod.rs @@ -5,13 +5,11 @@ pub mod template; use iced::widget::{container, pick_list, slider, Button, Space}; use iced::{alignment, Alignment, Length}; -use liana::miniscript::bitcoin::Network; use liana_ui::component::text::{p1_bold, p2_regular, H3_SIZE}; use std::borrow::Cow; use std::fmt::Display; use std::str::FromStr; -use liana::miniscript::bitcoin::{self}; use liana_ui::{ component::{ button, card, form, separation, @@ -255,12 +253,6 @@ pub fn undefined_key<'a>( .into() } -pub fn example_xpub(network: Network) -> String { - format!("[aabbccdd/42'/0']{}pub6DAkq8LWw91WGgUGnkR5Sbzjev5JCsXaTVZQ9MwsPV4BkNFKygtJ8GHodfDVx1udR723nT7JASqGPpKvz7zQ25pUTW6zVEBdiWoaC4aUqik", - if network == bitcoin::Network::Bitcoin { "x" } else { "t" } - ) -} - /// returns y,m,d,h,m fn duration_from_sequence(sequence: u16) -> (u32, u32, u32, u32, u32) { let mut n_minutes = sequence as u32 * 10; diff --git a/liana-gui/src/installer/view/mod.rs b/liana-gui/src/installer/view/mod.rs index 933d79096..ba3ad890e 100644 --- a/liana-gui/src/installer/view/mod.rs +++ b/liana-gui/src/installer/view/mod.rs @@ -782,16 +782,17 @@ pub fn backup_descriptor<'a>( done: bool, ) -> Element<'a, Message> { let backup_button = if done { - button::secondary(Some(icon::backup_icon()), "Back Up Wallet") - .on_press(Message::BackupWallet) + button::secondary(Some(icon::backup_icon()), "Back Up Descriptor") + .on_press(Message::BackupDescriptor) } else { - button::primary(Some(icon::backup_icon()), "Back Up Wallet").on_press(Message::BackupWallet) + button::primary(Some(icon::backup_icon()), "Back Up Descriptor") + .on_press(Message::BackupDescriptor) }; layout( progress, email, - "Back Up your wallet", + "Back Up your wallet configuration (Descriptor)", Column::new() .push( Column::new() @@ -858,8 +859,7 @@ pub fn backup_descriptor<'a>( .max_width(1500), ) .push( - checkbox("I have backed up my wallet/descriptor", done) - .on_toggle(Message::UserActionDone), + checkbox("I have backed up my descriptor", done).on_toggle(Message::UserActionDone), ) .push(if done { button::primary(None, "Next") diff --git a/liana-gui/src/utils/mod.rs b/liana-gui/src/utils/mod.rs index b01f9e99f..72df12b9f 100644 --- a/liana-gui/src/utils/mod.rs +++ b/liana-gui/src/utils/mod.rs @@ -1,4 +1,9 @@ -use std::time::{Duration, SystemTime, SystemTimeError, UNIX_EPOCH}; +use std::{ + str::FromStr, + time::{Duration, SystemTime, SystemTimeError, UNIX_EPOCH}, +}; + +use liana::miniscript::bitcoin::{self, bip32::DerivationPath, Network}; pub mod serde; @@ -17,3 +22,22 @@ pub fn now() -> Duration { pub fn now_fallible() -> Result { SystemTime::now().duration_since(UNIX_EPOCH) } + +pub fn example_xpub(network: Network) -> String { + format!("[aabbccdd/42'/0']{}pub6DAkq8LWw91WGgUGnkR5Sbzjev5JCsXaTVZQ9MwsPV4BkNFKygtJ8GHodfDVx1udR723nT7JASqGPpKvz7zQ25pUTW6zVEBdiWoaC4aUqik", + if network == bitcoin::Network::Bitcoin { "x" } else { "t" } + ) +} + +pub fn default_derivation_path(network: Network) -> DerivationPath { + // Note that "m" is ignored when parsing string and could be removed: + // https://github.com/rust-bitcoin/rust-bitcoin/pull/2677 + DerivationPath::from_str({ + if network == Network::Bitcoin { + "m/48'/0'/0'/2'" + } else { + "m/48'/1'/0'/2'" + } + }) + .unwrap() +} diff --git a/liana-gui/test_assets/v0.bed b/liana-gui/test_assets/v0.bed new file mode 100644 index 000000000..2f0d7f61d Binary files /dev/null and b/liana-gui/test_assets/v0.bed differ diff --git a/liana-ui/src/component/modal.rs b/liana-ui/src/component/modal.rs index 5c3026c4f..2e7ff1fb3 100644 --- a/liana-ui/src/component/modal.rs +++ b/liana-ui/src/component/modal.rs @@ -26,7 +26,7 @@ pub const BTN_H: u16 = 40; pub const V_SPACING: u16 = 10; pub const H_SPACING: u16 = 5; -fn widget_style(theme: &Theme, status: Status) -> Style { +pub fn widget_style(theme: &Theme, status: Status) -> Style { theme::button::secondary(theme, status) } diff --git a/liana/src/descriptors/analysis.rs b/liana/src/descriptors/analysis.rs index 9e68a4202..c4e4b0fc6 100644 --- a/liana/src/descriptors/analysis.rs +++ b/liana/src/descriptors/analysis.rs @@ -382,7 +382,7 @@ impl PathInfo { // > lift_x(0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0) which is constructed // > by taking the hash of the standard uncompressed encoding of the secp256k1 base point G as X // > coordinate. -fn bip341_nums() -> secp256k1::PublicKey { +pub fn bip341_nums() -> secp256k1::PublicKey { secp256k1::PublicKey::from_str( "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0", ) @@ -444,7 +444,7 @@ fn unspendable_internal_xpub( }) } -fn unspendable_internal_key( +pub fn unspendable_internal_key( desc: &descriptor::Tr, ) -> Option { Some(descriptor::DescriptorPublicKey::MultiXPub( diff --git a/liana/src/descriptors/mod.rs b/liana/src/descriptors/mod.rs index 09a73da34..7d75ef398 100644 --- a/liana/src/descriptors/mod.rs +++ b/liana/src/descriptors/mod.rs @@ -1,16 +1,16 @@ use miniscript::{ bitcoin::{ self, - bip32::{self, Fingerprint}, + bip32::{self, DerivationPath, Fingerprint}, constants::WITNESS_SCALE_FACTOR, psbt::{Input as PsbtIn, Output as PsbtOut, Psbt}, secp256k1, }, - descriptor, + descriptor::{self, DescriptorXKey, Wildcard}, miniscript::satisfy::Placeholder, plan::{Assets, CanSign}, psbt::{PsbtInputExt, PsbtOutputExt}, - translate_hash_clone, ForEachKey, TranslatePk, Translator, + translate_hash_clone, Descriptor, DescriptorPublicKey, ForEachKey, TranslatePk, Translator, }; use std::{ @@ -250,6 +250,11 @@ impl LianaDescriptor { .unwrap_or(false) } + /// Get the multipath descriptor + pub fn descriptor(&self) -> &Descriptor { + &self.multi_desc + } + /// Get the descriptor for receiving addresses. pub fn receive_descriptor(&self) -> &SinglePathLianaDesc { &self.receive_desc @@ -266,6 +271,42 @@ impl LianaDescriptor { .expect("We never create a Liana descriptor with an invalid Liana policy.") } + /// Get the set of Xpubs that can sign (excluding unspendable key) in the form of an + /// DescriptorPublicKey::XPub with empty derivation path, in order to avoid duplicates. + pub fn spendable_keys(&self) -> Vec { + let mut keys = BTreeSet::new(); + let nums = bip341_nums(); + self.multi_desc.for_each_key(|k| { + if let DescriptorPublicKey::MultiXPub(multixkey) = k { + let key = DescriptorPublicKey::XPub(DescriptorXKey { + origin: multixkey.origin.clone(), + xkey: multixkey.xkey, + derivation_path: DerivationPath::default(), + wildcard: Wildcard::None, + }); + + if multixkey.xkey.public_key != nums { + keys.insert(key.clone()); + } + } else { + unreachable!("all keys must be of MultiXpub type"); + } + true + }); + keys.into_iter().collect() + } + + /// Get the unspendable key from this descriptor if the descriptor is of taproot type. + /// Note: an unspendable key is always returned from a taproot descriptor even if not + /// part of the policy. + pub fn process_unspendable_key(&self) -> Option { + if let Descriptor::Tr(tr_descriptor) = &self.multi_desc { + unspendable_internal_key(tr_descriptor) + } else { + None + } + } + /// Get the value (in blocks) of the smallest relative timelock of the recovery paths. pub fn first_timelock_value(&self) -> u16 { *self @@ -2359,5 +2400,39 @@ mod tests { )); } + #[test] + fn test_descriptor_keys() { + let xpub = bip32::Xpub::from_str("xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW").unwrap(); + let fg = Fingerprint::from_str("abcdef01").unwrap(); + let deriv = DerivationPath::from_str("0/0/0").unwrap(); + let key = DescriptorPublicKey::XPub(DescriptorXKey { + origin: Some((fg, deriv)), + xkey: xpub, + derivation_path: DerivationPath::default(), + wildcard: Wildcard::None, + }); + let key_str = "[abcdef01/0/0/0]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW"; + assert_eq!(key.to_string(), key_str); + + let descr_str = "tr(tpubD6NzVbkrYhZ4XokGX5s1FcKj2ozqibXqXc79NkzEbNQvT1j9F7YWe3fPp3eDeMVypHXocGWUkVaC6ZUqMqyQRS27XJcujQLbrqzpoYYLGW5/<0;1>/*,{and_v(v:multi_a(1,[37435aca/48'/1'/0'/2']tpubDEcZc7Um5KPZLF11qLYNgcteH1PH7CSKtTFTuTYuL6jqsP73gBHvLY42FuyvkTWN8C4q1YiDAUAeEuA78CDuuD2x17UKJymkBacCJz8P6NU/<2;3>/*,[05813578/48'/1'/0'/2']tpubDEymPgUFZDLzEePYsai2ZXc9ntsnZBohJvGuHgpZsY5bsUDJMD6de6tevVd1z1EdGPYLdNaPvD4Ck7NgcteYDUPWwExvscfaSUu19k48Mvp/<2;3>/*),older(52596)),and_v(v:pk([37435aca/48'/1'/0'/2']tpubDEcZc7Um5KPZLF11qLYNgcteH1PH7CSKtTFTuTYuL6jqsP73gBHvLY42FuyvkTWN8C4q1YiDAUAeEuA78CDuuD2x17UKJymkBacCJz8P6NU/<0;1>/*),pk([05813578/48'/1'/0'/2']tpubDEymPgUFZDLzEePYsai2ZXc9ntsnZBohJvGuHgpZsY5bsUDJMD6de6tevVd1z1EdGPYLdNaPvD4Ck7NgcteYDUPWwExvscfaSUu19k48Mvp/<0;1>/*))})#ffdmpxzr"; + let liana_descriptor = LianaDescriptor::from_str(descr_str).unwrap(); + let key1_str = "[37435aca/48'/1'/0'/2']tpubDEcZc7Um5KPZLF11qLYNgcteH1PH7CSKtTFTuTYuL6jqsP73gBHvLY42FuyvkTWN8C4q1YiDAUAeEuA78CDuuD2x17UKJymkBacCJz8P6NU"; + let key2_str = "[05813578/48'/1'/0'/2']tpubDEymPgUFZDLzEePYsai2ZXc9ntsnZBohJvGuHgpZsY5bsUDJMD6de6tevVd1z1EdGPYLdNaPvD4Ck7NgcteYDUPWwExvscfaSUu19k48Mvp"; + let unspendable_str = "tpubD6NzVbkrYhZ4XokGX5s1FcKj2ozqibXqXc79NkzEbNQvT1j9F7YWe3fPp3eDeMVypHXocGWUkVaC6ZUqMqyQRS27XJcujQLbrqzpoYYLGW5/<0;1>/*"; + let key1 = DescriptorPublicKey::from_str(key1_str).unwrap(); + let key2 = DescriptorPublicKey::from_str(key2_str).unwrap(); + let unspendable = DescriptorPublicKey::from_str(unspendable_str).unwrap(); + + let keys = liana_descriptor.spendable_keys(); + // no duplicates + assert_eq!(keys.len(), 2); + assert!(keys.contains(&key1)); + assert!(keys.contains(&key2)); + assert!(!keys.contains(&unspendable)); + + let p_unspendable = liana_descriptor.process_unspendable_key().unwrap(); + assert_eq!(p_unspendable, unspendable); + } + // TODO: test error conditions of deserialization. }