Merge #1675: Disable signing devices that should not or cannot spend

74087c37cf0b0796e794f98bebb11167ca243969 refactor: map fingerprint only once (Thomas Ballivet)
86c4896534c1ac169a3352651b0e27170e8cb06f Disable signing devices that are not related to the actual spending path (Thomas Ballivet)

Pull request description:

  Inspired by #1607 this shoud closes #1041.

  Notice that I added the wanted logic also for the recovery spending. That means you can only select a signing device related to the primary path for usual spend. But for recovery spending you can only select a signing device related to the recovery_path.

  You can see here both examples :

  ![Capture d’écran du 2025-04-25 17-32-39](https://github.com/user-attachments/assets/3c137161-a108-4313-be03-4ec1c27c24ab)

  ![Capture d’écran du 2025-04-25 17-30-53](https://github.com/user-attachments/assets/71ea7cd0-cc45-4dcc-b00a-66b3243408bc)

ACKs for top commit:
  jp1ac4:
    tACK 74087c37cf0b0796e794f98bebb11167ca243969.

Tree-SHA512: 93e452ca9f56e214f308cbaf3cdc9ffb52301e64fded860d1a961e97df21270e18f994cef83358459981ddc3029c89183d23bc33bab863253cfa2a2c5a9d9554
This commit is contained in:
edouardparis 2025-05-19 10:32:42 +02:00
commit ba17782604
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
7 changed files with 238 additions and 44 deletions

View File

@ -187,6 +187,7 @@ impl PsbtState {
cache.datadir_path.clone(),
cache.network,
self.saved,
self.tx.recovery_timelock(),
);
let cmd = modal.load(daemon);
self.modal = Some(PsbtModal::Sign(modal));
@ -438,6 +439,7 @@ pub struct SignModal {
signed: HashSet<Fingerprint>,
is_saved: bool,
display_modal: bool,
recovery_timelock: Option<u16>,
}
impl SignModal {
@ -447,6 +449,7 @@ impl SignModal {
datadir_path: LianaDirectory,
network: Network,
is_saved: bool,
recovery_timelock: Option<u16>,
) -> Self {
Self {
signing: HashSet::new(),
@ -456,6 +459,7 @@ impl SignModal {
signed,
is_saved,
display_modal: true,
recovery_timelock,
}
}
}
@ -565,6 +569,7 @@ impl Modal for SignModal {
view::psbt::sign_action(
self.error.as_ref(),
&self.hws.list,
&self.wallet.main_descriptor,
self.wallet.signer.as_ref().map(|s| s.fingerprint()),
self.wallet
.signer
@ -572,6 +577,7 @@ impl Modal for SignModal {
.and_then(|signer| self.wallet.keys_aliases.get(&signer.fingerprint)),
&self.signed,
&self.signing,
self.recovery_timelock,
),
)
.on_blur(Some(view::Message::Spend(view::SpendTxMessage::Cancel)))

View File

@ -13,6 +13,7 @@ pub fn hw_list_view(
hw: &HardwareWallet,
signed: bool,
signing: bool,
can_sign: bool,
) -> Element<Message> {
let mut bttn = Button::new(match hw {
HardwareWallet::Supported {
@ -40,6 +41,8 @@ pub fn hw_list_view(
alias.as_ref(),
"The wallet descriptor is not registered on the device.\n You can register it in the settings.",
)
} else if !can_sign {
hw::disabled_hardware_wallet(kind, version.as_ref(), fingerprint, "This signing device is not part of this spending path.")
} else {
hw::supported_hardware_wallet(kind, version.as_ref(), fingerprint, alias.as_ref())
}
@ -71,7 +74,7 @@ pub fn hw_list_view(
})
.style(theme::button::secondary)
.width(Length::Fill);
if !signing {
if can_sign && !signing {
if let HardwareWallet::Supported { registered, .. } = hw {
if *registered != Some(false) {
bttn = bttn.on_press(Message::SelectHardwareWallet(i));

View File

@ -5,6 +5,7 @@ use iced::{
Alignment, Length,
};
use liana::descriptors::LianaDescriptor;
use liana::{
descriptors::{LianaPolicy, PathInfo, PathSpendInfo},
miniscript::bitcoin::{
@ -1014,13 +1015,16 @@ fn change_view(output: &TxOut, network: Network) -> Element<Message> {
.into()
}
#[allow(clippy::too_many_arguments)]
pub fn sign_action<'a>(
warning: Option<&Error>,
hws: &'a [HardwareWallet],
descriptor: &LianaDescriptor,
signer: Option<Fingerprint>,
signer_alias: Option<&'a String>,
signed: &HashSet<Fingerprint>,
signing: &HashSet<Fingerprint>,
recovery_timelock: Option<u16>,
) -> Element<'a, Message> {
Column::new()
.push_maybe(warning.map(|w| warn(Some(w))))
@ -1037,29 +1041,37 @@ pub fn sign_action<'a>(
.push(hws.iter().enumerate().fold(
Column::new().spacing(10),
|col, (i, hw)| {
col.push(hw_list_view(
i,
hw,
hw.fingerprint()
.map(|f| signed.contains(&f))
.unwrap_or(false),
hw.fingerprint()
.map(|f| signing.contains(&f))
.unwrap_or(false),
))
let (signed, signing, can_sign) =
hw.fingerprint().map_or((false, false, false), |f| {
(
signed.contains(&f),
signing.contains(&f),
descriptor
.contains_fingerprint_in_path(f, recovery_timelock),
)
});
col.push(hw_list_view(i, hw, signed, signing, can_sign))
},
))
.push_maybe(signer.map(|fingerprint| {
Button::new(if signed.contains(&fingerprint) {
hw::sign_success_hot_signer(fingerprint, signer_alias)
} else {
hw::hot_signer(fingerprint, signer_alias)
.push_maybe({
signer.map(|fingerprint| {
let can_sign = descriptor
.contains_fingerprint_in_path(fingerprint, recovery_timelock);
let btn = Button::new(if signed.contains(&fingerprint) {
hw::sign_success_hot_signer(fingerprint, signer_alias)
} else {
hw::hot_signer(fingerprint, signer_alias, can_sign)
})
.padding(10)
.style(theme::button::secondary)
.width(Length::Fill);
if can_sign {
btn.on_press(Message::Spend(SpendTxMessage::SelectHotSigner))
} else {
btn
}
})
.on_press(Message::Spend(SpendTxMessage::SelectHotSigner))
.padding(10)
.style(theme::button::secondary)
.width(Length::Fill)
}))
})
.width(Length::Fill),
)
.spacing(20)

View File

@ -205,6 +205,10 @@ impl SpendTx {
.find(|&path| path.sigs_count >= path.threshold)
}
pub fn recovery_timelock(&self) -> Option<u16> {
self.sigs.recovery_paths().keys().max().cloned()
}
pub fn signers(&self) -> HashSet<Fingerprint> {
let mut signers = HashSet::new();
for fg in self.sigs.primary_path().signed_pubkeys.keys() {

View File

@ -125,10 +125,11 @@ pub fn unimplemented_method_hardware_wallet<'a, T: 'a, K: Display, V: Display, F
.width(Length::Fill)
}
pub fn unrelated_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>(
pub fn disabled_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>(
kind: K,
version: Option<V>,
fingerprint: F,
label: &'static str,
) -> Container<'a, T> {
let key = column(vec![
text::p1_regular(format!("#{}", fingerprint)).into(),
@ -144,9 +145,7 @@ pub fn unrelated_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>(
.push(key)
.push(Space::with_width(15))
.push(Space::with_width(Length::Fill))
.push(text::text(
"This signing device is not related to this Liana wallet.",
))
.push(text::text(label))
.push(Space::with_width(Length::Fill))
.align_y(Vertical::Center),
)
@ -157,6 +156,19 @@ pub fn unrelated_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>(
.width(Length::Fill)
}
pub fn unrelated_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>(
kind: K,
version: Option<V>,
fingerprint: F,
) -> Container<'a, T> {
disabled_hardware_wallet(
kind,
version,
fingerprint,
"This signing device is not related to this Liana wallet.",
)
}
pub fn processing_hardware_wallet<'a, T: 'a, K: Display, V: Display, F: Display>(
kind: K,
version: Option<V>,
@ -484,20 +496,31 @@ pub fn unselected_hot_signer<'a, T: 'a, F: Display>(
pub fn hot_signer<'a, T: 'a, F: Display>(
fingerprint: F,
alias: Option<impl Into<Cow<'a, str>> + Display>,
can_sign: bool,
) -> Container<'a, T> {
Container::new(
column(vec![
Row::new()
.spacing(5)
.push_maybe(alias.map(|a| text::p1_bold(a)))
.push(text::p1_regular(format!("#{}", fingerprint)))
.into(),
Row::new()
.spacing(5)
.push(text::caption("This computer"))
.into(),
])
.width(Length::Fill),
Row::new()
.push(column(vec![
Row::new()
.spacing(5)
.push_maybe(alias.map(|a| text::p1_bold(a)))
.push(text::p1_regular(format!("#{}", fingerprint)))
.into(),
Row::new()
.spacing(5)
.push(text::caption("This computer"))
.into(),
]))
.push(Space::with_width(Length::Fixed(20.0)))
.push_maybe(if !can_sign {
Some(text::text(
"This hot signer is not part of this spending path.",
))
} else {
None
})
.push(Space::with_width(Length::Fill))
.align_y(Vertical::Center),
)
.padding(10)
}

View File

@ -9,6 +9,7 @@ use miniscript::{
RelLockTime, ScriptContext, Threshold,
};
use miniscript::bitcoin::bip32::Fingerprint;
use std::{
collections::{BTreeMap, HashMap, HashSet},
convert::TryFrom,
@ -368,6 +369,11 @@ impl PathInfo {
),
})
}
/// Determine whether the fingerprint is part of this path.
pub fn contains_fingerprint(&self, fingerprint: Fingerprint) -> bool {
self.thresh_origins().1.contains_key(&fingerprint)
}
}
// See

View File

@ -217,6 +217,39 @@ impl LianaDescriptor {
.for_any_key(|k| k.master_fingerprint() == fg)
}
/// Determine whether the fingerprint is part of a specific path of this descriptor.
/// If recovery_timelock is None, checks in the primary path.
/// If recovery_timelock is Some(timelock), checks in the recovery path with specified timelock.
pub fn contains_fingerprint_in_path(
&self,
fingerprint: Fingerprint,
recovery_timelock: Option<u16>,
) -> bool {
match recovery_timelock {
None => self.contains_fingerprint_in_primary_path(fingerprint),
Some(timelock) => self.contains_fingerprint_in_recovery_path(fingerprint, timelock),
}
}
/// Determine whether the fingerprint is part of the primary path of this descriptor.
fn contains_fingerprint_in_primary_path(&self, fingerprint: Fingerprint) -> bool {
self.policy().primary_path.contains_fingerprint(fingerprint)
}
/// Determine whether the fingerprint is part of the recovery path of this descriptor for the
/// specified timelock.
fn contains_fingerprint_in_recovery_path(
&self,
fingerprint: Fingerprint,
recovery_timelock: u16,
) -> bool {
self.policy()
.recovery_paths
.get(&recovery_timelock)
.map(|path_info| path_info.contains_fingerprint(fingerprint))
.unwrap_or(false)
}
/// Get the descriptor for receiving addresses.
pub fn receive_descriptor(&self) -> &SinglePathLianaDesc {
&self.receive_desc
@ -751,9 +784,9 @@ impl DerivedSinglePathLianaDesc {
mod tests {
use super::*;
use bitcoin::{hashes::Hash, Sequence};
use crate::signer::HotSigner;
use bitcoin::{hashes::Hash, Sequence};
use miniscript::bitcoin::bip32::Fingerprint;
fn random_desc_key(
secp: &secp256k1::Secp256k1<impl secp256k1::Signing>,
@ -2210,13 +2243,120 @@ mod tests {
}
#[test]
fn descriptor_contains_fingerprint() {
fn descriptor_contains_fingerprint_in_primary_path_multi() {
let descr = LianaDescriptor::from_str("wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/0/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/0/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/1/<0;1>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*)),older(26352))))").unwrap();
assert!(descr.contains_fingerprint(Fingerprint::from_str("aabb0011").unwrap()));
assert!(descr.contains_fingerprint(Fingerprint::from_str("aabb0012").unwrap()));
assert!(descr.contains_fingerprint(Fingerprint::from_str("aabb0013").unwrap()));
assert!(!descr.contains_fingerprint(Fingerprint::from_str("aabb0014").unwrap()));
assert!(
descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("aabb0011").unwrap())
);
assert!(
descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("aabb0012").unwrap())
);
assert!(
descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("aabb0013").unwrap())
);
assert!(
!descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("aabb0014").unwrap())
);
}
#[test]
fn descriptor_contains_fingerprint_in_primary_path_single_key() {
let descr = LianaDescriptor::from_str("wsh(or_d(pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*),and_v(v:thresh(1,pkh([bbcc2233/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*)),older(26352))))").unwrap();
assert!(
descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("aabb0011").unwrap())
);
assert!(
!descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("bbcc2233").unwrap())
);
assert!(
!descr.contains_fingerprint_in_primary_path(Fingerprint::from_str("ddeeff00").unwrap())
);
}
#[test]
fn descriptor_contains_fingerprint_in_recovery_path_multi() {
let descr = LianaDescriptor::from_str("wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/0/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/0/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/1/<0;1>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*)),older(26352))))").unwrap();
assert!(descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("aabb0011").unwrap(),
26352
));
assert!(descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("aabb0012").unwrap(),
26352
));
assert!(descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("aabb0013").unwrap(),
26352
));
assert!(!descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("aabb0013").unwrap(),
1000
));
assert!(!descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("aabb0014").unwrap(),
26352
));
}
#[test]
fn descriptor_contains_fingerprint_in_recovery_path_single_key() {
let descr = LianaDescriptor::from_str("wsh(or_d(pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*),and_v(v:thresh(1,pkh([bbcc2233/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*)),older(26352))))").unwrap();
assert!(!descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("aabb0011").unwrap(),
26352
));
assert!(descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("bbcc2233").unwrap(),
26352
));
assert!(!descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("ddeeff00").unwrap(),
26352
));
}
#[test]
fn descriptor_contains_fingerprint_in_recovery_path_multiple_keys() {
let descr = LianaDescriptor::from_str("wsh(or_d(c:or_i(and_v(v:older(38305),and_v(v:pkh([d6bba22a/84'/1'/0']tpubDCwMfgJWBGfJuZFmgTAP9qdSwJeC4fEKaYXQx7CNiTzsB5WrpVSmySdPFnKDu8ChWZweYNh7MoAoFsCNY7gTRFSGtDYbG9s6vAKNKzT1Hii/<0;1>/*),pk_h([e9e6c583/48'/1'/0'/2']tpubDEWn2LRKdyREaweKHxj7XzSjcxXGTVbFkL5Qi5AWsJzGvN28cKQwGqCND9TP6EPtPaE13eK9SnyuiQ4qsfy5UuGD3p32Ew36mWfKmYCJRcz/<0;1>/*))),pk_k([de8abde2/48'/1'/0'/2']tpubDES5ZQEwEuj7Fpe6d6wkwD8SdequEa2cqq57QHQ43pb1x2HxbLp6anHwutDNzrMhDAbx1YgxCFAbRi6EhWwQLaGMSSmxJRaAzCUgn6VwpVD/<0;1>/*)),and_v(v:thresh(1,pkh([d6bba22a/84'/1'/0']tpubDCwMfgJWBGfJuZFmgTAP9qdSwJeC4fEKaYXQx7CNiTzsB5WrpVSmySdPFnKDu8ChWZweYNh7MoAoFsCNY7gTRFSGtDYbG9s6vAKNKzT1Hii/<2;3>/*),a:pkh([e9e6c583/48'/1'/0'/2']tpubDEWn2LRKdyREaweKHxj7XzSjcxXGTVbFkL5Qi5AWsJzGvN28cKQwGqCND9TP6EPtPaE13eK9SnyuiQ4qsfy5UuGD3p32Ew36mWfKmYCJRcz/<2;3>/*)),older(52596))))").unwrap();
assert!(descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("d6bba22a").unwrap(),
38305
));
assert!(descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("e9e6c583").unwrap(),
38305
));
assert!(descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("d6bba22a").unwrap(),
52596
));
assert!(descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("e9e6c583").unwrap(),
52596
));
assert!(!descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("d6bba22a").unwrap(),
12345
));
assert!(!descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("e9e6c583").unwrap(),
12345
));
assert!(!descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("ffffffff").unwrap(),
38305
));
assert!(!descr.contains_fingerprint_in_recovery_path(
Fingerprint::from_str("ffffffff").unwrap(),
52596
));
}
// TODO: test error conditions of deserialization.