diff --git a/Cargo.lock b/Cargo.lock index baa321d3e..b0ebc0222 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", @@ -980,7 +980,7 @@ dependencies = [ "ctr", "hidapi", "k256", - "rand", + "rand 0.8.5", ] [[package]] @@ -1218,7 +1218,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 +1230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1513,7 +1514,7 @@ dependencies = [ "generic-array", "group", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -1537,6 +1538,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encrypted_backup" +version = "0.1.0" +source = "git+https://github.com/pythcoiner/encrypted_backup.git?rev=df395ee#df395eeaaf5b726416557ea3be2ee73484d77674" +dependencies = [ + "aes-gcm", + "miniscript", + "num_enum", + "rand 0.9.2", +] + [[package]] name = "endi" version = "1.1.0" @@ -1689,7 +1701,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 +2119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -3081,6 +3093,7 @@ dependencies = [ "chrono", "dirs 3.0.2", "email_address", + "encrypted_backup", "flate2", "fs2", "hex", @@ -4091,7 +4104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -4417,8 +4430,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 +4451,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 +4473,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 +4526,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 +5119,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 +6779,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..d6d24dbac 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 = { git = "https://github.com/pythcoiner/encrypted_backup.git", rev= "df395ee", features = ["miniscript_12_0"]} + [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..63fb2a6a4 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(..) => { - format!("liana-backup-{date}.json") + ImportExportType::ExportProcessBackup(..) => { + format!("liana-backup-{date}.beb") } - 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 a11dc0966..499af920c 100644 --- a/liana-gui/src/app/state/settings/mod.rs +++ b/liana-gui/src/app/state/settings/mod.rs @@ -255,7 +255,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 14b257f87..ecc9c176d 100644 --- a/liana-gui/src/app/view/message.rs +++ b/liana-gui/src/app/view/message.rs @@ -97,7 +97,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..d3f70c845 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..f810f1b06 100644 --- a/liana-gui/src/backup.rs +++ b/liana-gui/src/backup.rs @@ -1,4 +1,5 @@ use chrono::{Duration, Utc}; +use encrypted_backup::ToPayload; use liana::{ descriptors::LianaDescriptor, miniscript::{ @@ -22,13 +23,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 +124,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( @@ -482,6 +451,28 @@ pub enum KeyType { ThirdParty, } +impl ToPayload for Backup { + fn to_payload(&self) -> Result, encrypted_backup::Error> { + Ok(self.to_string().as_bytes().to_vec()) + } + + fn content_type(&self) -> encrypted_backup::Content { + encrypted_backup::Content::WalletBackup + } + + fn derivation_paths( + &self, + ) -> Result, encrypted_backup::Error> { + Ok(vec![]) + } + + fn keys( + &self, + ) -> Result, encrypted_backup::Error> { + Ok(vec![]) + } +} + #[cfg(test)] mod test { use super::*; diff --git a/liana-gui/src/decrypt.rs b/liana-gui/src/decrypt.rs new file mode 100644 index 000000000..1304083b1 --- /dev/null +++ b/liana-gui/src/decrypt.rs @@ -0,0 +1,655 @@ +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::example_xpub, +}; + +type FnMsg = fn() -> installer::Message; + +#[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) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + None, + Busy, + NotMatch, +} + +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(DerivationPath::from_str("48'/0'/0'/2'").expect("hardcoded")); + h.insert(DerivationPath::from_str("48'/1'/0'/2'").expect("hardcoded")); + 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/export.rs b/liana-gui/src/export.rs index 853b40ffd..8ba310e28 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -9,6 +9,10 @@ use std::{ time, }; +use encrypted_backup::{ + descriptor::{descr_to_dpks, dpks_to_derivation_keys_paths}, + Content, Decrypted, EncryptedBackup, ToPayload, +}; use tokio::sync::mpsc::{channel, unbounded_channel, Sender, UnboundedReceiver, UnboundedSender}; use async_hwi::bitbox::api::btc::Fingerprint; @@ -122,11 +126,13 @@ pub enum Error { Bip329Export(String), BackupImport(String), Backup(backup::Error), + EncryptedBackup(encrypted_backup::Error), ParseXpub, XpubNetwork, TxidNotMatch, InsanePsbt, OutpointNotOwned, + UnknownFormat, } impl Display for Error { @@ -154,6 +160,8 @@ 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, "Fail to encrypt backup: {e:?}"), + Error::UnknownFormat => write!(f, "Format of the file unknow"), } } } @@ -163,7 +171,7 @@ pub enum ImportExportType { Transactions, ExportPsbt(String), ExportXpub(String), - ExportBackup(String), + ExportEncryptedDescriptor(Box), ExportProcessBackup(LianaDirectory, Network, Arc, Arc), ImportBackup { network_dir: NetworkDirectory, @@ -171,7 +179,7 @@ pub enum ImportExportType { overwrite_labels: Option>, overwrite_aliases: Option>, }, - WalletFromBackup, + FromBackup, Descriptor(LianaDescriptor), ExportLabels, ImportPsbt(Option), @@ -184,15 +192,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 +230,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 +265,7 @@ pub enum Progress { Backup, ), ), + EncryptedFile(Vec), } pub struct Export { @@ -295,7 +310,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 +331,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); @@ -575,8 +592,30 @@ pub async fn export_string( path: PathBuf, str: String, ) -> Result<(), Error> { + export_bytes(sender, path, str.as_bytes()).await +} + +pub async fn export_bytes( + sender: &UnboundedSender, + path: PathBuf, + bytes: &[u8], +) -> Result<(), Error> { + 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 export_encrypted_descriptor( + sender: &UnboundedSender, + path: PathBuf, + descr: LianaDescriptor, +) -> Result<(), Error> { + let descriptor = descr.descriptor(); + let bytes = EncryptedBackup::new().set_payload(descriptor)?.encrypt()?; let mut file = open_file_write(&path).await?; - file.write_all(str.as_bytes())?; + file.write_all(&bytes)?; send_progress!(sender, Progress(100.0)); send_progress!(sender, Ended); Ok(()) @@ -743,16 +782,52 @@ pub async fn import_backup( // Load backup from 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 backup_bytes = Vec::::new(); + file.read_to_end(&mut backup_bytes)?; - let backup: Result = serde_json::from_str(&backup_str); - let backup = match backup { - Ok(psbt) => psbt, - Err(e) => { - return Err(Error::BackupImport(format!("{:?}", e))); + let default_error = Error::BackupImport("Fail to import backup: unknown format".to_string()); + + // first try to parse as encrypted backup + let backup: Backup = if let Ok(mut encrypted_backup) = + EncryptedBackup::new().set_encrypted_payload(&backup_bytes) + { + if encrypted_backup.get_content() != Content::WalletBackup { + return Err(Error::BackupImport( + "Encrypted file does not contains a backup.".to_string(), + )); } + let descriptor = wallet.main_descriptor.descriptor(); + let dpks = descr_to_dpks(descriptor).expect("descriptor always have valid keys"); + let (keys, _) = dpks_to_derivation_keys_paths(&dpks); + encrypted_backup = encrypted_backup.set_keys(keys); + let decrypted = encrypted_backup + .decrypt() + .map_err(|_| Error::BackupImport("Fail to decrypt file.".to_string()))?; + let backup_bytes = if let Decrypted::WalletBackup(bytes) = decrypted { + bytes + } else { + return Err(Error::BackupImport( + "File decrypted but does not contains a backup payload.".to_string(), + )); + }; + let mut backup_str = String::from_utf8(backup_bytes).map_err(|_| { + Error::BackupImport( + "File decrypted but does not contains a valid utf8 backup payload.".to_string(), + ) + })?; + backup_str = backup_str.trim().to_string(); + + serde_json::from_str(&backup_str).map_err(|_| { + Error::BackupImport( + "File decrypted but does not contains a valid backup payload.".to_string(), + ) + })? + } else { + // else we try to parse as unencrypted backup + let mut backup_str = String::from_utf8(backup_bytes).map_err(|_| default_error.clone())?; + backup_str = backup_str.trim().to_string(); + + serde_json::from_str(&backup_str).map_err(|_| default_error)? }; // get backend info @@ -1027,22 +1102,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, @@ -1284,9 +1370,8 @@ pub async fn app_backup( wallet: Arc, daemon: Arc, sender: &UnboundedSender, -) -> Result { - let backup = Backup::from_app(datadir, network, config, wallet, daemon, sender).await?; - serde_json::to_string_pretty(&backup).map_err(|_| backup::Error::Json) +) -> Result { + Backup::from_app(datadir, network, config, wallet, daemon, sender).await } pub async fn app_backup_export( @@ -1298,10 +1383,26 @@ pub async fn app_backup_export( path: PathBuf, sender: &UnboundedSender, ) -> Result<(), Error> { + let descriptor = wallet + .main_descriptor + .clone() + .policy() + .into_multipath_descriptor(); let backup = app_backup(datadir.clone(), network, config, wallet, daemon, sender) .await .map_err(Error::Backup)?; - export_string(sender, path, backup).await + let keys = ToPayload::keys(&descriptor).expect("cannot fail"); + let deriv_paths = ToPayload::derivation_paths(&descriptor).expect("cannot fail"); + + let backup = EncryptedBackup::new() + .set_payload(&backup) + .expect("cannot fail") + .set_keys(keys) + .set_derivation_paths(deriv_paths) + .set_content_type(encrypted_backup::Content::WalletBackup) + .encrypt()?; + + export_bytes(sender, path, &backup).await } #[cfg(test)] diff --git a/liana-gui/src/hw.rs b/liana-gui/src/hw.rs index 52b65336b..20def3e1b 100644 --- a/liana-gui/src/hw.rs +++ b/liana-gui/src/hw.rs @@ -145,6 +145,7 @@ pub enum HardwareWalletMessage { Error(String), List(ConnectedList), Unlocked(String, Result), + Update, } #[derive(Debug, Clone)] @@ -312,6 +313,7 @@ impl HardwareWallets { } Ok(Task::none()) } + HardwareWalletMessage::Update => Ok(Task::none()), } } diff --git a/liana-gui/src/installer/message.rs b/liana-gui/src/installer/message.rs index 51b1f0242..a3c077acf 100644 --- a/liana-gui/src/installer/message.rs +++ b/liana-gui/src/installer/message.rs @@ -1,9 +1,13 @@ -use liana::miniscript::{ - bitcoin::{ - bip32::{ChildNumber, Fingerprint}, - Network, +use crate::decrypt::Decrypt; +use liana::{ + descriptors::LianaDescriptor, + miniscript::{ + bitcoin::{ + bip32::{ChildNumber, Fingerprint}, + Network, + }, + DescriptorPublicKey, }, - DescriptorPublicKey, }; use std::collections::HashMap; @@ -17,7 +21,7 @@ use crate::{ settings::{self, ProviderKey}, view::Close, }, - backup::{self, Backup}, + backup::Backup, download::{DownloadError, Progress}, export::ImportExportMessage, hw::HardwareWalletMessage, @@ -63,8 +67,8 @@ pub enum Message { 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..42f9769d8 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -27,11 +27,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 +241,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::HardwareWallets(HardwareWalletMessage::Update), + ); + } + 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 +853,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..9ab85c6a5 100644 --- a/liana-gui/src/installer/step/backend.rs +++ b/liana-gui/src/installer/step/backend.rs @@ -1,6 +1,11 @@ +use crate::{ + decrypt::{Decrypt, DecryptModal}, + hw::HardwareWalletMessage, + installer::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 +30,8 @@ use crate::{ }, }; +use super::import_descriptor::BACKUP_NETWORK_NOT_MATCH; + pub struct ChooseBackend { network: Network, remote_backend_is_selected: bool, @@ -451,7 +458,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 +475,7 @@ impl ImportRemoteWallet { error: None, backend: context::RemoteBackend::Undefined, wallets: Vec::new(), - modal: None, + modal: ImportDescriptorModal::None, wallet_alias: None, } } @@ -505,39 +512,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::HardwareWallets(HardwareWalletMessage::Update) => { + 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 +712,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 +749,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 9fc7b355a..9903a4614 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::{ diff --git a/liana-gui/src/installer/step/descriptor/mod.rs b/liana-gui/src/installer/step/descriptor/mod.rs index cea59a7b1..fd130e6d7 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, + decrypt::{Decrypt, DecryptModal}, export::{ImportExportMessage, ImportExportType, Progress}, - hw::{HardwareWallet, HardwareWallets}, + hw::{HardwareWallet, HardwareWalletMessage, HardwareWallets}, installer::{ 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::HardwareWallets(HardwareWalletMessage::Update) => { + 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..7a2965a46 --- /dev/null +++ b/liana-gui/src/installer/step/import_descriptor.rs @@ -0,0 +1,107 @@ +use iced::{Subscription, Task}; +use liana_ui::widget::Element; + +use crate::{ + app::state::export::ExportModal, + decrypt::{Decrypt, DecryptModal}, + export::{ImportExportMessage, Progress}, + hw::HardwareWallets, + installer, +}; + +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/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/lib.rs b/liana-gui/src/lib.rs index f815876c7..c16a0b073 100644 --- a/liana-gui/src/lib.rs +++ b/liana-gui/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod backup; pub mod daemon; +pub mod decrypt; pub mod delete; pub mod dir; pub mod download; diff --git a/liana-gui/src/utils/mod.rs b/liana-gui/src/utils/mod.rs index b01f9e99f..88572a90e 100644 --- a/liana-gui/src/utils/mod.rs +++ b/liana-gui/src/utils/mod.rs @@ -1,6 +1,7 @@ use std::time::{Duration, SystemTime, SystemTimeError, UNIX_EPOCH}; pub mod serde; +use liana::miniscript::bitcoin::{self, Network}; #[cfg(test)] pub mod sandbox; @@ -17,3 +18,9 @@ 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" } + ) +} diff --git a/liana-ui/src/component/modal.rs b/liana-ui/src/component/modal.rs index 19f7f4df1..7f2cf8c81 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/mod.rs b/liana/src/descriptors/mod.rs index 16f5e2620..e90f14729 100644 --- a/liana/src/descriptors/mod.rs +++ b/liana/src/descriptors/mod.rs @@ -10,7 +10,7 @@ use miniscript::{ 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