Replace VarInt with custom UncheckedVarInt
What changed, and why it matters
This commit swaps the library's standard variable-length integer type for a custom one in Ledger's Bitcoin app client code. The change is needed because a newer version of the rust-bitcoin library started rejecting large varints that the Ledger protocol legitimately uses for non-Bitcoin data. The patch itself is a compatibility fix, not an obvious vulnerability, but it removes a safety boundary and any bugs in the new custom encoder/decoder could affect how the client talks to Ledger devices.
Review the UncheckedVarInt encoder/decoder for off-by-one errors and non-minimal rejection logic; add unit tests for boundary values (0xFC/0xFD, 0xFFFF/0x10000, 0xFFFFFFFF/0x100000000) and malformed inputs; verify downstream Ledger firmware still accepts the produced wire format; consider pinning or documenting the rust-bitcoin dependency rationale.
Security signals we found
Custom reimplementation of a consensus-sensitive encoding primitive
Removal of an upstream library-enforced upper bound on varint values
New Decodable implementation must correctly reject non-minimal encodings to avoid parsing ambiguity
Change spans protocol parsing, command serialization, and PSBT handling
Evidence from the diff
The commit replaces rust-bitcoin’s consensus::encode::VarInt with a new crate-local UncheckedVarInt. rust-bitcoin 0.32.9 enforces MAX_COMPACT_SIZE (0x02000000) for VarInt, while the Ledger Bitcoin app protocol uses CompactSize-style encoding for arbitrary u64 tags such as 0xFFFFFFFF. The new type implements Encodable/Decodable with the same wire format but no upper bound, and still rejects non-minimal encodings. It is used across command construction, interpreter responses, PSBT v2 serialization, wallet policy serialization, and MuSig2 payload parsing.
Changed components
bitcoin_client_rs/src/protocol.rsbitcoin_client_rs/src/command.rsbitcoin_client_rs/src/interpreter.rsbitcoin_client_rs/src/psbt.rsbitcoin_client_rs/src/wallet.rsInspect captured patch +135 / −43
diff --git a/bitcoin_client_rs/src/command.rs b/bitcoin_client_rs/src/command.rs
index 6d54c79..2034c5b 100644
--- a/bitcoin_client_rs/src/command.rs
+++ b/bitcoin_client_rs/src/command.rs
@@ -2,8 +2,10 @@
///
use bitcoin::{
bip32::{ChildNumber, DerivationPath},
- consensus::encode::{self, VarInt},
+ consensus::encode::{self},
};
+
+use crate::protocol::UncheckedVarInt;
use core::default::Default;
use super::{
@@ -54,7 +56,7 @@ pub fn get_extended_pubkey(path: &DerivationPath, display: bool) -> APDUCommand
/// Creates the APDU command required to register the given wallet policy.
pub fn register_wallet(policy: &WalletPolicy) -> APDUCommand {
let bytes = policy.serialize();
- let mut data = encode::serialize(&VarInt(bytes.len() as u64));
+ let mut data = encode::serialize(&UncheckedVarInt(bytes.len() as u64));
data.extend(bytes);
APDUCommand {
cla: apdu::Cla::Bitcoin as u8,
@@ -98,9 +100,9 @@ pub fn sign_psbt(
) -> APDUCommand {
let mut data: Vec<u8> = Vec::new();
data.extend_from_slice(global_mapping_commitment);
- data.extend(encode::serialize(&VarInt(inputs_number as u64)));
+ data.extend(encode::serialize(&UncheckedVarInt(inputs_number as u64)));
data.extend_from_slice(input_commitments_root);
- data.extend(encode::serialize(&VarInt(outputs_number as u64)));
+ data.extend(encode::serialize(&UncheckedVarInt(outputs_number as u64)));
data.extend_from_slice(output_commitments_root);
data.extend_from_slice(&policy.id());
data.extend_from_slice(hmac.unwrap_or(&[b'\0'; 32]));
@@ -126,7 +128,7 @@ pub fn sign_message(
acc.extend_from_slice(&u32::from(x).to_be_bytes());
acc
});
- data.extend(encode::serialize(&VarInt(message_length as u64)));
+ data.extend(encode::serialize(&UncheckedVarInt(message_length as u64)));
data.extend_from_slice(message_commitment_root);
APDUCommand {
diff --git a/bitcoin_client_rs/src/interpreter.rs b/bitcoin_client_rs/src/interpreter.rs
index 3bf0145..a7b42db 100644
--- a/bitcoin_client_rs/src/interpreter.rs
+++ b/bitcoin_client_rs/src/interpreter.rs
@@ -2,10 +2,12 @@ use core::convert::TryFrom;
use core::fmt::Debug;
use bitcoin::{
- consensus::encode::{self, VarInt},
+ consensus::encode::{self},
hashes::{sha256, Hash, HashEngine},
};
+use crate::protocol::UncheckedVarInt;
+
use crate::{apdu::ClientCommandCode, merkle::MerkleTree};
/// Interpreter for the client-side commands.
@@ -137,7 +139,7 @@ fn get_preimage_command(
.find(|(hash, _)| hash == &request[1..])
.ok_or(InterpreterError::UnknownHash)?;
- let preimage_len_out = encode::serialize(&VarInt(preimage.len() as u64));
+ let preimage_len_out = encode::serialize(&UncheckedVarInt(preimage.len() as u64));
// We can send at most 255 - len(preimage_len_out) - 1 bytes in a single message;
//the rest will be stored for GET_MORE_ELEMENTS
@@ -175,13 +177,13 @@ fn get_merkle_leaf_proof(
};
let root = &request[0..32];
- let (tree_size, read): (VarInt, usize) =
- encode::deserialize_partial(&request[32..]).map_err(|_| {
- InterpreterError::UnsupportedRequest(ClientCommandCode::GetMerkleLeafProof as u8)
- })?;
+ let (tree_size, read): (UncheckedVarInt, usize) = encode::deserialize_partial(&request[32..])
+ .map_err(|_| {
+ InterpreterError::UnsupportedRequest(ClientCommandCode::GetMerkleLeafProof as u8)
+ })?;
// deserialize consumes the entire vector.
- let leaf_index: VarInt = encode::deserialize(&request[32 + read..]).map_err(|_| {
+ let leaf_index: UncheckedVarInt = encode::deserialize(&request[32 + read..]).map_err(|_| {
InterpreterError::UnsupportedRequest(ClientCommandCode::GetMerkleLeafProof as u8)
})?;
@@ -242,7 +244,7 @@ fn get_merkle_leaf_index(
.ok_or(InterpreterError::UnknownHash)?;
let mut response = 1_u8.to_be_bytes().to_vec();
- response.extend(encode::serialize(&VarInt(leaf_index as u64)));
+ response.extend(encode::serialize(&UncheckedVarInt(leaf_index as u64)));
Ok(response)
}
@@ -297,7 +299,7 @@ pub fn get_merkleized_map_commitment(mapping: &[(Vec<u8>, Vec<u8>)]) -> Vec<u8>
values_hashes.push(sha256::Hash::from_engine(engine).to_byte_array());
}
- let mut commitment = encode::serialize(&VarInt(sorted.len() as u64));
+ let mut commitment = encode::serialize(&UncheckedVarInt(sorted.len() as u64));
commitment.extend(MerkleTree::new(keys_hashes).root_hash());
commitment.extend(MerkleTree::new(values_hashes).root_hash());
commitment
diff --git a/bitcoin_client_rs/src/protocol.rs b/bitcoin_client_rs/src/protocol.rs
index ad66313..bb0764f 100644
--- a/bitcoin_client_rs/src/protocol.rs
+++ b/bitcoin_client_rs/src/protocol.rs
@@ -1,14 +1,96 @@
//! This module contains types that are specific to the Ledger Bitcoin application protocol.
use bitcoin::{
- consensus::encode::{deserialize_partial, VarInt},
+ consensus::encode::{deserialize_partial, Decodable, Encodable, Error as EncodeError},
hashes::Hash,
+ io::{Read, Write},
taproot::TapLeafHash,
PublicKey,
};
use crate::psbt::{PartialSignature, PartialSignatureError};
+/// A variable-length unsigned integer using the same wire encoding as
+/// Bitcoin's `CompactSize`, but without the upper-bound limit enforced by the
+/// `bitcoin` crate's [`VarInt`](bitcoin::consensus::encode::VarInt).
+///
+/// The Ledger protocol reuses the CompactSize encoding for tag values (e.g.
+/// `0xFFFFFFFF`) that exceed `MAX_COMPACT_SIZE`. This type can be used for
+/// both serialization and deserialization of any `u64` value.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) struct UncheckedVarInt(pub u64);
+
+impl Encodable for UncheckedVarInt {
+ fn consensus_encode<W: Write + ?Sized>(&self, w: &mut W) -> Result<usize, bitcoin::io::Error> {
+ match self.0 {
+ 0..=0xFC => {
+ (self.0 as u8).consensus_encode(w)?;
+ Ok(1)
+ }
+ 0xFD..=0xFFFF => {
+ 0xFDu8.consensus_encode(w)?;
+ (self.0 as u16).consensus_encode(w)?;
+ Ok(3)
+ }
+ 0x10000..=0xFFFFFFFF => {
+ 0xFEu8.consensus_encode(w)?;
+ (self.0 as u32).consensus_encode(w)?;
+ Ok(5)
+ }
+ _ => {
+ 0xFFu8.consensus_encode(w)?;
+ self.0.consensus_encode(w)?;
+ Ok(9)
+ }
+ }
+ }
+}
+
+impl Decodable for UncheckedVarInt {
+ fn consensus_decode<R: Read + ?Sized>(r: &mut R) -> Result<Self, EncodeError> {
+ let n = u8::consensus_decode(r)?;
+ match n {
+ 0xFF => {
+ let x = u64::consensus_decode(r)?;
+ if x < 0x1_0000_0000 {
+ Err(EncodeError::NonMinimalVarInt)
+ } else {
+ Ok(UncheckedVarInt(x))
+ }
+ }
+ 0xFE => {
+ let x = u32::consensus_decode(r)?;
+ if x < 0x10000 {
+ Err(EncodeError::NonMinimalVarInt)
+ } else {
+ Ok(UncheckedVarInt(x as u64))
+ }
+ }
+ 0xFD => {
+ let x = u16::consensus_decode(r)?;
+ if x < 0xFD {
+ Err(EncodeError::NonMinimalVarInt)
+ } else {
+ Ok(UncheckedVarInt(x as u64))
+ }
+ }
+ n => Ok(UncheckedVarInt(n as u64)),
+ }
+ }
+}
+
+impl UncheckedVarInt {
+ /// Returns the number of bytes this varint occupies when serialized.
+ pub fn size(&self) -> usize {
+ match self.0 {
+ 0..=0xFC => 1,
+ 0xFD..=0xFFFF => 3,
+ 0x10000..=0xFFFFFFFF => 5,
+ _ => 9,
+ }
+ }
+}
+
/// 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.
@@ -70,13 +152,14 @@ pub enum SignPsbtYieldedObject {
pub fn parse_sign_psbt_yielded(
data: &[u8],
) -> Result<(usize, SignPsbtYieldedObject), PartialSignatureError> {
- let (tag, i): (VarInt, usize) =
+ let (UncheckedVarInt(tag), i): (UncheckedVarInt, usize) =
deserialize_partial(data).map_err(|_| PartialSignatureError::InvalidLength)?;
- match tag.0 {
+ match tag {
CCMD_YIELD_MUSIG_PUBNONCE_TAG => {
- let (input_index, j): (VarInt, usize) = deserialize_partial(&data[i..])
- .map_err(|_| PartialSignatureError::InvalidLength)?;
+ let (UncheckedVarInt(input_index), j): (UncheckedVarInt, 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.
@@ -98,7 +181,7 @@ pub fn parse_sign_psbt_yielded(
None
};
Ok((
- input_index.0 as usize,
+ input_index as usize,
SignPsbtYieldedObject::MusigPubNonce(MusigPubNonce {
participant_pubkey,
aggregate_pubkey,
@@ -108,8 +191,9 @@ pub fn parse_sign_psbt_yielded(
))
}
CCMD_YIELD_MUSIG_PARTIALSIGNATURE_TAG => {
- let (input_index, j): (VarInt, usize) = deserialize_partial(&data[i..])
- .map_err(|_| PartialSignatureError::InvalidLength)?;
+ let (UncheckedVarInt(input_index), j): (UncheckedVarInt, 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.
@@ -131,7 +215,7 @@ pub fn parse_sign_psbt_yielded(
None
};
Ok((
- input_index.0 as usize,
+ input_index as usize,
SignPsbtYieldedObject::MusigPartialSignature(MusigPartialSignature {
participant_pubkey,
aggregate_pubkey,
@@ -145,18 +229,19 @@ pub fn parse_sign_psbt_yielded(
// 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 (UncheckedVarInt(input_index), j): (UncheckedVarInt, usize) =
+ deserialize_partial(&data[i..])
+ .map_err(|_| PartialSignatureError::InvalidLength)?;
let rest = &data[i + j..];
Ok((
- input_index.0 as usize,
+ input_index 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 input_index = tag as usize;
let ps = PartialSignature::from_slice(&data[i..])?;
Ok((input_index, SignPsbtYieldedObject::Partial(ps)))
}
@@ -190,7 +275,7 @@ mod tests {
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));
+ let mut payload = serialize(&UncheckedVarInt(3));
payload.push(32);
payload.extend(XONLY);
payload.extend([0xAAu8; 64]);
@@ -206,8 +291,8 @@ mod tests {
}
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)));
+ let mut payload = serialize(&UncheckedVarInt(CCMD_YIELD_MUSIG_PUBNONCE_TAG));
+ payload.extend(serialize(&UncheckedVarInt(input_index)));
payload.extend([0xAAu8; 66]); // pubnonce
payload.extend(PUBKEY); // participant pk (33)
payload.extend(PUBKEY); // aggregate pk (33)
@@ -248,8 +333,8 @@ mod tests {
}
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)));
+ let mut payload = serialize(&UncheckedVarInt(CCMD_YIELD_MUSIG_PARTIALSIGNATURE_TAG));
+ payload.extend(serialize(&UncheckedVarInt(input_index)));
payload.extend([0xBBu8; 32]); // partial signature
payload.extend(PUBKEY); // participant pk (33)
payload.extend(PUBKEY); // aggregate pk (33)
@@ -297,8 +382,8 @@ mod tests {
const UNKNOWN_TAG: u64 = 0x89AB_CDEF;
let trailer = hex!("deadbeef");
- let mut payload = serialize(&VarInt(UNKNOWN_TAG));
- payload.extend(serialize(&VarInt(11)));
+ let mut payload = serialize(&UncheckedVarInt(UNKNOWN_TAG));
+ payload.extend(serialize(&UncheckedVarInt(11)));
payload.extend(&trailer);
let (idx, obj) = parse_ok(&payload);
diff --git a/bitcoin_client_rs/src/psbt.rs b/bitcoin_client_rs/src/psbt.rs
index b520326..1e5b482 100644
--- a/bitcoin_client_rs/src/psbt.rs
+++ b/bitcoin_client_rs/src/psbt.rs
@@ -5,7 +5,7 @@
/// rust-bitcoin currently support V0.
use bitcoin::{
blockdata::transaction::{TxIn, TxOut},
- consensus::encode::{deserialize, serialize, VarInt},
+ consensus::encode::{deserialize, serialize},
ecdsa,
hashes::Hash,
key::FromSliceError as KeyError,
@@ -16,6 +16,7 @@ use bitcoin::{
PublicKey,
};
+use crate::protocol::UncheckedVarInt;
use serialize::Serialize;
#[rustfmt::skip]
@@ -94,7 +95,7 @@ pub fn get_v2_global_pairs(psbt: &Psbt) -> Vec<raw::Pair> {
type_value: PSBT_GLOBAL_INPUT_COUNT,
key: vec![],
},
- value: serialize(&VarInt(psbt.inputs.len() as u64)),
+ value: serialize(&UncheckedVarInt(psbt.inputs.len() as u64)),
});
rv.push(raw::Pair {
@@ -102,7 +103,7 @@ pub fn get_v2_global_pairs(psbt: &Psbt) -> Vec<raw::Pair> {
type_value: PSBT_GLOBAL_OUTPUT_COUNT,
key: vec![],
},
- value: serialize(&VarInt(psbt.outputs.len() as u64)),
+ value: serialize(&UncheckedVarInt(psbt.outputs.len() as u64)),
});
rv.push(raw::Pair {
@@ -459,9 +460,10 @@ mod serialize {
secp256k1::{self, XOnlyPublicKey},
taproot,
taproot::{ControlBlock, LeafVersion, TapLeafHash, TapNodeHash, TapTree, TaprootBuilder},
- VarInt,
};
+ use crate::protocol::UncheckedVarInt;
+
macro_rules! impl_psbt_de_serialize {
($thing:ty) => {
impl_psbt_serialize!($thing);
@@ -547,7 +549,7 @@ mod serialize {
impl Serialize for bitcoin::psbt::raw::Key {
fn serialize(&self) -> Vec<u8> {
let mut buf = Vec::new();
- VarInt((self.key.len() + 1) as u64)
+ UncheckedVarInt((self.key.len() + 1) as u64)
.consensus_encode(&mut buf)
.expect("in-memory writers don't error");
@@ -814,7 +816,7 @@ mod serialize {
let capacity = self
.script_leaves()
.map(|l| {
- l.script().len() + VarInt(l.script().len() as u64).size() // script version
+ l.script().len() + UncheckedVarInt(l.script().len() as u64).size() // script version
+ 1 // merkle branch
+ 1 // leaf version
})
diff --git a/bitcoin_client_rs/src/wallet.rs b/bitcoin_client_rs/src/wallet.rs
index 5f5c713..5715bb6 100644
--- a/bitcoin_client_rs/src/wallet.rs
+++ b/bitcoin_client_rs/src/wallet.rs
@@ -4,11 +4,12 @@ use core::str::FromStr;
use bitcoin::{
bip32::{DerivationPath, Error, Fingerprint, KeySource, Xpub},
- consensus::encode::{self, VarInt},
+ consensus::encode::{self},
hashes::{sha256, Hash, HashEngine},
};
use crate::merkle::MerkleTree;
+use crate::protocol::UncheckedVarInt;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Version {
@@ -106,7 +107,7 @@ impl WalletPolicy {
let mut res: Vec<u8> = (self.version as u8).to_be_bytes().to_vec();
res.extend_from_slice(&(self.name.len() as u8).to_be_bytes());
res.extend_from_slice(self.name.as_bytes());
- res.extend(encode::serialize(&VarInt(
+ res.extend(encode::serialize(&UncheckedVarInt(
self.descriptor_template.as_bytes().len() as u64,
)));
@@ -119,7 +120,7 @@ impl WalletPolicy {
res.extend_from_slice(self.descriptor_template.as_bytes());
}
- res.extend(encode::serialize(&VarInt(self.keys.len() as u64)));
+ res.extend(encode::serialize(&UncheckedVarInt(self.keys.len() as u64)));
res.extend_from_slice(
MerkleTree::new(
Why this scored 34/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.