From 50b73de96715364f4e9d6a6a69bf0f5c6defd8eb Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Thu, 14 Aug 2025 07:40:12 +0200 Subject: [PATCH 01/11] liana-ui: add modal::collapsible_input_button() helper --- liana-ui/src/widget/mod.rs | 1 + liana-ui/src/widget/modal.rs | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 liana-ui/src/widget/modal.rs diff --git a/liana-ui/src/widget/mod.rs b/liana-ui/src/widget/mod.rs index 9bfe4bcd5..33a6cdd46 100644 --- a/liana-ui/src/widget/mod.rs +++ b/liana-ui/src/widget/mod.rs @@ -1,6 +1,7 @@ mod cursor; mod editor; mod menu; +pub mod modal; pub mod text_input; use crate::theme::Theme; diff --git a/liana-ui/src/widget/modal.rs b/liana-ui/src/widget/modal.rs new file mode 100644 index 000000000..fa1f1ed0d --- /dev/null +++ b/liana-ui/src/widget/modal.rs @@ -0,0 +1,87 @@ +use iced::{ + alignment::Vertical, + widget::{ + button::{Status, Style}, + row, Space, + }, + Length, +}; + +use crate::{ + color, + component::{ + form::{self, Value}, + text, + }, + icon, + theme::{self, Theme}, +}; + +use super::{Button, Column, Element, Row, Text}; + +pub const MODAL_WIDTH: u16 = 550; +pub const BTN_W: u16 = 400; +pub const BTN_H: u16 = 40; +pub const SPACING: u16 = 10; + +fn widget_style(theme: &Theme, status: Status) -> Style { + crate::theme::button::secondary(theme, status) +} + +#[allow(clippy::too_many_arguments)] +pub fn collapsible_input_button<'a, Message, Paste, Collapse, Input>( + collapsed: bool, + icon: Option>, + label: String, + input_placeholder: String, + input_value: &Value, + input_message: Option, + paste_message: Option, + collapse_message: Collapse, +) -> Element<'a, Message> +where + Input: 'static + Fn(String) -> Message, + Paste: 'static + Fn() -> Message, + Collapse: 'static + Fn() -> Message, + Message: Clone + 'static, +{ + let form = if let Some(input_message) = input_message { + form::Form::new(&input_placeholder, input_value, input_message) + } else { + form::Form::new_disabled(&input_placeholder, input_value) + } + .padding(10); + let paste = paste_message + .map(|m| Button::new(icon::clipboard_icon().style(theme::text::secondary)).on_press(m())); + + let icon = icon.map(|i| i.color(color::WHITE)); + + if !collapsed { + let line = Row::new().push(form).push_maybe(paste).spacing(SPACING); + let col = Column::new() + .push(row![ + text::p1_regular(label).color(color::WHITE), + Space::with_width(Length::Fill) + ]) + .push(line); + let row = Row::new() + .push_maybe(icon) + .push(col) + .align_y(Vertical::Center) + .spacing(SPACING); + + Button::new(row).style(widget_style) + } else { + let row = Row::new() + .push_maybe(icon) + .push(text::p1_regular(label)) + .height(BTN_H) + .spacing(SPACING) + .align_y(Vertical::Center); + Button::new(row) + .on_press(collapse_message()) + .style(widget_style) + } + .width(BTN_W) + .into() +} From 3d9000eb22a102f1b9873af17a9de963e1505212 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Thu, 14 Aug 2025 09:20:50 +0200 Subject: [PATCH 02/11] liana-ui: add modal::header() helper --- liana-ui/src/icon.rs | 4 ++++ liana-ui/src/widget/modal.rs | 29 +++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/liana-ui/src/icon.rs b/liana-ui/src/icon.rs index 1fd9385e1..535e10169 100644 --- a/liana-ui/src/icon.rs +++ b/liana-ui/src/icon.rs @@ -27,6 +27,10 @@ pub fn arrow_return_right() -> Text<'static> { bootstrap_icon('\u{F132}') } +pub fn arrow_back() -> Text<'static> { + bootstrap_icon('\u{F12E}') +} + pub fn chevron_right() -> Text<'static> { bootstrap_icon('\u{F285}') } diff --git a/liana-ui/src/widget/modal.rs b/liana-ui/src/widget/modal.rs index fa1f1ed0d..3e59df43c 100644 --- a/liana-ui/src/widget/modal.rs +++ b/liana-ui/src/widget/modal.rs @@ -10,11 +10,12 @@ use iced::{ use crate::{ color, component::{ + button, form::{self, Value}, text, }, icon, - theme::{self, Theme}, + theme::Theme, }; use super::{Button, Column, Element, Row, Text}; @@ -28,6 +29,30 @@ fn widget_style(theme: &Theme, status: Status) -> Style { crate::theme::button::secondary(theme, status) } +pub fn header<'a, Message, Back, Close>( + label: Option, + back_message: Option, + close_message: Option, +) -> Element<'a, Message> +where + Back: 'static + Fn() -> Message, + Close: 'static + Fn() -> Message, + Message: Clone + 'static, +{ + let back = back_message + .map(|m| button::transparent(Some(icon::arrow_back().size(25)), "").on_press(m())); + let title = label.map(text::h3); + let close = close_message + .map(|m| button::transparent(Some(icon::cross_icon().size(40)), "").on_press(m())); + Row::new() + .push_maybe(back) + .push_maybe(title) + .push(Space::with_width(Length::Fill)) + .push_maybe(close) + .align_y(Vertical::Center) + .into() +} + #[allow(clippy::too_many_arguments)] pub fn collapsible_input_button<'a, Message, Paste, Collapse, Input>( collapsed: bool, @@ -52,7 +77,7 @@ where } .padding(10); let paste = paste_message - .map(|m| Button::new(icon::clipboard_icon().style(theme::text::secondary)).on_press(m())); + .map(|m| Button::new(icon::clipboard_icon().color(color::BLACK)).on_press(m())); let icon = icon.map(|i| i.color(color::WHITE)); From e5af0301d5bdf87b29b89705eb492f78fb381dc4 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Thu, 14 Aug 2025 10:47:56 +0200 Subject: [PATCH 03/11] liana-ui: add modal::optional_section() helper --- liana-ui/src/widget/modal.rs | 44 +++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/liana-ui/src/widget/modal.rs b/liana-ui/src/widget/modal.rs index 3e59df43c..04ab76d81 100644 --- a/liana-ui/src/widget/modal.rs +++ b/liana-ui/src/widget/modal.rs @@ -15,7 +15,7 @@ use crate::{ text, }, icon, - theme::Theme, + theme::{self, Theme}, }; use super::{Button, Column, Element, Row, Text}; @@ -23,10 +23,11 @@ use super::{Button, Column, Element, Row, Text}; pub const MODAL_WIDTH: u16 = 550; pub const BTN_W: u16 = 400; pub const BTN_H: u16 = 40; -pub const SPACING: u16 = 10; +pub const V_SPACING: u16 = 10; +pub const H_SPACING: u16 = 5; fn widget_style(theme: &Theme, status: Status) -> Style { - crate::theme::button::secondary(theme, status) + theme::button::secondary(theme, status) } pub fn header<'a, Message, Back, Close>( @@ -53,6 +54,37 @@ where .into() } +pub fn optional_section<'a, Message, Collapse, Fold>( + collapsed: bool, + title: String, + collapse: Collapse, + fold: Fold, +) -> Element<'a, Message> +where + Collapse: 'static + Fn() -> Message, + Fold: 'static + Fn() -> Message, + Message: Clone + 'static, +{ + let icon = if collapsed { + icon::collapsed_icon().style(theme::text::secondary) + } else { + icon::collapse_icon().style(theme::text::secondary) + }; + + let msg = if !collapsed { collapse() } else { fold() }; + + let row = Row::new() + .push(text::p1_bold(&title)) + .push(icon) + .align_y(Vertical::Center) + .spacing(H_SPACING); + + Button::new(row) + .style(theme::button::transparent_border) + .on_press(msg) + .into() +} + #[allow(clippy::too_many_arguments)] pub fn collapsible_input_button<'a, Message, Paste, Collapse, Input>( collapsed: bool, @@ -82,7 +114,7 @@ where let icon = icon.map(|i| i.color(color::WHITE)); if !collapsed { - let line = Row::new().push(form).push_maybe(paste).spacing(SPACING); + let line = Row::new().push(form).push_maybe(paste).spacing(V_SPACING); let col = Column::new() .push(row![ text::p1_regular(label).color(color::WHITE), @@ -93,7 +125,7 @@ where .push_maybe(icon) .push(col) .align_y(Vertical::Center) - .spacing(SPACING); + .spacing(V_SPACING); Button::new(row).style(widget_style) } else { @@ -101,7 +133,7 @@ where .push_maybe(icon) .push(text::p1_regular(label)) .height(BTN_H) - .spacing(SPACING) + .spacing(V_SPACING) .align_y(Vertical::Center); Button::new(row) .on_press(collapse_message()) From 1cff0d45226894b29d08bcd2b7c8b571a0ff22e0 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Thu, 14 Aug 2025 12:43:35 +0200 Subject: [PATCH 04/11] liana-ui: add modal::button_entry() helper --- liana-ui/src/widget/modal.rs | 43 ++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/liana-ui/src/widget/modal.rs b/liana-ui/src/widget/modal.rs index 04ab76d81..07da2836e 100644 --- a/liana-ui/src/widget/modal.rs +++ b/liana-ui/src/widget/modal.rs @@ -2,7 +2,7 @@ use iced::{ alignment::Vertical, widget::{ button::{Status, Style}, - row, Space, + container, row, Space, }, Length, }; @@ -12,7 +12,7 @@ use crate::{ component::{ button, form::{self, Value}, - text, + text, tooltip, }, icon, theme::{self, Theme}, @@ -142,3 +142,42 @@ where .width(BTN_W) .into() } + +pub fn button_entry<'a, Message, OnClick>( + icon: Option>, + label: &'a str, + tooltip_str: Option<&'static str>, + error: Option, + on_press: Option, +) -> Element<'a, Message> +where + OnClick: 'static + Fn() -> Message, + Message: Clone + 'static, +{ + let error = error.map(|e| { + row![ + text::p1_regular(e).color(color::ORANGE), + Space::with_width(Length::Fill) + ] + }); + + let tt = tooltip_str.map(|s| tooltip(s)); + + let row = Row::new() + .push_maybe(icon) + .push(text::p1_regular(label)) + .push(Space::with_width(Length::Fill)) + .push_maybe(tt) + .spacing(H_SPACING) + .align_y(Vertical::Center) + .height(BTN_H); + + let col = Column::new().push(row).push_maybe(error).width(BTN_W); + + let mut btn = Button::new(container(col)).style(widget_style); + if let Some(msg) = on_press { + let msg = msg(); + btn = btn.on_press(msg); + } + btn.into() +} From 0e02ebec0f3696c4ba42701d2706f625f375d95e Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Thu, 14 Aug 2025 14:21:40 +0200 Subject: [PATCH 05/11] liana-ui: add modal::key_entry() helper --- liana-ui/src/widget/modal.rs | 47 ++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/liana-ui/src/widget/modal.rs b/liana-ui/src/widget/modal.rs index 07da2836e..46dcbb1da 100644 --- a/liana-ui/src/widget/modal.rs +++ b/liana-ui/src/widget/modal.rs @@ -1,8 +1,8 @@ use iced::{ - alignment::Vertical, + alignment::{Horizontal, Vertical}, widget::{ button::{Status, Style}, - container, row, Space, + column, container, row, Space, }, Length, }; @@ -143,6 +143,49 @@ where .into() } +pub fn key_entry<'a, Message, OnClick>( + icon: Option>, + name: String, + fingerprint: Option, + tooltip_str: Option<&'static str>, + error: Option, + mut message: Option, + on_press: Option, +) -> Element<'a, Message> +where + OnClick: 'static + Fn() -> Message, + Message: Clone + 'static, +{ + if error.is_some() { + message = None; + } + let message = message.map(text::p1_regular); + let error = error.map(|e| text::p1_regular(e).color(color::ORANGE)); + let tt = tooltip_str.map(|s| tooltip(s)); + + let designation = column![ + text::p1_bold(name), + text::p1_regular(fingerprint.unwrap_or(" - ".to_string())) + ] + .align_x(Horizontal::Center); + let row = Row::new() + .push_maybe(icon.as_ref().map(|_| Space::with_width(H_SPACING))) + .push_maybe(icon) + .push(Space::with_width(H_SPACING)) + .push(designation) + .push_maybe(message) + .push_maybe(error) + .push(Space::with_width(Length::Fill)) + .push_maybe(tt) + .align_y(Vertical::Center) + .spacing(V_SPACING); + let mut btn = Button::new(row).style(widget_style).width(BTN_W); + if let Some(msg) = on_press { + btn = btn.on_press(msg()) + } + btn.into() +} + pub fn button_entry<'a, Message, OnClick>( icon: Option>, label: &'a str, From 48d14499518beef9c0e0928e7ee01201a573d529 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Fri, 8 Aug 2025 10:02:49 +0200 Subject: [PATCH 06/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 514d0ac40..5bfda1464 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=1f3a68a#1f3a68ad028e1f3bd2994f9c5b3148124c5c04d7" +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..03dd9b185 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= "1f3a68a", features = ["miniscript_12_0"]} + [target.'cfg(windows)'.dependencies] zip = { version = "0.6", default-features=false, features = ["bzip2", "deflate"] } From 4799644f85b021b7a5845120ea1abb052002a95a Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Fri, 8 Aug 2025 10:04:52 +0200 Subject: [PATCH 07/11] installer: export encrypted descriptor instead plaintext backup --- liana-gui/src/app/state/export.rs | 3 ++ liana-gui/src/backup.rs | 1 + 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 ++++---- 8 files changed, 72 insertions(+), 28 deletions(-) diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs index 46cc9c69f..56e3b25d6 100644 --- a/liana-gui/src/app/state/export.rs +++ b/liana-gui/src/app/state/export.rs @@ -78,6 +78,7 @@ impl ExportModal { ImportExportType::ImportXpub(_) => "Import Xpub", ImportExportType::ExportBackup(_) => "Export Backup", ImportExportType::Descriptor(_) => "Export Descriptor", + ImportExportType::ExportEncryptedDescriptor(_) => "Export Encrypted Descriptor", ImportExportType::ExportProcessBackup(..) | ImportExportType::ExportLabels => { "Export Labels" } @@ -105,6 +106,8 @@ impl ExportModal { .to_string(); format!("liana-{}.txt", checksum) } + // TODO: update w/ BIP # + ImportExportType::ExportEncryptedDescriptor(_) => "liana.bipxxxx".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 bb3b09d82..61d374ada 100644 --- a/liana-gui/src/backup.rs +++ b/liana-gui/src/backup.rs @@ -135,6 +135,7 @@ impl Backup { /// /// # Arguments /// * `ctx` - the installer context + #[allow(unused)] pub async fn from_installer_descriptor_step(ctx: Context) -> Result { let descriptor = ctx.descriptor.clone().ok_or(Error::DescriptorMissing)?; diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs index fd4e74ee5..abfb950cd 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.policy().into_multipath_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 606f979c7..223cecf4e 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; @@ -13,7 +16,7 @@ use crate::{ settings::{self, ProviderKey}, view::Close, }, - backup::{self, Backup}, + backup::Backup, download::{DownloadError, Progress}, export::ImportExportMessage, hw::HardwareWalletMessage, @@ -59,8 +62,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 a8436e84b..31de2331b 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, @@ -833,7 +832,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 bf4f2c79c..ab5a53a2c 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 DEFINE_DESCRIPTOR_FINGERPRINT_TOOLTIP: &str = "The alias is applied on all the keys derived from the same seed"; 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."; 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 2b5ae66ee..243a27837 100644 --- a/liana-gui/src/installer/view/mod.rs +++ b/liana-gui/src/installer/view/mod.rs @@ -783,16 +783,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() @@ -859,8 +860,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 61de544eb19c75ba09181cb73c112fba60c883b6 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Thu, 14 Aug 2025 07:40:12 +0200 Subject: [PATCH 08/11] liana-ui: add modal::collapsible_input_button() helper --- liana-ui/src/widget/modal.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/liana-ui/src/widget/modal.rs b/liana-ui/src/widget/modal.rs index 46dcbb1da..5fc5817a4 100644 --- a/liana-ui/src/widget/modal.rs +++ b/liana-ui/src/widget/modal.rs @@ -124,8 +124,7 @@ where let row = Row::new() .push_maybe(icon) .push(col) - .align_y(Vertical::Center) - .spacing(V_SPACING); + .align_y(Vertical::Center); Button::new(row).style(widget_style) } else { @@ -133,7 +132,6 @@ where .push_maybe(icon) .push(text::p1_regular(label)) .height(BTN_H) - .spacing(V_SPACING) .align_y(Vertical::Center); Button::new(row) .on_press(collapse_message()) From ff94b975605b95d5534de91d1fa34b1a239e1f79 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Thu, 14 Aug 2025 10:47:56 +0200 Subject: [PATCH 09/11] liana-ui: add modal::optional_section() helper --- liana-ui/src/widget/modal.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/liana-ui/src/widget/modal.rs b/liana-ui/src/widget/modal.rs index 5fc5817a4..46dcbb1da 100644 --- a/liana-ui/src/widget/modal.rs +++ b/liana-ui/src/widget/modal.rs @@ -124,7 +124,8 @@ where let row = Row::new() .push_maybe(icon) .push(col) - .align_y(Vertical::Center); + .align_y(Vertical::Center) + .spacing(V_SPACING); Button::new(row).style(widget_style) } else { @@ -132,6 +133,7 @@ where .push_maybe(icon) .push(text::p1_regular(label)) .height(BTN_H) + .spacing(V_SPACING) .align_y(Vertical::Center); Button::new(row) .on_press(collapse_message()) From 5b1603f8eba9d7d52bbcc66fc314ec4da8e33eef Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Fri, 8 Aug 2025 12:11:55 +0200 Subject: [PATCH 10/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.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 bbe5438d9..f56412347 100644 --- a/liana-gui/src/app/state/settings/mod.rs +++ b/liana-gui/src/app/state/settings/mod.rs @@ -244,7 +244,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 63f810765..b35e3f01c 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 d956b8505..f2adfca54 100644 --- a/liana-gui/src/app/view/message.rs +++ b/liana-gui/src/app/view/message.rs @@ -92,7 +92,7 @@ pub enum SettingsMessage { RemoteBackendSettings(RemoteBackendSettingsMessage), EditWalletSettings, ImportExportSection, - ExportDescriptor, + ExportEncryptedDescriptor, ExportTransactions, ExportLabels, ExportWallet, diff --git a/liana-gui/src/app/view/settings.rs b/liana-gui/src/app/view/settings.rs index 034dc37c1..f970494f9 100644 --- a/liana-gui/src/app/view/settings.rs +++ b/liana-gui/src/app/view/settings.rs @@ -208,9 +208,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( @@ -228,14 +228,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), @@ -1000,18 +1000,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()) @@ -1029,6 +1017,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())), @@ -1108,7 +1102,6 @@ pub fn wallet_settings<'a>( Column::new() .spacing(20) .push(header) - .push(import_export) .push(descr) .push( card::simple(display_policy( From b54f68d9a9cd89443afaee6f5f9582c90406608b Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Wed, 13 Aug 2025 05:55:05 +0200 Subject: [PATCH 11/11] installer: implement import encrypted descriptor --- Cargo.lock | 2 +- liana-gui/Cargo.toml | 2 +- liana-gui/src/app/state/export.rs | 9 +- liana-gui/src/decrypt.rs | 650 ++++++++++++++++++ liana-gui/src/export.rs | 50 +- liana-gui/src/hw.rs | 2 + liana-gui/src/installer/message.rs | 3 + liana-gui/src/installer/mod.rs | 32 +- .../src/installer/step/descriptor/mod.rs | 170 ++++- liana-gui/src/installer/view/editor/mod.rs | 8 +- liana-gui/src/lib.rs | 1 + liana-gui/src/utils/mod.rs | 8 + liana-ui/src/widget/modal.rs | 2 +- 13 files changed, 875 insertions(+), 64 deletions(-) create mode 100644 liana-gui/src/decrypt.rs diff --git a/Cargo.lock b/Cargo.lock index 5bfda1464..4121cbe86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "encrypted_backup" version = "0.1.0" -source = "git+https://github.com/pythcoiner/encrypted_backup.git?rev=1f3a68a#1f3a68ad028e1f3bd2994f9c5b3148124c5c04d7" +source = "git+https://github.com/pythcoiner/encrypted_backup.git?rev=797f3f6#797f3f679f1b7acf79c8cb0fe3907b2a8a5ca73a" dependencies = [ "aes-gcm", "miniscript", diff --git a/liana-gui/Cargo.toml b/liana-gui/Cargo.toml index 03dd9b185..a6bc82973 100644 --- a/liana-gui/Cargo.toml +++ b/liana-gui/Cargo.toml @@ -59,7 +59,7 @@ fs2 = "0.4.3" # Used for opening URLs in browser open = "5.3" -encrypted_backup = { git = "https://github.com/pythcoiner/encrypted_backup.git", rev= "1f3a68a", features = ["miniscript_12_0"]} +encrypted_backup = { git = "https://github.com/pythcoiner/encrypted_backup.git", rev= "797f3f6", 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 56e3b25d6..b14e45695 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", } } @@ -114,7 +114,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() } } @@ -194,8 +194,9 @@ impl ExportModal { ImportExportMessage::UpdateAliases(map.clone()).into() }); } - Progress::WalletFromBackup(_) => {} - Progress::Psbt(_) => {} + Progress::WalletFromBackup(_) + | Progress::EncryptedDescriptor(_) + | Progress::Psbt(_) => {} }, ImportExportMessage::TimedOut => { self.stop(ImportExportState::TimedOut); diff --git a/liana-gui/src/decrypt.rs b/liana-gui/src/decrypt.rs new file mode 100644 index 000000000..20033975f --- /dev/null +++ b/liana-gui/src/decrypt.rs @@ -0,0 +1,650 @@ +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::Modal, + text::{self, p1_regular}, + }, + icon, + widget::{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 => 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(_) => { + log::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(_) => { + log::error!("Descriptor decrypted but not a valid liana descriptor"); + Task::done(Decrypt::InvalidDescriptor.into()) + } + _ => { + log::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(); + println!("Emit fetch task for {fingerprint}"); + Task::perform( + async move { + println!( + "Fetch {} {} {:?}", + device.device_kind(), + fingerprint, + derivation_paths + ); + 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) + // FIXME: reuse widget_style() from #1768 + .style(liana_ui::theme::button::secondary) + // FIXME: reuse BTN_W from #1768 + .width(400) +} + +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)); + } + // col = col.push(Space::with_height(Length::Fill)); + + 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 abfb950cd..200ae3a75 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, ), ), + EncryptedDescriptor(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,36 @@ 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 descriptor +/// - liana wallet backup +/// - plaintext descriptor +pub async fn from_backup(sender: &UnboundedSender, path: PathBuf) -> Result<(), Error> { + log::debug!("export::from_backup()"); + // 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() { + log::debug!("export::from_backup() => encrypted"); + send_progress!(sender, EncryptedDescriptor(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), + }; + log::debug!("export::from_backup() => not encrypted"); + // 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, @@ -1079,6 +1096,7 @@ pub async fn wallet_from_backup( let descr = LianaDescriptor::from_str(&backup_str); match descr { Ok(descr) => { + log::debug!("export::from_backup() => bare descriptor"); let network = if descr.all_xpubs_net_is(Network::Bitcoin) { Network::Bitcoin } else { 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 223cecf4e..21ef0927b 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::{ @@ -71,6 +72,8 @@ pub enum Message { WalletAliasEdited(String), SelectAccount(Fingerprint, ChildNumber), OpenUrl(String), + Decrypt(Decrypt), + None, } impl Close for Message { diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index 31de2331b..83c6db44d 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/mod.rs b/liana-gui/src/installer/step/descriptor/mod.rs index 37a1872d7..aad538d76 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,29 @@ 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), +} + +impl ImportDescriptorModal { + pub fn is_none(&self) -> bool { + matches!(self, ImportDescriptorModal::None) + } + pub fn is_some(&self) -> bool { + !self.is_none() + } +} + 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 +63,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 +103,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 +134,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 +155,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 +165,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::EncryptedDescriptor(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 +315,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 b0fb4debf..409502466 100644 --- a/liana-gui/src/installer/view/editor/mod.rs +++ b/liana-gui/src/installer/view/editor/mod.rs @@ -5,7 +5,6 @@ pub mod template; use iced::widget::{self, container, pick_list, scrollable, slider, Button, Space}; use iced::{alignment, Alignment, Length}; -use liana::miniscript::bitcoin::Network; use liana_ui::component::text::{self, h3, p1_bold, p2_regular, H3_SIZE}; use liana_ui::image; use std::borrow::Cow; @@ -30,6 +29,7 @@ use crate::installer::{ view::defined_sequence, Error, }; +use crate::utils::example_xpub; use super::defined_threshold; @@ -517,12 +517,6 @@ pub fn edit_key_modal<'a>( scrollable(content).into() } -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 084f16d95..370cb8b11 100644 --- a/liana-gui/src/utils/mod.rs +++ b/liana-gui/src/utils/mod.rs @@ -1,5 +1,13 @@ +use liana::miniscript::bitcoin::{self, Network}; + #[cfg(test)] pub mod sandbox; #[cfg(test)] pub mod mock; + +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/widget/modal.rs b/liana-ui/src/widget/modal.rs index 46dcbb1da..af19fd72e 100644 --- a/liana-ui/src/widget/modal.rs +++ b/liana-ui/src/widget/modal.rs @@ -113,7 +113,7 @@ where let icon = icon.map(|i| i.color(color::WHITE)); - if !collapsed { + if collapsed { let line = Row::new().push(form).push_maybe(paste).spacing(V_SPACING); let col = Column::new() .push(row![