From ae206a02f6467a2526ee2bce894a2823ea5e2607 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 29 May 2026 18:10:20 +0200 Subject: [PATCH 1/3] smite: implement WireFormat for AttributionData --- smite/src/bolt/wire.rs | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/smite/src/bolt/wire.rs b/smite/src/bolt/wire.rs index 78e6f4ff..5e3e0a0c 100644 --- a/smite/src/bolt/wire.rs +++ b/smite/src/bolt/wire.rs @@ -1,6 +1,7 @@ //! Wire format serialization and deserialization primitives. use crate::bolt::BoltError; +use crate::bolt::attribution_data::AttributionData; use crate::bolt::types::{ BigSize, CHANNEL_ID_SIZE, COMPACT_SIGNATURE_SIZE, ChannelId, PUBLIC_KEY_SIZE, SHA256_HASH_SIZE, ShortChannelId, TXID_SIZE, @@ -229,10 +230,28 @@ impl WireFormat for sha256::Hash { } } +impl WireFormat for AttributionData { + fn read(data: &mut &[u8]) -> Result { + let value = Self::decode(data)?; + *data = &data[Self::SIZE..]; + Ok(value) + } + + fn write(&self, out: &mut Vec) { + for &t in &self.htlc_hold_times { + t.write(out); + } + for hmac in &self.truncated_hmacs { + hmac.write(out); + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::bolt::SHORT_CHANNEL_ID_SIZE; + use crate::bolt::attribution_data::TruncatedHmac; use bitcoin::secp256k1::{self, Secp256k1, SecretKey}; #[test] @@ -985,4 +1004,42 @@ mod tests { ); assert_eq!(data.len(), 5); // 5 bytes remaining } + + #[test] + fn attribution_data_read_truncated() { + let mut empty: &[u8] = &[]; + assert_eq!( + AttributionData::read(&mut empty), + Err(BoltError::Truncated { + expected: AttributionData::SIZE, + actual: 0 + }) + ); + + let mut short: &[u8] = &[0xaa; 100]; + assert_eq!( + AttributionData::read(&mut short), + Err(BoltError::Truncated { + expected: AttributionData::SIZE, + actual: 100 + }) + ); + } + + #[test] + fn attribution_data_write_roundtrip() { + let original = AttributionData { + htlc_hold_times: [42; 20], + truncated_hmacs: [TruncatedHmac([0xcc; 4]); 210], + }; + + let mut buf = Vec::new(); + original.write(&mut buf); + assert_eq!(buf.len(), AttributionData::SIZE); + + let mut cursor: &[u8] = &buf; + let decoded = AttributionData::read(&mut cursor).unwrap(); + assert_eq!(decoded, original); + assert!(cursor.is_empty()); + } } From d331cba9bbd0801d4be91149604be10a3f4b8171 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 29 May 2026 17:24:39 +0200 Subject: [PATCH 2/3] smite: functions to decode TLVs with length from known encoding --- smite/src/bolt.rs | 7 ++ smite/src/bolt/tlv.rs | 154 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/smite/src/bolt.rs b/smite/src/bolt.rs index cafc9872..9e709996 100644 --- a/smite/src/bolt.rs +++ b/smite/src/bolt.rs @@ -103,6 +103,13 @@ pub enum BoltError { /// Unknown even TLV type (must reject per BOLT 1) #[error("TLV_UNKNOWN_EVEN_TYPE {0}")] TlvUnknownEvenType(u64), + /// TLV value longer than the known encoding for its type + #[error("TLV_TRAILING_BYTES type {tlv_type} expected {expected} got {actual}")] + TlvTrailingBytes { + tlv_type: u64, + expected: usize, + actual: usize, + }, } /// BOLT message type constants. diff --git a/smite/src/bolt/tlv.rs b/smite/src/bolt/tlv.rs index 4b495b89..f61e0888 100644 --- a/smite/src/bolt/tlv.rs +++ b/smite/src/bolt/tlv.rs @@ -63,6 +63,85 @@ impl TlvStream { .map(|r| r.value.as_slice()) } + /// Gets a record by type and decodes it as a fixed-size `WireFormat` value. + /// + /// Returns `None` if the record is absent. Rejects TLV values that are + /// longer than the type's known wire encoding. + /// + /// # Errors + /// + /// Returns a `BoltError` if the value is truncated or contains trailing + /// bytes after decoding. + pub fn get_as(&self, tlv_type: u64) -> Result, BoltError> { + self.get(tlv_type) + .map(|data| { + let mut cursor = data; + let value = T::read(&mut cursor)?; + // we must fail to parse the stream + // "if length is not exactly equal to that required for the known encoding for type" + // [BOLT 1]: https://github.com/lightning/bolts/blob/master/01-messaging.md#type-length-value-format + if !cursor.is_empty() { + let bytes_read = data.len() - cursor.len(); + return Err(BoltError::TlvTrailingBytes { + tlv_type, + expected: bytes_read, + actual: data.len(), + }); + } + Ok(value) + }) + .transpose() + } + + /// Gets all records by type and decodes them as fixed-size `WireFormat` + /// values. + /// + /// Returns `None` if no records are found, or `Some(vec)` if present. + /// + /// # Errors + /// + /// Returns a `BoltError` if decoding a TLV value fails or the TLV values + /// cannot be divided into fixed-size chunks. + pub fn get_as_many(&self, tlv_type: u64) -> Result>, BoltError> { + match self.get(tlv_type) { + Some(data) => { + if data.is_empty() { + return Ok(Some(Vec::new())); + } + + let total_bytes = data.len(); + let mut cursor = data; + + // read first element to determine chunk size + let first = T::read(&mut cursor)?; + + let chunk_size = total_bytes - cursor.len(); + if chunk_size == 0 { + return Err(BoltError::Truncated { + expected: 1, + actual: 0, + }); + } + if total_bytes % chunk_size != 0 { + return Err(BoltError::TlvTrailingBytes { + tlv_type, + expected: (total_bytes / chunk_size) * chunk_size, + actual: total_bytes, + }); + } + + let mut values = Vec::with_capacity(total_bytes / chunk_size); + values.push(first); + for chunk in cursor.chunks(chunk_size) { + let mut chunk_cursor = chunk; + values.push(T::read(&mut chunk_cursor)?); + } + Ok(Some(values)) + } + None => Ok(None), + } + } + /// Returns an iterator over all records. pub fn iter(&self) -> impl Iterator { self.records.iter() @@ -210,6 +289,81 @@ mod tests { assert_eq!(decoded.get(255), Some(&[0xff; 100][..])); } + #[test] + fn get_as_missing_returns_none() { + let stream = TlvStream::new(); + assert_eq!(stream.get_as::(1).unwrap(), None); + } + + #[test] + fn get_as_exact_length() { + let mut stream = TlvStream::new(); + let mut value = Vec::new(); + 42u64.write(&mut value); + stream.add(1, value); + + assert_eq!(stream.get_as::(1).unwrap(), Some(42)); + } + + #[test] + fn get_as_overlength_rejected() { + let mut stream = TlvStream::new(); + let mut value = Vec::new(); + 42u64.write(&mut value); + value.push(0xff); + stream.add(1, value); + + assert_eq!( + stream.get_as::(1), + Err(BoltError::TlvTrailingBytes { + tlv_type: 1, + expected: 8, + actual: 9 + }) + ); + } + + #[test] + fn get_as_underlength_truncated() { + let mut stream = TlvStream::new(); + stream.add(1, vec![0xaa; 4]); + + assert_eq!( + stream.get_as::(1), + Err(BoltError::Truncated { + expected: 8, + actual: 4 + }) + ); + } + + #[test] + fn get_as_many() { + let mut stream = TlvStream::new(); + let value = [[0u8; 32], [1u8; 32]].as_flattened().to_vec(); + stream.add(1, value); + + assert_eq!( + stream.get_as_many::<[u8; 32]>(1).unwrap(), + Some(vec![[0u8; 32], [1u8; 32]]) + ); + } + + #[test] + fn get_as_many_reject_trailing_bytes() { + let mut stream = TlvStream::new(); + stream.add(1, vec![0u8; 33]); + + assert_eq!( + stream.get_as_many::<[u8; 32]>(1), + Err(BoltError::TlvTrailingBytes { + tlv_type: 1, + expected: 32, + actual: 33 + }) + ); + } + #[test] fn known_even_accepted() { // type=2 is even but known From b8d7907cdb1a400363f675573589ba0f4e670704 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 29 May 2026 18:13:38 +0200 Subject: [PATCH 3/3] smite: check if TLV length matches length of known encoding --- smite/src/bolt/channel_ready.rs | 33 +++++++++++++++++++-------- smite/src/bolt/init.rs | 21 ++++------------- smite/src/bolt/tx_ack_rbf.rs | 12 +++------- smite/src/bolt/tx_add_input.rs | 10 ++------ smite/src/bolt/tx_init_rbf.rs | 12 +++------- smite/src/bolt/update_fail_htlc.rs | 7 ++---- smite/src/bolt/update_fulfill_htlc.rs | 7 ++---- 7 files changed, 41 insertions(+), 61 deletions(-) diff --git a/smite/src/bolt/channel_ready.rs b/smite/src/bolt/channel_ready.rs index a685b29b..91ecb8e4 100644 --- a/smite/src/bolt/channel_ready.rs +++ b/smite/src/bolt/channel_ready.rs @@ -80,16 +80,9 @@ impl ChannelReadyTlvs { /// /// # Errors /// - /// Returns `Truncated` if the short channel ID TLV has invalid length. + /// Returns a `BoltError` if the short channel ID TLV has invalid length. fn from_stream(stream: &TlvStream) -> Result { - let short_channel_id = if let Some(data) = stream.get(TLV_SHORT_CHANNEL_ID) { - let mut cursor = data; - let scid = u64::read(&mut cursor)?; - Some(scid) - } else { - None - }; - + let short_channel_id = stream.get_as::(TLV_SHORT_CHANNEL_ID)?; Ok(Self { short_channel_id }) } } @@ -223,6 +216,28 @@ mod tests { ); } + #[test] + // Test constants are known to fit in u8 + #[allow(clippy::cast_possible_truncation)] + fn decode_short_channel_id_reject_trailing_bytes() { + let msg = sample_channel_ready(None); + let mut encoded = msg.encode(); + + // short_channel_id TLV should be 8 bytes, but we push 9 bytes + encoded.push(TLV_SHORT_CHANNEL_ID as u8); + encoded.push(0x09); + encoded.extend_from_slice(&[0xbb; 9]); + + assert_eq!( + ChannelReady::decode(&encoded), + Err(BoltError::TlvTrailingBytes { + tlv_type: TLV_SHORT_CHANNEL_ID, + expected: 8, + actual: 9, + }) + ); + } + #[test] fn default_tlvs_are_none() { let tlvs = ChannelReadyTlvs::default(); diff --git a/smite/src/bolt/init.rs b/smite/src/bolt/init.rs index 9f28138e..a846ee92 100644 --- a/smite/src/bolt/init.rs +++ b/smite/src/bolt/init.rs @@ -125,21 +125,9 @@ impl InitTlvs { /// /// # Errors /// - /// Returns `Truncated` if the networks TLV has invalid length. + /// Returns a `BoltError` if the networks TLV has invalid length. fn from_stream(stream: &TlvStream) -> Result { - let networks = if let Some(data) = stream.get(TLV_NETWORKS) { - let (chunks, remainder) = data.as_chunks::(); - if !remainder.is_empty() { - return Err(BoltError::Truncated { - expected: (chunks.len() + 1) * CHAIN_HASH_SIZE, - actual: data.len(), - }); - } - Some(chunks.to_vec()) - } else { - None - }; - + let networks = stream.get_as_many::<[u8; 32]>(TLV_NETWORKS)?; let remote_addr = stream.get(TLV_REMOTE_ADDR).map(Vec::from); Ok(Self { @@ -389,8 +377,9 @@ mod tests { assert_eq!( Init::decode(&data), - Err(BoltError::Truncated { - expected: CHAIN_HASH_SIZE * 2, // Next multiple of 32 + Err(BoltError::TlvTrailingBytes { + tlv_type: 1, + expected: 32, actual: 33 }) ); diff --git a/smite/src/bolt/tx_ack_rbf.rs b/smite/src/bolt/tx_ack_rbf.rs index c74a9293..c990bc2b 100644 --- a/smite/src/bolt/tx_ack_rbf.rs +++ b/smite/src/bolt/tx_ack_rbf.rs @@ -90,16 +90,10 @@ impl TxAckRbfTlvs { /// /// # Errors /// - /// Returns `Truncated` if `funding_output_contribution` has invalid length. + /// Returns a `BoltError` if `funding_output_contribution` has invalid + /// length. fn from_stream(stream: &TlvStream) -> Result { - let funding_output_contribution = - if let Some(data) = stream.get(TLV_FUNDING_OUTPUT_CONTRIBUTION) { - let mut cursor = data; - Some(i64::read(&mut cursor)?) - } else { - None - }; - + let funding_output_contribution = stream.get_as::(TLV_FUNDING_OUTPUT_CONTRIBUTION)?; let require_confirmed_inputs = stream.get(TLV_REQUIRE_CONFIRMED_INPUTS).is_some(); Ok(Self { diff --git a/smite/src/bolt/tx_add_input.rs b/smite/src/bolt/tx_add_input.rs index edc1417e..04412c2a 100644 --- a/smite/src/bolt/tx_add_input.rs +++ b/smite/src/bolt/tx_add_input.rs @@ -44,15 +44,9 @@ impl TxAddInputTlvs { /// /// # Errors /// - /// Returns `Truncated` if `shared_input_txid` has invalid length. + /// Returns a `BoltError` if `shared_input_txid` has invalid length. fn from_stream(stream: &TlvStream) -> Result { - let shared_input_txid = stream - .get(TLV_SHARED_INPUT_TXID) - .map(|v| { - let mut cursor = v; - Txid::read(&mut cursor) - }) - .transpose()?; + let shared_input_txid = stream.get_as::(TLV_SHARED_INPUT_TXID)?; Ok(Self { shared_input_txid }) } } diff --git a/smite/src/bolt/tx_init_rbf.rs b/smite/src/bolt/tx_init_rbf.rs index 9c909a8e..db9057e9 100644 --- a/smite/src/bolt/tx_init_rbf.rs +++ b/smite/src/bolt/tx_init_rbf.rs @@ -103,16 +103,10 @@ impl TxInitRbfTlvs { /// /// # Errors /// - /// Returns `Truncated` if `funding_output_contribution` has invalid length. + /// Returns a `BoltError` if `funding_output_contribution` has invalid + /// length. fn from_stream(stream: &TlvStream) -> Result { - let funding_output_contribution = - if let Some(data) = stream.get(TLV_FUNDING_OUTPUT_CONTRIBUTION) { - let mut cursor = data; - Some(i64::read(&mut cursor)?) - } else { - None - }; - + let funding_output_contribution = stream.get_as::(TLV_FUNDING_OUTPUT_CONTRIBUTION)?; let require_confirmed_inputs = stream.get(TLV_REQUIRE_CONFIRMED_INPUTS).is_some(); Ok(Self { diff --git a/smite/src/bolt/update_fail_htlc.rs b/smite/src/bolt/update_fail_htlc.rs index af82fd08..26b4ce6d 100644 --- a/smite/src/bolt/update_fail_htlc.rs +++ b/smite/src/bolt/update_fail_htlc.rs @@ -82,12 +82,9 @@ impl UpdateFailHtlcTlvs { /// /// # Errors /// - /// Returns `Truncated` if `attribution_data` has invalid length. + /// Returns a `BoltError` if `attribution_data` has invalid length. fn from_stream(stream: &TlvStream) -> Result { - let attribution_data = stream - .get(TLV_ATTRIBUTION_DATA) - .map(AttributionData::decode) - .transpose()?; + let attribution_data = stream.get_as::(TLV_ATTRIBUTION_DATA)?; Ok(Self { attribution_data }) } } diff --git a/smite/src/bolt/update_fulfill_htlc.rs b/smite/src/bolt/update_fulfill_htlc.rs index 1f004315..16673ad8 100644 --- a/smite/src/bolt/update_fulfill_htlc.rs +++ b/smite/src/bolt/update_fulfill_htlc.rs @@ -86,12 +86,9 @@ impl UpdateFulfillHtlcTlvs { /// /// # Errors /// - /// Returns `Truncated` if `attribution_data` has invalid length. + /// Returns a `BoltError` if `attribution_data` has invalid length. fn from_stream(stream: &TlvStream) -> Result { - let attribution_data = stream - .get(TLV_ATTRIBUTION_DATA) - .map(AttributionData::decode) - .transpose()?; + let attribution_data = stream.get_as::(TLV_ATTRIBUTION_DATA)?; Ok(Self { attribution_data }) } }