Generalize return value of sign_psbt in the Rust client
What changed, and why it matters
This commit is a routine API update for the Ledger Bitcoin app's Rust client library. It changes what kind of data the library returns when a user signs a Bitcoin transaction (PSBT), so it can also return MuSig2 multi-signature material (public nonces and partial signatures) in addition to ordinary signatures. The commit message explicitly calls this a breaking change that requires a major release. There is no indication of a security bug being fixed or introduced.
No security action required. Treat as a normal semver-major API change. Consumers of the Rust client should update their code to match the new SignPsbtYieldedObject enum and include a wildcard arm because the enum is #[non_exhaustive].
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch generalizes the return type of sign_psbt in the Rust client from Vec<(usize, PartialSignature)> to Vec<(usize, SignPsbtYieldedObject)>. A new protocol.rs module is added that parses device-yielded payloads, recognizes two new reserved tags (0xFFFFFFFF for MuSig2 pubnonces and 0xFFFFFFFE for MuSig2 partial signatures), and falls back to Unknown for future reserved tags. Legacy untagged partial-signature payloads remain supported. The change is purely client-side parsing/typing; no device firmware or signing logic is modified.
Changed components
bitcoin_client_rs/src/async_client.rsbitcoin_client_rs/src/client.rsbitcoin_client_rs/src/lib.rsbitcoin_client_rs/src/protocol.rsbitcoin_client_rs/src/psbt.rsbitcoin_client_rs/examples/ledger_hwi/src/main.rsbitcoin_client_rs/tests/client.rsbitcoin_client_rs/Cargo.tomlInspect captured patch +469 / −70
diff --git a/bitcoin_client_rs/Cargo.toml b/bitcoin_client_rs/Cargo.toml
index 548036a..3e17539 100644
--- a/bitcoin_client_rs/Cargo.toml
+++ b/bitcoin_client_rs/Cargo.toml
@@ -30,6 +30,7 @@ members = ["examples/ledger_hwi"]
# Dependencies used for tests and examples only.
[dev-dependencies]
hex = "0.4.3"
+hex-literal = "1.1.0"
tokio = { version = "1.21", features = ["macros", "rt", "rt-multi-thread"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
diff --git a/bitcoin_client_rs/examples/ledger_hwi/src/main.rs b/bitcoin_client_rs/examples/ledger_hwi/src/main.rs
index 0b53ed5..2472c5e 100644
--- a/bitcoin_client_rs/examples/ledger_hwi/src/main.rs
+++ b/bitcoin_client_rs/examples/ledger_hwi/src/main.rs
@@ -12,6 +12,7 @@ use ledger_bitcoin_client::{
async_client::{BitcoinClient, Transport},
psbt::PartialSignature,
wallet::{Version, WalletPolicy, WalletPubKey},
+ SignPsbtYieldedObject,
};
mod transport;
@@ -175,12 +176,16 @@ async fn sign<T: Transport>(
.await
.map_err(|e| format!("{:#?}", e))?;
- for (index, sig) in res {
- match sig {
- PartialSignature::Sig(key, sig) => {
+ for (index, obj) in res {
+ match obj {
+ SignPsbtYieldedObject::Partial(PartialSignature::Sig(key, sig)) => {
println!("index: {}, key: {}, sig: {}", index, key, sig);
}
- PartialSignature::TapScriptSig(key, tapleaf_hash, sig) => {
+ SignPsbtYieldedObject::Partial(PartialSignature::TapScriptSig(
+ key,
+ tapleaf_hash,
+ sig,
+ )) => {
println!(
"index: {}, key: {}, tapleaf_hash: {}, sig: {}",
index,
@@ -191,6 +196,31 @@ async fn sign<T: Transport>(
hex::encode(sig.to_vec())
);
}
+ SignPsbtYieldedObject::MusigPubNonce(pn) => {
+ println!(
+ "index: {}, musig pubnonce: participant: {}, aggregate: {}, tapleaf_hash: {}, pubnonce: {}",
+ index,
+ pn.participant_pubkey,
+ pn.aggregate_pubkey,
+ pn.tapleaf_hash
+ .map(|h| hex::encode(h))
+ .unwrap_or("none".to_string()),
+ hex::encode(pn.pubnonce)
+ );
+ }
+ SignPsbtYieldedObject::MusigPartialSignature(ps) => {
+ println!(
+ "index: {}, musig partial sig: participant: {}, aggregate: {}, tapleaf_hash: {}, partial_sig: {}",
+ index,
+ ps.participant_pubkey,
+ ps.aggregate_pubkey,
+ ps.tapleaf_hash
+ .map(|h| hex::encode(h))
+ .unwrap_or("none".to_string()),
+ hex::encode(ps.partial_signature)
+ );
+ }
+ _ => {}
}
}
Ok(())
diff --git a/bitcoin_client_rs/src/async_client.rs b/bitcoin_client_rs/src/async_client.rs
index a7b4663..9012a59 100644
--- a/bitcoin_client_rs/src/async_client.rs
+++ b/bitcoin_client_rs/src/async_client.rs
@@ -6,7 +6,7 @@ use async_trait::async_trait;
use bitcoin::{
address,
bip32::{DerivationPath, Fingerprint, Xpub},
- consensus::encode::{deserialize_partial, VarInt},
+ consensus::encode::deserialize_partial,
secp256k1::ecdsa::Signature,
Psbt,
};
@@ -19,6 +19,7 @@ use crate::{
command,
error::BitcoinClientError,
interpreter::{get_merkleized_map_commitment, ClientCommandInterpreter},
+ protocol::*,
psbt::*,
wallet::WalletPolicy,
};
@@ -262,13 +263,17 @@ impl<T: Transport> BitcoinClient<T> {
/// Signs a PSBT using a registered wallet (or a standard wallet that does not need registration).
/// Signature requires explicit approval from the user.
+ ///
+ /// Each yielded payload is decoded into a [`SignPsbtYieldedObject`], which
+ /// covers regular partial signatures as well as MuSig2 round 1 / round 2
+ /// payloads.
#[allow(clippy::type_complexity)]
pub async fn sign_psbt(
&self,
psbt: &Psbt,
wallet: &WalletPolicy,
wallet_hmac: Option<&[u8; 32]>,
- ) -> Result<Vec<(usize, PartialSignature)>, BitcoinClientError<T::Error>> {
+ ) -> Result<Vec<(usize, SignPsbtYieldedObject)>, BitcoinClientError<T::Error>> {
let mut intpr = ClientCommandInterpreter::new();
intpr.add_known_preimage(wallet.serialize());
let keys: Vec<String> = wallet.keys.iter().map(|k| k.to_string()).collect();
@@ -338,26 +343,18 @@ impl<T: Transport> BitcoinClient<T> {
});
}
- let mut signatures = Vec::new();
+ let mut yielded_objects = Vec::new();
for result in results {
- let (input_index, i): (VarInt, usize) =
- deserialize_partial(&result).map_err(|_| BitcoinClientError::UnexpectedResult {
+ let (input_index, obj) = parse_sign_psbt_yielded(&result).map_err(|_| {
+ BitcoinClientError::UnexpectedResult {
command: cmd.ins,
data: result.clone(),
- })?;
-
- signatures.push((
- input_index.0 as usize,
- PartialSignature::from_slice(&result[i..]).map_err(|_| {
- BitcoinClientError::UnexpectedResult {
- command: cmd.ins,
- data: result.clone(),
- }
- })?,
- ));
+ }
+ })?;
+ yielded_objects.push((input_index, obj));
}
- Ok(signatures)
+ Ok(yielded_objects)
}
/// Sign a message with the key derived with the given derivation path.
diff --git a/bitcoin_client_rs/src/client.rs b/bitcoin_client_rs/src/client.rs
index 3212e5d..efc7379 100644
--- a/bitcoin_client_rs/src/client.rs
+++ b/bitcoin_client_rs/src/client.rs
@@ -4,7 +4,7 @@ use core::str::FromStr;
use bitcoin::{
address,
bip32::{DerivationPath, Fingerprint, Xpub},
- consensus::encode::{deserialize_partial, VarInt},
+ consensus::encode::deserialize_partial,
psbt::Psbt,
secp256k1::ecdsa,
};
@@ -17,6 +17,7 @@ use crate::{
command,
error::BitcoinClientError,
interpreter::{get_merkleized_map_commitment, ClientCommandInterpreter},
+ protocol::*,
psbt::*,
wallet::WalletPolicy,
};
@@ -243,13 +244,17 @@ impl<T: Transport> BitcoinClient<T> {
/// Signs a PSBT using a registered wallet (or a standard wallet that does not need registration).
/// Signature requires explicit approval from the user.
+ ///
+ /// Each yielded payload is decoded into a [`SignPsbtYieldedObject`], which
+ /// covers regular partial signatures as well as MuSig2 round 1 / round 2
+ /// payloads.
#[allow(clippy::type_complexity)]
pub fn sign_psbt(
&self,
psbt: &Psbt,
wallet: &WalletPolicy,
wallet_hmac: Option<&[u8; 32]>,
- ) -> Result<Vec<(usize, PartialSignature)>, BitcoinClientError<T::Error>> {
+ ) -> Result<Vec<(usize, SignPsbtYieldedObject)>, BitcoinClientError<T::Error>> {
let mut intpr = ClientCommandInterpreter::new();
intpr.add_known_preimage(wallet.serialize());
let keys: Vec<String> = wallet.keys.iter().map(|k| k.to_string()).collect();
@@ -319,26 +324,18 @@ impl<T: Transport> BitcoinClient<T> {
});
}
- let mut signatures = Vec::new();
+ let mut yielded_objects = Vec::new();
for result in results {
- let (input_index, i): (VarInt, usize) =
- deserialize_partial(&result).map_err(|_| BitcoinClientError::UnexpectedResult {
+ let (input_index, obj) = parse_sign_psbt_yielded(&result).map_err(|_| {
+ BitcoinClientError::UnexpectedResult {
command: cmd.ins,
data: result.clone(),
- })?;
-
- signatures.push((
- input_index.0 as usize,
- PartialSignature::from_slice(&result[i..]).map_err(|_| {
- BitcoinClientError::UnexpectedResult {
- command: cmd.ins,
- data: result.clone(),
- }
- })?,
- ));
+ }
+ })?;
+ yielded_objects.push((input_index, obj));
}
- Ok(signatures)
+ Ok(yielded_objects)
}
/// Sign a message with the key derived with the given derivation path.
diff --git a/bitcoin_client_rs/src/lib.rs b/bitcoin_client_rs/src/lib.rs
index 7701c89..501591a 100644
--- a/bitcoin_client_rs/src/lib.rs
+++ b/bitcoin_client_rs/src/lib.rs
@@ -8,6 +8,7 @@ mod bip327;
pub mod apdu;
pub mod client;
pub mod error;
+mod protocol;
pub mod psbt;
pub mod wallet;
@@ -15,4 +16,6 @@ pub mod wallet;
pub mod async_client;
pub use client::{BitcoinClient, Transport};
+pub use protocol::{MusigPartialSignature, MusigPubNonce, SignPsbtYieldedObject};
+pub use psbt::{PartialSignature, PartialSignatureError};
pub use wallet::{WalletPolicy, WalletPubKey};
diff --git a/bitcoin_client_rs/src/protocol.rs b/bitcoin_client_rs/src/protocol.rs
new file mode 100644
index 0000000..ad66313
--- /dev/null
+++ b/bitcoin_client_rs/src/protocol.rs
@@ -0,0 +1,361 @@
+//! This module contains types that are specific to the Ledger Bitcoin application protocol.
+
+use bitcoin::{
+ consensus::encode::{deserialize_partial, VarInt},
+ hashes::Hash,
+ taproot::TapLeafHash,
+ PublicKey,
+};
+
+use crate::psbt::{PartialSignature, PartialSignatureError};
+
+/// Tag yielded by the device to introduce a MuSig2 pubnonce payload.
+pub const CCMD_YIELD_MUSIG_PUBNONCE_TAG: u64 = 0xFFFFFFFF;
+/// Tag yielded by the device to introduce a MuSig2 partial-signature payload.
+pub const CCMD_YIELD_MUSIG_PARTIALSIGNATURE_TAG: u64 = 0xFFFFFFFE;
+
+/// A MuSig2 public nonce yielded by the device during the first round of a
+/// MuSig2 signing session, as specified in BIP-373.
+#[derive(Debug, Clone)]
+pub struct MusigPubNonce {
+ /// The 33-byte compressed pubkey of this participant.
+ pub participant_pubkey: PublicKey,
+ /// The 33-byte compressed aggregate pubkey.
+ pub aggregate_pubkey: PublicKey,
+ /// The tapleaf hash, if signing for a tapscript; `None` otherwise.
+ pub tapleaf_hash: Option<TapLeafHash>,
+ /// The 66-byte pubnonce.
+ pub pubnonce: [u8; 66],
+}
+
+/// A MuSig2 partial signature yielded by the device during the second round of
+/// a MuSig2 signing session, as specified in BIP-373
+///
+/// Note: not to be confused with [`PartialSignature`], which represents a
+/// regular ECDSA or Schnorr signature for a single input.
+#[derive(Debug, Clone)]
+pub struct MusigPartialSignature {
+ /// The 33-byte compressed pubkey of this participant.
+ pub participant_pubkey: PublicKey,
+ /// The 33-byte compressed aggregate pubkey.
+ pub aggregate_pubkey: PublicKey,
+ /// The tapleaf hash, if signing for a tapscript; `None` otherwise.
+ pub tapleaf_hash: Option<TapLeafHash>,
+ /// The 32-byte partial signature for this participant.
+ pub partial_signature: [u8; 32],
+}
+
+/// An object yielded by the device while signing a PSBT.
+///
+/// The variants cover the different kinds of payload the device can produce.
+/// The enum is `#[non_exhaustive]`: callers must include a wildcard arm so that
+/// new payload kinds introduced in future protocol versions do not cause
+/// compilation failures in existing code.
+#[non_exhaustive]
+#[derive(Debug, Clone)]
+pub enum SignPsbtYieldedObject {
+ /// A regular partial signature for a PSBT input.
+ Partial(PartialSignature),
+ /// A MuSig2 public nonce (round 1).
+ MusigPubNonce(MusigPubNonce),
+ /// A MuSig2 partial signature (round 2).
+ MusigPartialSignature(MusigPartialSignature),
+ /// An unknown payload with an unrecognized tag; the original bytes are included for reference.
+ Unknown(Vec<u8>),
+}
+
+/// Parses a single payload yielded by the device during `sign_psbt`.
+///
+/// On success returns the input index together with the decoded object.
+pub fn parse_sign_psbt_yielded(
+ data: &[u8],
+) -> Result<(usize, SignPsbtYieldedObject), PartialSignatureError> {
+ let (tag, i): (VarInt, usize) =
+ deserialize_partial(data).map_err(|_| PartialSignatureError::InvalidLength)?;
+
+ match tag.0 {
+ CCMD_YIELD_MUSIG_PUBNONCE_TAG => {
+ let (input_index, j): (VarInt, usize) = deserialize_partial(&data[i..])
+ .map_err(|_| PartialSignatureError::InvalidLength)?;
+ let rest = &data[i + j..];
+ // Layout: 66-byte pubnonce || 33-byte participant pubkey ||
+ // 33-byte aggregate pubkey || optional 32-byte tapleaf hash.
+ if rest.len() != 132 && rest.len() != 164 {
+ return Err(PartialSignatureError::InvalidLength);
+ }
+ let mut pubnonce = [0u8; 66];
+ pubnonce.copy_from_slice(&rest[0..66]);
+ let participant_pubkey =
+ PublicKey::from_slice(&rest[66..99]).map_err(PartialSignatureError::PubKey)?;
+ let aggregate_pubkey =
+ PublicKey::from_slice(&rest[99..132]).map_err(PartialSignatureError::PubKey)?;
+ let tapleaf_hash = if rest.len() == 164 {
+ Some(
+ TapLeafHash::from_slice(&rest[132..164])
+ .map_err(PartialSignatureError::TapLeaf)?,
+ )
+ } else {
+ None
+ };
+ Ok((
+ input_index.0 as usize,
+ SignPsbtYieldedObject::MusigPubNonce(MusigPubNonce {
+ participant_pubkey,
+ aggregate_pubkey,
+ tapleaf_hash,
+ pubnonce,
+ }),
+ ))
+ }
+ CCMD_YIELD_MUSIG_PARTIALSIGNATURE_TAG => {
+ let (input_index, j): (VarInt, usize) = deserialize_partial(&data[i..])
+ .map_err(|_| PartialSignatureError::InvalidLength)?;
+ let rest = &data[i + j..];
+ // Layout: 32-byte partial signature || 33-byte participant pubkey ||
+ // 33-byte aggregate pubkey || optional 32-byte tapleaf hash.
+ if rest.len() != 98 && rest.len() != 130 {
+ return Err(PartialSignatureError::InvalidLength);
+ }
+ let mut partial_signature = [0u8; 32];
+ partial_signature.copy_from_slice(&rest[0..32]);
+ let participant_pubkey =
+ PublicKey::from_slice(&rest[32..65]).map_err(PartialSignatureError::PubKey)?;
+ let aggregate_pubkey =
+ PublicKey::from_slice(&rest[65..98]).map_err(PartialSignatureError::PubKey)?;
+ let tapleaf_hash = if rest.len() == 130 {
+ Some(
+ TapLeafHash::from_slice(&rest[98..130])
+ .map_err(PartialSignatureError::TapLeaf)?,
+ )
+ } else {
+ None
+ };
+ Ok((
+ input_index.0 as usize,
+ SignPsbtYieldedObject::MusigPartialSignature(MusigPartialSignature {
+ participant_pubkey,
+ aggregate_pubkey,
+ tapleaf_hash,
+ partial_signature,
+ }),
+ ))
+ }
+ // Reserved tag range used by the protocol for non-input-index payloads.
+ // Tags in this range that are not explicitly handled above are unknown to this client version, and reserved
+ // for future use.
+ tag_value if tag_value >= 0x80000000 => {
+ // Future tags are expected to follow the same layout, using the first varint to refer to the input index
+ let (input_index, j): (VarInt, usize) = deserialize_partial(&data[i..])
+ .map_err(|_| PartialSignatureError::InvalidLength)?;
+ let rest = &data[i + j..];
+ Ok((
+ input_index.0 as usize,
+ SignPsbtYieldedObject::Unknown(rest.to_vec()),
+ ))
+ }
+ // Otherwise the leading varint is the input index and the remainder is a regular partial signature.
+ // These are the only payloads that were used in protocol versions prior to the introduction of the tags.
+ _ => {
+ let input_index = tag.0 as usize;
+ let ps = PartialSignature::from_slice(&data[i..])?;
+ Ok((input_index, SignPsbtYieldedObject::Partial(ps)))
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bitcoin::consensus::encode::serialize;
+ use hex_literal::hex;
+
+ // Arbitrary valid compressed secp256k1 pubkey (no special meaning).
+ const PUBKEY: [u8; 33] =
+ hex!("034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa");
+ // x-only version of the same pubkey (drops the 0x03 prefix).
+ const XONLY: [u8; 32] =
+ hex!("4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa");
+ // 32-byte tapleaf hash placeholder (any 32 bytes are accepted by from_slice).
+ const TAPLEAF: [u8; 32] =
+ hex!("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
+
+ fn parse_ok(payload: &[u8]) -> (usize, SignPsbtYieldedObject) {
+ match parse_sign_psbt_yielded(payload) {
+ Ok(v) => v,
+ Err(_) => panic!("parse_sign_psbt_yielded returned an unexpected error"),
+ }
+ }
+
+ #[test]
+ fn parse_legacy_partial_taproot_no_tapleaf() {
+ // Layout (untagged, used by protocol versions <= 2.1):
+ // varint(input_index) || key_augment_len(=32) || 32-byte x-only pk || 64-byte schnorr sig
+ let mut payload = serialize(&VarInt(3));
+ payload.push(32);
+ payload.extend(XONLY);
+ payload.extend([0xAAu8; 64]);
+
+ let (idx, obj) = parse_ok(&payload);
+ assert_eq!(idx, 3);
+ match obj {
+ SignPsbtYieldedObject::Partial(PartialSignature::TapScriptSig(_, tlh, _)) => {
+ assert!(tlh.is_none());
+ }
+ other => panic!("expected TapScriptSig without tapleaf, got {:?}", other),
+ }
+ }
+
+ fn build_musig_pubnonce(input_index: u64, with_tapleaf: bool) -> Vec<u8> {
+ let mut payload = serialize(&VarInt(CCMD_YIELD_MUSIG_PUBNONCE_TAG));
+ payload.extend(serialize(&VarInt(input_index)));
+ payload.extend([0xAAu8; 66]); // pubnonce
+ payload.extend(PUBKEY); // participant pk (33)
+ payload.extend(PUBKEY); // aggregate pk (33)
+ if with_tapleaf {
+ payload.extend(TAPLEAF);
+ }
+ payload
+ }
+
+ #[test]
+ fn parse_musig_pubnonce_without_tapleaf() {
+ let payload = build_musig_pubnonce(7, false);
+ let (idx, obj) = parse_ok(&payload);
+ assert_eq!(idx, 7);
+ match obj {
+ SignPsbtYieldedObject::MusigPubNonce(n) => {
+ assert!(n.tapleaf_hash.is_none());
+ assert_eq!(n.pubnonce, [0xAAu8; 66]);
+ assert_eq!(n.participant_pubkey.to_bytes(), PUBKEY);
+ assert_eq!(n.aggregate_pubkey.to_bytes(), PUBKEY);
+ }
+ other => panic!("expected MusigPubNonce, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn parse_musig_pubnonce_with_tapleaf() {
+ let payload = build_musig_pubnonce(0, true);
+ let (idx, obj) = parse_ok(&payload);
+ assert_eq!(idx, 0);
+ match obj {
+ SignPsbtYieldedObject::MusigPubNonce(n) => {
+ let tlh = n.tapleaf_hash.expect("tapleaf hash should be present");
+ assert_eq!(tlh.to_byte_array(), TAPLEAF);
+ }
+ other => panic!("expected MusigPubNonce, got {:?}", other),
+ }
+ }
+
+ fn build_musig_partial_sig(input_index: u64, with_tapleaf: bool) -> Vec<u8> {
+ let mut payload = serialize(&VarInt(CCMD_YIELD_MUSIG_PARTIALSIGNATURE_TAG));
+ payload.extend(serialize(&VarInt(input_index)));
+ payload.extend([0xBBu8; 32]); // partial signature
+ payload.extend(PUBKEY); // participant pk (33)
+ payload.extend(PUBKEY); // aggregate pk (33)
+ if with_tapleaf {
+ payload.extend(TAPLEAF);
+ }
+ payload
+ }
+
+ #[test]
+ fn parse_musig_partial_signature_without_tapleaf() {
+ let payload = build_musig_partial_sig(2, false);
+ let (idx, obj) = parse_ok(&payload);
+ assert_eq!(idx, 2);
+ match obj {
+ SignPsbtYieldedObject::MusigPartialSignature(s) => {
+ assert!(s.tapleaf_hash.is_none());
+ assert_eq!(s.partial_signature, [0xBBu8; 32]);
+ assert_eq!(s.participant_pubkey.to_bytes(), PUBKEY);
+ assert_eq!(s.aggregate_pubkey.to_bytes(), PUBKEY);
+ }
+ other => panic!("expected MusigPartialSignature, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn parse_musig_partial_signature_with_tapleaf() {
+ let payload = build_musig_partial_sig(42, true);
+ let (idx, obj) = parse_ok(&payload);
+ assert_eq!(idx, 42);
+ match obj {
+ SignPsbtYieldedObject::MusigPartialSignature(s) => {
+ let tlh = s.tapleaf_hash.expect("tapleaf hash should be present");
+ assert_eq!(tlh.to_byte_array(), TAPLEAF);
+ }
+ other => panic!("expected MusigPartialSignature, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn parse_unknown_reserved_tag() {
+ // Any tag in the reserved range (>= 0x80000000) that this client version does
+ // not explicitly handle must surface as `Unknown` while preserving input_index
+ // and the trailing payload verbatim.
+ const UNKNOWN_TAG: u64 = 0x89AB_CDEF;
+ let trailer = hex!("deadbeef");
+
+ let mut payload = serialize(&VarInt(UNKNOWN_TAG));
+ payload.extend(serialize(&VarInt(11)));
+ payload.extend(&trailer);
+
+ let (idx, obj) = parse_ok(&payload);
+ assert_eq!(idx, 11);
+ match obj {
+ SignPsbtYieldedObject::Unknown(bytes) => assert_eq!(bytes, trailer),
+ other => panic!("expected Unknown, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn parse_musig_pubnonce_wrong_trailer_length_is_error() {
+ // Drop one byte from the aggregate pubkey to make the trailer length invalid
+ // (133 bytes instead of 132 or 164).
+ let mut payload = build_musig_pubnonce(0, false);
+ payload.push(0x00);
+ assert!(matches!(
+ parse_sign_psbt_yielded(&payload),
+ Err(PartialSignatureError::InvalidLength)
+ ));
+ }
+
+ #[test]
+ fn parse_musig_partial_signature_wrong_trailer_length_is_error() {
+ let mut payload = build_musig_partial_sig(0, false);
+ payload.push(0x00);
+ assert!(matches!(
+ parse_sign_psbt_yielded(&payload),
+ Err(PartialSignatureError::InvalidLength)
+ ));
+ }
+
+ #[test]
+ fn parse_musig_pubnonce_truncated_input_is_error() {
+ // Truncate so the participant pubkey is missing entirely.
+ let full = build_musig_pubnonce(0, false);
+ // tag(5) + input_index(1) + pubnonce(66) = 72 bytes; cut everything after that.
+ let truncated = &full[..72];
+ assert!(matches!(
+ parse_sign_psbt_yielded(truncated),
+ Err(PartialSignatureError::InvalidLength)
+ ));
+ }
+
+ #[test]
+ fn parse_musig_pubnonce_invalid_participant_pubkey_is_error() {
+ // Replace the participant pubkey (33 bytes after the 66-byte nonce) with all
+ // zeros, which is not a valid encoded compressed pubkey.
+ let mut payload = build_musig_pubnonce(0, false);
+ // tag(5 bytes for 0xFFFFFFFF) + input_index(1) + pubnonce(66) = 72.
+ let pubkey_offset = 72;
+ for b in &mut payload[pubkey_offset..pubkey_offset + 33] {
+ *b = 0x00;
+ }
+ assert!(matches!(
+ parse_sign_psbt_yielded(&payload),
+ Err(PartialSignatureError::PubKey(_))
+ ));
+ }
+}
diff --git a/bitcoin_client_rs/src/psbt.rs b/bitcoin_client_rs/src/psbt.rs
index 34dbe68..b520326 100644
--- a/bitcoin_client_rs/src/psbt.rs
+++ b/bitcoin_client_rs/src/psbt.rs
@@ -390,6 +390,7 @@ pub fn deserialize_pair(pair: raw::Pair) -> (Vec<u8>, Vec<u8>) {
)
}
+#[derive(Debug, Clone)]
pub enum PartialSignature {
/// signature stored in pbst.partial_sigs
Sig(PublicKey, ecdsa::Signature),
@@ -432,6 +433,7 @@ impl PartialSignature {
pub enum PartialSignatureError {
BadKeyAugmentLength,
+ InvalidLength,
XOnlyPubKey(secp256k1::Error),
PubKey(KeyError),
EcdsaSig(ecdsa::Error),
diff --git a/bitcoin_client_rs/tests/client.rs b/bitcoin_client_rs/tests/client.rs
index 53b7201..d4bfde8 100644
--- a/bitcoin_client_rs/tests/client.rs
+++ b/bitcoin_client_rs/tests/client.rs
@@ -6,7 +6,9 @@ use bitcoin::{
hashes::{hex::FromHex, Hash},
psbt::Psbt,
};
-use ledger_bitcoin_client::{async_client, client, psbt::PartialSignature, wallet};
+use ledger_bitcoin_client::{
+ async_client, client, psbt::PartialSignature, wallet, SignPsbtYieldedObject,
+};
fn test_cases(path: &str) -> Vec<serde_json::Value> {
let data = std::fs::read_to_string(path).expect("Unable to read file");
@@ -319,44 +321,50 @@ async fn test_sign_psbt() {
.sign_psbt(&psbt, &wallet, hmac.as_ref())
.unwrap();
- let check_signatures = |sigs: &[serde_json::Value], res: Vec<(usize, PartialSignature)>| {
- for (i, psbt_sig) in res {
- for (j, res_sig) in sigs.iter().enumerate() {
- if i == j {
- match psbt_sig {
- PartialSignature::TapScriptSig(key, tapleaf_hash, sig) => {
- assert_eq!(
- res_sig
- .get("key")
- .map(|v| serde_json::from_value::<String>(v.clone())
- .unwrap())
- .unwrap(),
- key.to_string()
- );
- if let Some(tapleaf_hash_res) = res_sig
- .get("tapleaf_hash")
- .map(|v| serde_json::from_value::<String>(v.clone()).unwrap())
- {
+ let check_signatures =
+ |sigs: &[serde_json::Value], res: Vec<(usize, SignPsbtYieldedObject)>| {
+ for (i, psbt_sig) in res {
+ for (j, res_sig) in sigs.iter().enumerate() {
+ if i == j {
+ match psbt_sig {
+ SignPsbtYieldedObject::Partial(PartialSignature::TapScriptSig(
+ key,
+ tapleaf_hash,
+ sig,
+ )) => {
+ assert_eq!(
+ res_sig
+ .get("key")
+ .map(|v| serde_json::from_value::<String>(v.clone())
+ .unwrap())
+ .unwrap(),
+ key.to_string()
+ );
+ if let Some(tapleaf_hash_res) =
+ res_sig.get("tapleaf_hash").map(|v| {
+ serde_json::from_value::<String>(v.clone()).unwrap()
+ })
+ {
+ assert_eq!(
+ tapleaf_hash_res,
+ hex::encode(tapleaf_hash.unwrap().to_byte_array())
+ );
+ }
assert_eq!(
- tapleaf_hash_res,
- hex::encode(tapleaf_hash.unwrap().to_byte_array())
+ res_sig
+ .get("sig")
+ .map(|v| serde_json::from_value::<String>(v.clone())
+ .unwrap())
+ .unwrap(),
+ hex::encode(sig.to_vec())
);
}
- assert_eq!(
- res_sig
- .get("sig")
- .map(|v| serde_json::from_value::<String>(v.clone())
- .unwrap())
- .unwrap(),
- hex::encode(sig.to_vec())
- );
+ _ => {}
}
- _ => {}
}
}
}
- }
- };
+ };
check_signatures(&sigs, res);
Why this scored 18/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.