From 445a8e445fb72d9a6d8d97dd5535e07dd97d7d0e Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Fri, 5 Sep 2025 05:32:17 +0200 Subject: [PATCH 01/11] liana-sdk: add a getter for LianaDescriptor.multi_desc --- liana/src/descriptors/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From eeef37f5da698b791c78ae6ad9971af4ece1385d Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Fri, 8 Aug 2025 10:02:49 +0200 Subject: [PATCH 02/11] gui: add dependency for encrypted backup --- Cargo.lock | 68 +++++++++++++++++++++++++++++++++++--------- liana-gui/Cargo.toml | 2 ++ 2 files changed, 57 insertions(+), 13 deletions(-) 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"] } From 1c2bff3d6cd8b7e66e6bd9e3fbade6f754ad05c1 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Fri, 8 Aug 2025 10:04:52 +0200 Subject: [PATCH 03/11] installer: export encrypted descriptor instead plaintext backup --- liana-gui/src/app/state/export.rs | 2 + liana-gui/src/backup.rs | 34 +- liana-gui/src/decrypt.rs | 655 ++++++++++++++++++ liana-gui/src/export.rs | 28 + liana-gui/src/installer/message.rs | 19 +- liana-gui/src/installer/mod.rs | 3 +- liana-gui/src/installer/prompt.rs | 11 +- .../src/installer/step/descriptor/mod.rs | 23 +- liana-gui/src/installer/view/mod.rs | 12 +- 9 files changed, 726 insertions(+), 61 deletions(-) create mode 100644 liana-gui/src/decrypt.rs diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs index 2105b725f..111d420f6 100644 --- a/liana-gui/src/app/state/export.rs +++ b/liana-gui/src/app/state/export.rs @@ -79,6 +79,7 @@ impl ExportModal { ImportExportType::ExportProcessBackup(..) | ImportExportType::ExportBackup(_) => { "Export Backup" } + ImportExportType::ExportEncryptedDescriptor(_) => "Export Encrypted Descriptor", ImportExportType::Descriptor(_) => "Export Descriptor", ImportExportType::ExportLabels => "Export Labels", ImportExportType::ImportPsbt(_) => "Import PSBT", @@ -105,6 +106,7 @@ 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"), 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/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..2e8b7701a 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -9,6 +9,7 @@ use std::{ time, }; +use encrypted_backup::EncryptedBackup; use tokio::sync::mpsc::{channel, unbounded_channel, Sender, UnboundedReceiver, UnboundedSender}; use async_hwi::bitbox::api::btc::Fingerprint; @@ -122,6 +123,7 @@ pub enum Error { Bip329Export(String), BackupImport(String), Backup(backup::Error), + EncryptedBackup(encrypted_backup::Error), ParseXpub, XpubNetwork, TxidNotMatch, @@ -154,6 +156,7 @@ 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:?}"), } } } @@ -164,6 +167,7 @@ pub enum ImportExportType { ExportPsbt(String), ExportXpub(String), ExportBackup(String), + ExportEncryptedDescriptor(Box), ExportProcessBackup(LianaDirectory, Network, Arc, Arc), ImportBackup { network_dir: NetworkDirectory, @@ -188,6 +192,7 @@ impl ImportExportType { | ImportExportType::Descriptor(_) | ImportExportType::ExportProcessBackup(..) | ImportExportType::ExportXpub(_) + | ImportExportType::ExportEncryptedDescriptor(_) | ImportExportType::ExportLabels => "Export successful!", ImportExportType::ImportBackup { .. } | ImportExportType::ImportPsbt(_) @@ -222,6 +227,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, @@ -296,6 +307,9 @@ impl Export { 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( @@ -582,6 +596,20 @@ 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()?; + 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, diff --git a/liana-gui/src/installer/message.rs b/liana-gui/src/installer/message.rs index 51b1f0242..8edf626fa 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,7 +20,7 @@ use crate::{ settings::{self, ProviderKey}, view::Close, }, - backup::{self, Backup}, + backup::Backup, download::{DownloadError, Progress}, export::ImportExportMessage, hw::HardwareWalletMessage, @@ -63,8 +66,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, diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index c1379cb77..b5b7d4ecb 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -27,7 +27,6 @@ use crate::{ settings::{update_settings_file, AuthConfig, SettingsError, WalletId, WalletSettings}, wallet::wallet_name, }, - backup, daemon::{Daemon, DaemonError}, delete, dir::LianaDirectory, @@ -836,7 +835,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/descriptor/mod.rs b/liana-gui/src/installer/step/descriptor/mod.rs index cea59a7b1..37a1872d7 100644 --- a/liana-gui/src/installer/step/descriptor/mod.rs +++ b/liana-gui/src/installer/step/descriptor/mod.rs @@ -17,7 +17,7 @@ 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::{ @@ -412,29 +412,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/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") From 0ea96c71aced436c468d0cf97456f092dea354be Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Fri, 8 Aug 2025 12:11:55 +0200 Subject: [PATCH 04/11] gui: wording + add encrypted descriptor export --- liana-gui/src/app/state/settings/mod.rs | 4 ++- liana-gui/src/app/state/settings/wallet.rs | 39 +++++----------------- liana-gui/src/app/view/message.rs | 2 +- liana-gui/src/app/view/settings/mod.rs | 29 ++++++---------- 4 files changed, 24 insertions(+), 50 deletions(-) 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( From ae54e58e02c830054929cf842524c7729f702201 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Wed, 13 Aug 2025 05:55:05 +0200 Subject: [PATCH 05/11] installer: implement import encrypted descriptor --- liana-gui/src/app/state/export.rs | 7 +- liana-gui/src/export.rs | 46 +++-- liana-gui/src/hw.rs | 2 + liana-gui/src/installer/message.rs | 3 + liana-gui/src/installer/mod.rs | 32 +++- .../installer/step/descriptor/editor/key.rs | 2 +- .../src/installer/step/descriptor/mod.rs | 161 +++++++++++++++--- liana-gui/src/installer/view/editor/mod.rs | 8 - liana-gui/src/lib.rs | 1 + liana-gui/src/utils/mod.rs | 7 + liana-ui/src/component/modal.rs | 2 +- 11 files changed, 207 insertions(+), 64 deletions(-) diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs index 111d420f6..e66229752 100644 --- a/liana-gui/src/app/state/export.rs +++ b/liana-gui/src/app/state/export.rs @@ -85,7 +85,7 @@ impl ExportModal { 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", } } @@ -113,7 +113,7 @@ impl ExportModal { ImportExportType::ExportBackup(_) | ImportExportType::ExportProcessBackup(..) => { format!("liana-backup-{date}.json") } - ImportExportType::WalletFromBackup | ImportExportType::ImportBackup { .. } => { + ImportExportType::FromBackup | ImportExportType::ImportBackup { .. } => { "liana-backup.json".to_string() } } @@ -193,8 +193,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/export.rs b/liana-gui/src/export.rs index 2e8b7701a..7814eb880 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -129,6 +129,7 @@ pub enum Error { TxidNotMatch, InsanePsbt, OutpointNotOwned, + UnknownFormat, } impl Display for Error { @@ -157,6 +158,7 @@ impl Display for Error { "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"), } } } @@ -175,7 +177,7 @@ pub enum ImportExportType { overwrite_labels: Option>, overwrite_aliases: Option>, }, - WalletFromBackup, + FromBackup, Descriptor(LianaDescriptor), ExportLabels, ImportPsbt(Option), @@ -197,7 +199,7 @@ impl ImportExportType { ImportExportType::ImportBackup { .. } | ImportExportType::ImportPsbt(_) | ImportExportType::ImportXpub(_) - | ImportExportType::WalletFromBackup + | ImportExportType::FromBackup | ImportExportType::ImportDescriptor => "Import successful", } } @@ -262,6 +264,7 @@ pub enum Progress { Backup, ), ), + EncryptedFile(Vec), } pub struct Export { @@ -328,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); @@ -1055,22 +1058,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, 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 8edf626fa..a3c077acf 100644 --- a/liana-gui/src/installer/message.rs +++ b/liana-gui/src/installer/message.rs @@ -1,3 +1,4 @@ +use crate::decrypt::Decrypt; use liana::{ descriptors::LianaDescriptor, miniscript::{ @@ -77,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 b5b7d4ecb..42f9769d8 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -30,7 +30,7 @@ use crate::{ daemon::{Daemon, DaemonError}, delete, dir::LianaDirectory, - hw::{HardwareWalletConfig, HardwareWallets}, + hw::{HardwareWalletConfig, HardwareWalletMessage, HardwareWallets}, services::{ self, connect::client::{ @@ -241,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) { 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 37a1872d7..6b80761e3 100644 --- a/liana-gui/src/installer/step/descriptor/mod.rs +++ b/liana-gui/src/installer/step/descriptor/mod.rs @@ -18,8 +18,9 @@ use async_hwi::DeviceKind; use crate::{ app::{settings::KeySetting, state::export::ExportModal, wallet::wallet_name}, backup::Backup, + decrypt::{Decrypt, DecryptModal}, export::{ImportExportMessage, ImportExportType, Progress}, - hw::{HardwareWallet, HardwareWallets}, + hw::{HardwareWallet, HardwareWalletMessage, HardwareWallets}, installer::{ message::{self, Message}, step::{Context, Step}, @@ -27,11 +28,20 @@ use crate::{ }, }; +const BACKUP_NETWORK_NOT_MATCH: &str = "Backup network do not match the selected network!"; + +#[derive(Debug)] +pub enum ImportDescriptorModal { + None, + Export(ExportModal), + Decrypt(DecryptModal), +} + 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 +54,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 +94,28 @@ impl Step for ImportDescriptor { ctx.remote_backend.is_some() } - fn subscription(&self, _hws: &HardwareWallets) -> Subscription { - if let Some(modal) = &self.modal { + fn subscription(&self, hws: &HardwareWallets) -> Subscription { + if let ImportDescriptorModal::Export(modal) = &self.modal { if let Some(sub) = modal.subscription() { sub.map(|m| Message::ImportExport(ImportExportMessage::Progress(m))) } else { Subscription::none() } + } else if let ImportDescriptorModal::Decrypt(modal) = &self.modal { + let mut batch = vec![hws.refresh().map(Message::HardwareWallets)]; + if let Some(import_modal) = modal.modal.as_ref() { + if let Some(sub) = import_modal.subscription() { + batch.push(sub.map(|p| Message::ImportExport(ImportExportMessage::Progress(p)))) + } + } + Subscription::batch(batch) } else { Subscription::none() } } - 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 +125,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 +146,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 +156,108 @@ 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(ImportExportMessage::Progress(Progress::EncryptedFile( + bytes, + ))) => { + self.modal = ImportDescriptorModal::Decrypt(DecryptModal::new(bytes, self.network)); + None + } + Message::ImportExport(ImportExportMessage::Progress(Progress::Xpub(xpub))) => { + if let ImportDescriptorModal::Decrypt(modal) = &mut self.modal { + let _ = modal.update(Decrypt::CloseModal); + Some(modal.update(Decrypt::Xpub(xpub))) + } else { + None + } } Message::ImportExport(m) => { - if let Some(modal) = self.modal.as_mut() { + if let ImportDescriptorModal::Export(modal) = &mut self.modal { let task: Task = modal.update(m); - return task; - }; + Some(task) + } else if let ImportDescriptorModal::Decrypt(modal) = &mut self.modal { + if let Some(mo) = &mut modal.modal { + let task: Task = mo.update(m); + Some(task) + } else { + None + } + } else { + None + } } - _ => {} - } - Task::none() + 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) => { + if let ImportDescriptorModal::Decrypt(modal) = &mut self.modal { + 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(_) => Some(modal.update(msg)), + Decrypt::Backup(_) | Decrypt::Close => None, + } + } else { + None + } + } + _ => None, + }; + task.unwrap_or(Task::none()) } fn apply(&mut self, ctx: &mut Context) -> bool { @@ -199,10 +306,10 @@ impl Step for ImportDescriptor { self.wrong_network, self.error.as_ref(), ); - if let Some(modal) = &self.modal { - modal.view(content) - } else { - content + match &self.modal { + ImportDescriptorModal::None => content, + ImportDescriptorModal::Export(modal) => modal.view(content), + ImportDescriptorModal::Decrypt(modal) => modal.view(content), } } } 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/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) } From 297328797038d9770b064e47296482292634e8e0 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Wed, 27 Aug 2025 06:05:38 +0200 Subject: [PATCH 06/11] backup: implement encrypted_backup::ToPayload trait --- liana-gui/src/backup.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/liana-gui/src/backup.rs b/liana-gui/src/backup.rs index e0be0d6d5..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::{ @@ -450,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::*; From 39bcd44112345531108867d6fad6420cab637748 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Wed, 27 Aug 2025 06:22:06 +0200 Subject: [PATCH 07/11] export: remove unused variant of ImportExportType --- liana-gui/src/app/state/export.rs | 6 ++---- liana-gui/src/export.rs | 3 --- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs index e66229752..80bb5627c 100644 --- a/liana-gui/src/app/state/export.rs +++ b/liana-gui/src/app/state/export.rs @@ -76,9 +76,7 @@ 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", @@ -110,7 +108,7 @@ impl ExportModal { 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::FromBackup | ImportExportType::ImportBackup { .. } => { diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs index 7814eb880..dae0332da 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -168,7 +168,6 @@ pub enum ImportExportType { Transactions, ExportPsbt(String), ExportXpub(String), - ExportBackup(String), ExportEncryptedDescriptor(Box), ExportProcessBackup(LianaDirectory, Network, Arc, Arc), ImportBackup { @@ -190,7 +189,6 @@ impl ImportExportType { match self { ImportExportType::Transactions | ImportExportType::ExportPsbt(_) - | ImportExportType::ExportBackup(_) | ImportExportType::Descriptor(_) | ImportExportType::ExportProcessBackup(..) | ImportExportType::ExportXpub(_) @@ -309,7 +307,6 @@ 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 } From c585794d8720ddcb86984c0b6d162663bf6dc201 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Wed, 27 Aug 2025 06:43:13 +0200 Subject: [PATCH 08/11] export: export backup as encrypted --- liana-gui/src/app/state/export.rs | 2 +- liana-gui/src/export.rs | 35 +++++++++++++++++++++++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs index 80bb5627c..63fb2a6a4 100644 --- a/liana-gui/src/app/state/export.rs +++ b/liana-gui/src/app/state/export.rs @@ -109,7 +109,7 @@ impl ExportModal { ImportExportType::ImportDescriptor => "descriptor.txt".into(), ImportExportType::ExportLabels => format!("liana-labels-{date}.jsonl"), ImportExportType::ExportProcessBackup(..) => { - format!("liana-backup-{date}.json") + format!("liana-backup-{date}.beb") } ImportExportType::FromBackup | ImportExportType::ImportBackup { .. } => { "liana-backup.json".to_string() diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs index dae0332da..d1d9dccdc 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -9,7 +9,7 @@ use std::{ time, }; -use encrypted_backup::EncryptedBackup; +use encrypted_backup::{EncryptedBackup, ToPayload}; use tokio::sync::mpsc::{channel, unbounded_channel, Sender, UnboundedReceiver, UnboundedSender}; use async_hwi::bitbox::api::btc::Fingerprint; @@ -588,9 +588,17 @@ pub async fn export_string( sender: &UnboundedSender, 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(str.as_bytes())?; + file.write_all(bytes)?; send_progress!(sender, Progress(100.0)); send_progress!(sender, Ended); Ok(()) @@ -1323,9 +1331,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( @@ -1337,10 +1344,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)] From 330cb696e09942c3c59a31c72350f74b7a132ac5 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Mon, 1 Sep 2025 05:38:48 +0200 Subject: [PATCH 09/11] settings: when importing backup, try to parse both encrypted & unencrypted --- liana-gui/src/export.rs | 57 ++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs index d1d9dccdc..8ba310e28 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -9,7 +9,10 @@ use std::{ time, }; -use encrypted_backup::{EncryptedBackup, ToPayload}; +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; @@ -779,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 From 48823ac1626d252b3617124a8b9397fa7aca9dba Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Mon, 1 Sep 2025 06:56:06 +0200 Subject: [PATCH 10/11] installer: move ImportDescriptorModal with some logic to import_descriptor.rs --- .../src/installer/step/descriptor/mod.rs | 84 +------------- .../src/installer/step/import_descriptor.rs | 107 ++++++++++++++++++ liana-gui/src/installer/step/mod.rs | 1 + 3 files changed, 113 insertions(+), 79 deletions(-) create mode 100644 liana-gui/src/installer/step/import_descriptor.rs diff --git a/liana-gui/src/installer/step/descriptor/mod.rs b/liana-gui/src/installer/step/descriptor/mod.rs index 6b80761e3..fd130e6d7 100644 --- a/liana-gui/src/installer/step/descriptor/mod.rs +++ b/liana-gui/src/installer/step/descriptor/mod.rs @@ -23,20 +23,12 @@ use crate::{ hw::{HardwareWallet, HardwareWalletMessage, HardwareWallets}, installer::{ message::{self, Message}, + step::import_descriptor::{ImportDescriptorModal, BACKUP_NETWORK_NOT_MATCH}, step::{Context, Step}, view, Error, }, }; -const BACKUP_NETWORK_NOT_MATCH: &str = "Backup network do not match the selected network!"; - -#[derive(Debug)] -pub enum ImportDescriptorModal { - None, - Export(ExportModal), - Decrypt(DecryptModal), -} - pub struct ImportDescriptor { network: Network, wrong_network: bool, @@ -95,23 +87,7 @@ impl Step for ImportDescriptor { } fn subscription(&self, hws: &HardwareWallets) -> Subscription { - if let ImportDescriptorModal::Export(modal) = &self.modal { - if let Some(sub) = modal.subscription() { - sub.map(|m| Message::ImportExport(ImportExportMessage::Progress(m))) - } else { - Subscription::none() - } - } else if let ImportDescriptorModal::Decrypt(modal) = &self.modal { - let mut batch = vec![hws.refresh().map(Message::HardwareWallets)]; - if let Some(import_modal) = modal.modal.as_ref() { - if let Some(sub) = import_modal.subscription() { - batch.push(sub.map(|p| Message::ImportExport(ImportExportMessage::Progress(p)))) - } - } - Subscription::batch(batch) - } else { - Subscription::none() - } + self.modal.subscriptions(hws) } fn update(&mut self, hws: &mut HardwareWallets, message: Message) -> Task { @@ -167,29 +143,7 @@ impl Step for ImportDescriptor { self.modal = ImportDescriptorModal::Decrypt(DecryptModal::new(bytes, self.network)); None } - Message::ImportExport(ImportExportMessage::Progress(Progress::Xpub(xpub))) => { - if let ImportDescriptorModal::Decrypt(modal) = &mut self.modal { - let _ = modal.update(Decrypt::CloseModal); - Some(modal.update(Decrypt::Xpub(xpub))) - } else { - None - } - } - Message::ImportExport(m) => { - if let ImportDescriptorModal::Export(modal) = &mut self.modal { - let task: Task = modal.update(m); - Some(task) - } else if let ImportDescriptorModal::Decrypt(modal) = &mut self.modal { - if let Some(mo) = &mut modal.modal { - let task: Task = mo.update(m); - Some(task) - } else { - None - } - } else { - 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) @@ -230,31 +184,7 @@ impl Step for ImportDescriptor { } None } - Message::Decrypt(msg) => { - if let ImportDescriptorModal::Decrypt(modal) = &mut self.modal { - 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(_) => Some(modal.update(msg)), - Decrypt::Backup(_) | Decrypt::Close => None, - } - } else { - None - } - } + Message::Decrypt(msg) => Some(self.modal.update(Message::Decrypt(msg))), _ => None, }; task.unwrap_or(Task::none()) @@ -306,11 +236,7 @@ impl Step for ImportDescriptor { self.wrong_network, self.error.as_ref(), ); - match &self.modal { - ImportDescriptorModal::None => content, - ImportDescriptorModal::Export(modal) => modal.view(content), - ImportDescriptorModal::Decrypt(modal) => modal.view(content), - } + self.modal.view(content) } } 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; From bf1fba329b81839b61a73ebb394add5aceab6573 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Mon, 1 Sep 2025 09:03:36 +0200 Subject: [PATCH 11/11] installer: impl import encrypted backup/descriptor for liana-connect --- liana-gui/src/installer/step/backend.rs | 102 ++++++++++++++---------- 1 file changed, 61 insertions(+), 41 deletions(-) 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) } }