diff --git a/smite-scenarios/src/executor.rs b/smite-scenarios/src/executor.rs index a633501a..e4fef171 100644 --- a/smite-scenarios/src/executor.rs +++ b/smite-scenarios/src/executor.rs @@ -20,7 +20,8 @@ use smite::channel_tx::{ }; use smite::noise::{ConnectionError, NoiseConnection}; use smite::oracles::{ - AcceptChannelContext, AcceptChannelOracle, FundingSignedContext, FundingSignedOracle, Oracle, + AcceptChannelContext, AcceptChannelOracle, ChannelReadyContext, ChannelReadyOracle, + FundingSignedContext, FundingSignedOracle, Oracle, }; use smite::pending_channel::{KeyOrigin, PendingChannel}; use smite::violation::Violation; @@ -258,6 +259,10 @@ pub struct Executor { /// We may send the same pubkey more than once, keeping its first origin. /// The target must not send a pubkey whose private key is or will be known. revealed_pubkeys: HashMap, + /// Every `short_channel_id` revealed on the wire, both advertised aliases + /// and derived real ones, mapped to the channel that owns it. The target + /// must not reuse one across channels. + revealed_scids: HashMap, /// Transactions stored outside Bitcoin Core's mempool, typically because they /// were rejected by mempool policy, to be included in the next `MineBlocks` /// operation. Each is stored as `(txid, raw_hex)`: re-signing the same @@ -293,6 +298,7 @@ impl Executor { channel_states: HashMap::new(), negotiations: HashMap::new(), revealed_pubkeys, + revealed_scids: HashMap::new(), private_mempool: Vec::new(), unmined_txids: HashSet::new(), mined_txids: HashSet::new(), @@ -526,6 +532,9 @@ impl Executor { &mut self.conn, &mut self.channel_states, &mut self.revealed_pubkeys, + &mut self.revealed_scids, + &self.context, + &mut self.bitcoin_cli, RECV_IDLE_TIMEOUT, )?; log::debug!("[{:?}] RecvAcceptChannel: received", start.elapsed()); @@ -554,6 +563,9 @@ impl Executor { &mut self.conn, &mut self.channel_states, &mut self.revealed_pubkeys, + &mut self.revealed_scids, + &self.context, + &mut self.bitcoin_cli, RECV_IDLE_TIMEOUT, )?; log::debug!("[{:?}] RecvFundingSigned: received", start.elapsed()); @@ -574,6 +586,9 @@ impl Executor { &mut self.conn, &mut self.channel_states, &mut self.revealed_pubkeys, + &mut self.revealed_scids, + &self.context, + &mut self.bitcoin_cli, RECV_CHANNEL_READY_TIMEOUT, )?; log::debug!("[{:?}] RecvChannelReady: received", start.elapsed()); @@ -625,18 +640,11 @@ impl Executor { // message will simply fail on-chain validation, which is // the intended fuzzing behaviour for a valid but // unconfirmed program. - let scid = match self.bitcoin_cli.get_transaction_block_position(txid) { - Some(pos) => { - let funding_output_index = - u16::try_from(ft.vout).expect("funding output index fits in u16"); - ShortChannelId::new( - pos.block_height, - pos.tx_index, - funding_output_index, - ) - } - None => ShortChannelId::new(0, 0, 0), - }; + let scid = lookup_short_channel_id( + &mut self.bitcoin_cli, + OutPoint::new(txid, ft.vout), + ) + .unwrap_or_default(); log::debug!( "[{:?}] LookupShortChannelId: txid={} scid={}", start.elapsed(), @@ -1008,6 +1016,17 @@ fn build_shutdown(variables: &[Option], inputs: &[usize]) -> Shutdown Shutdown::for_channel(channel_id, scriptpubkey) } +/// Returns the short channel ID of the funding output `outpoint`, or `None` if +/// its transaction is unknown to the node or not yet confirmed. +fn lookup_short_channel_id( + bitcoin_cli: &mut impl BitcoinRpc, + outpoint: OutPoint, +) -> Option { + let pos = bitcoin_cli.get_transaction_block_position(outpoint.txid)?; + let vout = u16::try_from(outpoint.vout).expect("funding output index fits in u16"); + Some(ShortChannelId::new(pos.block_height, pos.tx_index, vout)) +} + /// Builds a signed `ChannelAnnouncement` from 7 input variables. fn build_channel_announcement( variables: &[Option], @@ -1179,9 +1198,9 @@ fn build_channel_update(variables: &[Option], inputs: &[usize]) -> Cha } /// Returns `true` if a message of type `msg_type` is handled implicitly by the -/// executor: [`recv_non_ping`] records it into the executor's tracking state -/// and returns it, and explicit receives skip past it. See each message's -/// `record_*` function for the state it records. +/// executor: [`recv_non_ping`] evaluates its oracle, then records it into the +/// executor's tracking state and returns it, and explicit receives skip past +/// it. See each message's `record_*` function for the state it records. /// /// This currently includes: /// - `channel_ready` @@ -1206,6 +1225,9 @@ fn recv_non_ping( conn: &mut impl Connection, channel_states: &mut HashMap, revealed_pubkeys: &mut HashMap, + revealed_scids: &mut HashMap, + context: &ProgramContext, + bitcoin_cli: &mut impl BitcoinRpc, timeout: Duration, ) -> Result { let previous = conn.read_timeout()?; @@ -1221,7 +1243,31 @@ fn recv_non_ping( } Message::ChannelReady(ref cr) => { log::debug!("received channel_ready on {}", cr.channel_id); - record_recv_channel_ready(channel_states, revealed_pubkeys, cr)?; + let channel = channel_states.get(&cr.channel_id); + // TODO: Both lookups below call `getrawtransaction`. Returning + // confirmations alongside the block position would save one RPC. + let funding_outpoint = channel + .filter(|c| c.is_funding_outpoint_valid) + .map(|c| c.config.funding_outpoint); + let funding_confirmations = funding_outpoint + .map_or(0, |o| bitcoin_cli.get_transaction_confirmations(o.txid)); + let funding_scid = + funding_outpoint.and_then(|o| lookup_short_channel_id(bitcoin_cli, o)); + ChannelReadyOracle.evaluate(&ChannelReadyContext { + channel_ready: cr, + channel, + negotiated_features: &context.negotiated_features, + revealed_pubkeys, + funding_confirmations, + revealed_scids, + })?; + record_recv_channel_ready( + channel_states, + revealed_pubkeys, + revealed_scids, + funding_scid, + cr, + ); return Ok(msg); } Message::Unknown { .. } => { @@ -1282,10 +1328,21 @@ fn recv_bolt( conn: &mut impl Connection, channel_states: &mut HashMap, revealed_pubkeys: &mut HashMap, + revealed_scids: &mut HashMap, + context: &ProgramContext, + bitcoin_cli: &mut impl BitcoinRpc, timeout: Duration, ) -> Result { loop { - let msg = recv_non_ping(conn, channel_states, revealed_pubkeys, timeout)?; + let msg = recv_non_ping( + conn, + channel_states, + revealed_pubkeys, + revealed_scids, + context, + bitcoin_cli, + timeout, + )?; let got = msg.msg_type(); if let Some(m) = M::from_message(msg) { return Ok(m); @@ -1422,32 +1479,43 @@ fn record_recv_funding_signed( /// counterparty's next per-commitment point on the channel it identifies, and /// in `revealed_pubkeys` as a per-commitment point the target revealed. /// -/// # Errors +/// # Panics /// -/// Returns [`Violation::UnknownChannel`] if no channel state exists for the -/// message's `channel_id`. +/// Panics if no matching channel state exists. This should be unreachable, as +/// `ChannelReadyOracle` reports such messages as a [`Violation`]. fn record_recv_channel_ready( channel_states: &mut HashMap, revealed_pubkeys: &mut HashMap, + revealed_scids: &mut HashMap, + funding_scid: Option, channel_ready: &ChannelReady, -) -> Result<(), Violation> { +) { let state = channel_states .get_mut(&channel_ready.channel_id) - .ok_or(Violation::UnknownChannel(channel_ready.channel_id))?; - *state.next_counterparty_per_commitment_point_mut() = - Some(channel_ready.second_per_commitment_point); + .expect("ChannelReadyOracle guaranteed this channel_id exists"); + + // Record the advertised alias and the channel's real `short_channel_id` as + // owned by this channel, so `ChannelReadyOracle` can reject a later reuse + // by another channel. + if let Some(alias) = channel_ready.tlvs.short_channel_id { + revealed_scids.insert(alias, channel_ready.channel_id); + } + if let Some(real_scid) = funding_scid { + revealed_scids.insert(real_scid, channel_ready.channel_id); + } + + // Only the first `channel_ready` sets these. `ChannelReadyOracle` checks + // resends against its point, and a resend's point is stale once the + // commitment state advances, so it must not overwrite the next point. + if state.received_channel_ready_point.is_none() { + state.received_channel_ready_point = Some(channel_ready.second_per_commitment_point); + *state.next_counterparty_per_commitment_point_mut() = + Some(channel_ready.second_per_commitment_point); + } // Record the `second_per_commitment_point` as a per-commitment point the // target revealed, for commitment number 1. It is unique, so record its // only origin. - // - // TODO: Once we have the channel_ready oracle, flag a reused - // `second_per_commitment_point`, i.e. one already present in - // `revealed_pubkeys`, unless the entry is a `TargetPcp` for this same - // channel and commitment number. That exception allows the two legitimate - // repeats: the target may resend `channel_ready` with a different alias but - // the same point, and we may receive the same point again after a - // reconnection. revealed_pubkeys.insert( channel_ready.second_per_commitment_point, KeyOrigin::TargetPcp { @@ -1455,8 +1523,6 @@ fn record_recv_channel_ready( commitment_number: 1, }, ); - - Ok(()) } /// Extracts a field from a parsed `accept_channel` message. diff --git a/smite-scenarios/src/executor/tests.rs b/smite-scenarios/src/executor/tests.rs index 0fefe0f5..84a9bd87 100644 --- a/smite-scenarios/src/executor/tests.rs +++ b/smite-scenarios/src/executor/tests.rs @@ -1532,6 +1532,143 @@ fn execute_recv_channel_ready_resend_with_new_alias() { assert_eq!(fx.channel_states().len(), 2); } +#[test] +fn execute_recv_channel_ready_rejects_alias_reused_by_another_channel() { + // A second negotiation with a different temporary channel ID and funding + // amount, funded by a second UTXO, resulting in a different channel ID. + let second_id = TemporaryChannelId::new([0xcc; 32]); + let mut second_utxo = sample_utxo(); + second_utxo.amount = Amount::from_sat(20_010_000); + second_utxo.outpoint.vout = 1; + let mut second_negotiation = sample_funding_negotiation(); + second_negotiation.open_channel.temporary_channel_id = second_id; + second_negotiation.open_channel.funding_satoshis = 20_000_000; + second_negotiation + .accept_channel + .as_mut() + .unwrap() + .temporary_channel_id = second_id; + let second_channel_id = ChannelId::v1_from_funding_outpoint(OutPoint { + txid: "cb6f11974ce6265f81037dc7c0e3f054462f645361bc1e10c208f132f4acbe8d" + .parse() + .expect("valid txid"), + vout: 0, + }); + + // Fund both channels and receive a `channel_ready` for each once the + // funding transactions reach `minimum_depth`. The second channel's + // `channel_ready` advertises the alias the first channel already + // advertised. + let mut b = ProgramBuilder::new(); + send_funding_created(&mut b); + let tx = create_funding_tx_with(&mut b, 20_000_000, 15_000); + let second_temp_chan_id = b.append(Operation::LoadChannelId(second_id.0), &[]); + b.append( + Operation::SendFundingCreated, + &[tx.tx, tx.opener_privkey, second_temp_chan_id], + ); + b.append(Operation::MineBlocks(6), &[]); + b.append(Operation::RecvChannelReady, &[]); + b.append(Operation::RecvChannelReady, &[]); + + let alias = ShortChannelId::from_u64(42); + let err = Fixture::new() + .with_utxos(vec![sample_utxo(), second_utxo]) + .with_negotiation(sample_funding_negotiation()) + .with_negotiation(second_negotiation) + .queue(&Message::ChannelReady(ChannelReady { + channel_id: funding_channel_id(), + second_per_commitment_point: sample_pubkey(7), + tlvs: ChannelReadyTlvs { + short_channel_id: Some(alias), + }, + })) + .queue(&Message::ChannelReady(ChannelReady { + channel_id: second_channel_id, + second_per_commitment_point: sample_pubkey(8), + tlvs: ChannelReadyTlvs { + short_channel_id: Some(alias), + }, + })) + .run_err(&b.build()); + + let ExecuteError::Violation(Violation::InvalidChannelReady(id, reason)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert_eq!(*id, second_channel_id); + assert!(reason.contains(&format!( + "short_channel_id alias {alias} reuses the short_channel_id of channel {}", + funding_channel_id(), + ))); +} + +#[test] +fn execute_recv_channel_ready_rejects_alias_matching_another_channels_scid() { + // A second negotiation with a different temporary channel ID and funding + // amount, funded by a second UTXO, resulting in a different channel ID. + let second_id = TemporaryChannelId::new([0xcc; 32]); + let mut second_utxo = sample_utxo(); + second_utxo.amount = Amount::from_sat(20_010_000); + second_utxo.outpoint.vout = 1; + let mut second_negotiation = sample_funding_negotiation(); + second_negotiation.open_channel.temporary_channel_id = second_id; + second_negotiation.open_channel.funding_satoshis = 20_000_000; + second_negotiation + .accept_channel + .as_mut() + .unwrap() + .temporary_channel_id = second_id; + let second_channel_id = ChannelId::v1_from_funding_outpoint(OutPoint { + txid: "cb6f11974ce6265f81037dc7c0e3f054462f645361bc1e10c208f132f4acbe8d" + .parse() + .expect("valid txid"), + vout: 0, + }); + + // Fund both channels and receive a `channel_ready` for each once the + // funding transactions reach `minimum_depth`. The second channel advertises + // an alias matching the first channel's real short channel ID. + let mut b = ProgramBuilder::new(); + send_funding_created(&mut b); + let tx = create_funding_tx_with(&mut b, 20_000_000, 15_000); + let second_temp_chan_id = b.append(Operation::LoadChannelId(second_id.0), &[]); + b.append( + Operation::SendFundingCreated, + &[tx.tx, tx.opener_privkey, second_temp_chan_id], + ); + b.append(Operation::MineBlocks(6), &[]); + b.append(Operation::RecvChannelReady, &[]); + b.append(Operation::RecvChannelReady, &[]); + + let alias = ShortChannelId::new(800_042, 7, 0); + let err = Fixture::new() + .with_utxos(vec![sample_utxo(), second_utxo]) + .with_negotiation(sample_funding_negotiation()) + .with_negotiation(second_negotiation) + .queue(&Message::ChannelReady(ChannelReady { + channel_id: funding_channel_id(), + second_per_commitment_point: sample_pubkey(7), + tlvs: ChannelReadyTlvs::default(), + })) + .queue(&Message::ChannelReady(ChannelReady { + channel_id: second_channel_id, + second_per_commitment_point: sample_pubkey(8), + tlvs: ChannelReadyTlvs { + short_channel_id: Some(alias), + }, + })) + .run_err(&b.build()); + + let ExecuteError::Violation(Violation::InvalidChannelReady(id, reason)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert_eq!(*id, second_channel_id); + assert!(reason.contains(&format!( + "short_channel_id alias {alias} reuses the short_channel_id of channel {}", + funding_channel_id(), + ))); +} + #[test] fn execute_recv_channel_ready_already_recorded_is_noop() { let (fx, target_pcp) = recv_channel_ready_fixture(); @@ -1561,6 +1698,68 @@ fn execute_recv_channel_ready_already_recorded_is_noop() { assert_eq!(fx.queued_len(), 0); } +#[test] +fn execute_recv_channel_ready_resend_rejects_changed_point() { + let (fx, target_pcp) = recv_channel_ready_fixture(); + let changed = sample_pubkey(8); + + // After the target's first `channel_ready` is received, it resends one for + // the same channel with a different `second_per_commitment_point`. + // + // `RecvChannelReady` becomes a no-op after the first expected + // `channel_ready` has been recorded, so we negotiate a second unrelated + // channel whose `RecvAcceptChannel` receives the `channel_ready` implicitly. + let mut b = ProgramBuilder::new(); + let funding_created = send_funding_created(&mut b); + b.append(Operation::RecvFundingSigned, &[funding_created.sent]); + b.append(Operation::MineBlocks(6), &[]); + b.append(Operation::RecvChannelReady, &[]); + negotiate_channel(&mut b, &announced_open_channel()); + + let err = fx + .queue(&channel_ready_reply(changed)) + .queue(&Message::AcceptChannel(sample_accept_channel())) + .run_err(&b.build()); + + let ExecuteError::Violation(Violation::InvalidChannelReady(id, reason)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert_eq!(*id, funding_channel_id()); + assert!(reason.contains(&format!( + "channel_ready resend changed second_per_commitment_point from {target_pcp} to {changed}" + ))); +} + +#[test] +fn execute_recv_channel_ready_rejects_below_minimum_depth() { + let (fx, _) = recv_channel_ready_fixture(); + + // Mine one block fewer than the `minimum_depth` negotiated in + // `accept_channel`. The target's `channel_ready` arrives early, which means + // `RecvChannelReady` acts as a no-op and won't wait for it. Instead we + // negotiate a second unrelated channel whose `RecvAcceptChannel` receives + // the `channel_ready` implicitly. + let mut b = ProgramBuilder::new(); + let funding_created = send_funding_created(&mut b); + b.append(Operation::RecvFundingSigned, &[funding_created.sent]); + b.append(Operation::MineBlocks(5), &[]); + negotiate_channel(&mut b, &announced_open_channel()); + + let err = fx + .queue(&Message::AcceptChannel(sample_accept_channel())) + .run_err(&b.build()); + + let ExecuteError::Violation(Violation::InvalidChannelReady(id, reason)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert_eq!(*id, funding_channel_id()); + assert!( + reason.contains( + "channel_ready received with 5 funding confirmation(s), below minimum_depth 6" + ) + ); +} + #[test] fn execute_recv_implicit_channel_ready_unknown_channel() { // No channel is tracked, so a `channel_ready` received while waiting for @@ -1569,10 +1768,11 @@ fn execute_recv_implicit_channel_ready_unknown_channel() { .queue(&channel_ready_reply(sample_pubkey(1))) .queue(&Message::AcceptChannel(sample_accept_channel())) .run_err(&negotiate_channel_program(&announced_open_channel())); - let ExecuteError::Violation(Violation::UnknownChannel(id)) = &err else { + let ExecuteError::Violation(Violation::InvalidChannelReady(id, reason)) = &err else { panic!("unexpected error: {err:?}"); }; assert_eq!(*id, funding_channel_id()); + assert!(reason.contains("unknown channel_id: no channel was funded for it")); } #[test] @@ -1591,6 +1791,111 @@ fn execute_recv_channel_ready_unexpected_message() { )); } +#[test] +fn execute_recv_channel_ready_rejects_reused_our_pubkey() { + // The target's `channel_ready` reuses a pubkey we revealed in an + // `open_channel` of a different negotiation. + let mut oc = SampleOpenChannel::new(PointSource::Secret([0x11; 32])); + oc.message.temporary_channel_id = TemporaryChannelId::new([0xcc; 32]); + let reused = oc.message.funding_pubkey; + + let mut b = ProgramBuilder::new(); + let sent_open_channel = send_open_channel(&mut b, &oc); + let funding_created = send_funding_created(&mut b); + b.append(Operation::RecvFundingSigned, &[funding_created.sent]); + b.append(Operation::MineBlocks(6), &[]); + b.append(Operation::RecvChannelReady, &[]); + + let err = Fixture::new() + .with_negotiation(sample_funding_negotiation()) + .queue(&funding_signed_reply(funding_channel_id())) + .queue(&channel_ready_reply(reused)) + .run_err(&b.build()); + + let ExecuteError::Violation(Violation::InvalidChannelReady(id, reason)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert_eq!(*id, funding_channel_id()); + assert!(reason.contains(&format!( + "second_per_commitment_point {reused} reuses our key from instruction {}", + sent_open_channel.sent, + ))); +} + +#[test] +fn execute_recv_channel_ready_rejects_reused_target_static_pubkey() { + let second_id = TemporaryChannelId::new([0xcc; 32]); + let mut oc = announced_open_channel(); + oc.message.temporary_channel_id = second_id; + + // The target's `channel_ready` reuses a static pubkey it revealed in an + // `accept_channel` of a different negotiation. + let accept_channel = AcceptChannel { + temporary_channel_id: second_id, + ..sample_accept_channel() + }; + let reused = accept_channel.delayed_payment_basepoint; + + let mut b = ProgramBuilder::new(); + negotiate_channel(&mut b, &oc); + let funding_created = send_funding_created(&mut b); + b.append(Operation::RecvFundingSigned, &[funding_created.sent]); + b.append(Operation::MineBlocks(6), &[]); + b.append(Operation::RecvChannelReady, &[]); + + let err = Fixture::new() + .with_negotiation(sample_funding_negotiation()) + .queue(&Message::AcceptChannel(accept_channel)) + .queue(&funding_signed_reply(funding_channel_id())) + .queue(&channel_ready_reply(reused)) + .run_err(&b.build()); + + let ExecuteError::Violation(Violation::InvalidChannelReady(id, reason)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert_eq!(*id, funding_channel_id()); + assert!(reason.contains(&format!( + "second_per_commitment_point {reused} reuses target's delayed_payment_basepoint on channel {second_id}" + ))); +} + +#[test] +fn execute_recv_channel_ready_rejects_reused_our_channel_ready_pubkey() { + let our_sk = SecretKey::from_slice(&[0x42; 32]).expect("valid secret key"); + let reused = PublicKey::from_secret_key(&Secp256k1::new(), &our_sk); + + // Send a `channel_ready` revealing a point we hold the secret for, then the + // target's `channel_ready` reuses it. + let mut b = ProgramBuilder::new(); + let funding_created = send_funding_created(&mut b); + let channel_id = b.append(Operation::RecvFundingSigned, &[funding_created.sent]); + b.append(Operation::MineBlocks(6), &[]); + let sk = b.append(Operation::LoadPrivateKey(our_sk.secret_bytes()), &[]); + let our_point = b.append(Operation::DerivePoint, &[sk]); + let alias = b.append(Operation::LoadShortChannelId(0), &[]); + let sent_channel_ready = b.append( + Operation::SendChannelReady { + include_alias: true, + }, + &[channel_id, our_point, alias], + ); + b.append(Operation::RecvChannelReady, &[]); + + let err = Fixture::new() + .with_negotiation(sample_funding_negotiation()) + .queue(&funding_signed_reply(funding_channel_id())) + .queue(&channel_ready_reply(reused)) + .run_err(&b.build()); + + let ExecuteError::Violation(Violation::InvalidChannelReady(id, reason)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert_eq!(*id, funding_channel_id()); + assert!(reason.contains(&format!( + "second_per_commitment_point {reused} reuses our key from instruction {sent_channel_ready}" + ))); +} + // -- extract_field tests -- // TODO: Once we can actually construct and send accept_channel messages, it diff --git a/smite/src/channel_state.rs b/smite/src/channel_state.rs index 3d5d5cbc..90253e9f 100644 --- a/smite/src/channel_state.rs +++ b/smite/src/channel_state.rs @@ -28,6 +28,10 @@ pub struct ChannelState { /// revealed by `channel_ready` and then each `revoke_and_ack`. `None` until /// known. pub acceptor_next_per_commitment_point: Option, + /// The `second_per_commitment_point` from the first received `channel_ready`, + /// recorded so a resend can be checked to carry the same point. `None` + /// until a `channel_ready` is received. + pub received_channel_ready_point: Option, /// Whether the on-chain output at the advertised funding outpoint matches /// the negotiated funding script and amount. pub is_funding_outpoint_valid: bool, @@ -64,6 +68,7 @@ impl ChannelState { commitments, opener_next_per_commitment_point: None, acceptor_next_per_commitment_point: None, + received_channel_ready_point: None, is_funding_outpoint_valid, was_funding_mined_prematurely, sent_invalid_signature, diff --git a/smite/src/oracles.rs b/smite/src/oracles.rs index 937b2fc2..8a4e395f 100644 --- a/smite/src/oracles.rs +++ b/smite/src/oracles.rs @@ -3,10 +3,12 @@ //! Oracles evaluate conditions beyond simple crashes. mod accept_channel; +mod channel_ready; mod funding_signed; use super::violation::Violation; pub use accept_channel::{AcceptChannelContext, AcceptChannelOracle}; +pub use channel_ready::{ChannelReadyContext, ChannelReadyOracle}; pub use funding_signed::{FundingSignedContext, FundingSignedOracle}; /// `Oracle` evaluates a condition against some context diff --git a/smite/src/oracles/channel_ready.rs b/smite/src/oracles/channel_ready.rs new file mode 100644 index 00000000..45ba6f8a --- /dev/null +++ b/smite/src/oracles/channel_ready.rs @@ -0,0 +1,476 @@ +//! BOLT 2 `channel_ready` oracle, for the channel funding flow. + +use super::Oracle; +use crate::bolt::{ChannelId, ChannelReady, Features, ShortChannelId}; +use crate::channel_state::ChannelState; +use crate::pending_channel::KeyOrigin; +use crate::violation::Violation; + +use bitcoin::secp256k1::PublicKey; +use std::collections::HashMap; + +/// Context for `ChannelReadyOracle` +pub struct ChannelReadyContext<'a> { + /// The `channel_ready` received from the peer. + pub channel_ready: &'a ChannelReady, + /// The channel the `channel_ready` belongs to, identified by its + /// `channel_id`, or `None` if no channel was funded for it. + pub channel: Option<&'a ChannelState>, + /// Features negotiated between the target node and Smite. + pub negotiated_features: &'a Features, + /// Every pubkey revealed on the wire by either side, mapped to its first + /// origin. + pub revealed_pubkeys: &'a HashMap, + /// Every `short_channel_id` revealed on the wire, both advertised aliases + /// and derived real ones, mapped to the channel that owns it. + pub revealed_scids: &'a HashMap, + /// Number of confirmations the funding transaction had when the + /// `channel_ready` was received, or `0` if it is unconfirmed or unknown. + pub funding_confirmations: u32, +} + +/// Checks whether a received `channel_ready` satisfies the BOLT 2 channel +/// establishment requirements. +pub struct ChannelReadyOracle; + +impl Oracle> for ChannelReadyOracle { + fn evaluate(&self, context: &ChannelReadyContext<'_>) -> Result<(), Violation> { + // Check that the `channel_ready` answers a channel we funded. + let Some(channel) = context.channel else { + return Err(Violation::InvalidChannelReady( + context.channel_ready.channel_id, + "unknown channel_id: no channel was funded for it".to_string(), + )); + }; + + // Check that the on-chain funding output matches the negotiated funding + // script and amount. + if !channel.is_funding_outpoint_valid { + return Err(Violation::InvalidChannelReady( + context.channel_ready.channel_id, + "funding output does not match the negotiated funding script and amount" + .to_string(), + )); + } + + // Check that the funding transaction reached `minimum_depth`. + if context.funding_confirmations < channel.config.minimum_depth { + return Err(Violation::InvalidChannelReady( + context.channel_ready.channel_id, + format!( + "channel_ready received with {} funding confirmation(s), below minimum_depth {}", + context.funding_confirmations, channel.config.minimum_depth, + ), + )); + } + + // Check that an alias is set when `option_scid_alias` was negotiated. + if context + .negotiated_features + .supports_feature(Features::OPTION_SCID_ALIAS) + && context.channel_ready.tlvs.short_channel_id.is_none() + { + return Err(Violation::InvalidChannelReady( + context.channel_ready.channel_id, + "option_scid_alias negotiated but short_channel_id alias is missing".to_string(), + )); + } + + // Check that an advertised alias does not reuse a `short_channel_id` + // already revealed by another channel (its alias or real + // `short_channel_id`). Reuse is allowed only for a resend of this + // channel's own alias. + if let Some(alias) = context.channel_ready.tlvs.short_channel_id + && let Some(origin) = context.revealed_scids.get(&alias) + && *origin != context.channel_ready.channel_id + { + return Err(Violation::InvalidChannelReady( + context.channel_ready.channel_id, + format!( + "short_channel_id alias {alias} reuses the short_channel_id of channel {origin}" + ), + )); + } + + // Check that a resent `channel_ready` repeats the first one's + // `second_per_commitment_point`. + if let Some(first) = channel.received_channel_ready_point + && first != context.channel_ready.second_per_commitment_point + { + return Err(Violation::InvalidChannelReady( + context.channel_ready.channel_id, + format!( + "channel_ready resend changed second_per_commitment_point from {first} to {}", + context.channel_ready.second_per_commitment_point, + ), + )); + } + + // Check that the `second_per_commitment_point` is not reused from a + // previously revealed key, since its secret will eventually be revealed. + // Reuse is allowed for repeated `channel_ready` messages (e.g., with + // different aliases or after reconnection) if the key origin matches + // the channel and commitment number. + let known_origin = KeyOrigin::TargetPcp { + channel: context.channel_ready.channel_id, + commitment_number: 1, + }; + if let Some(origin) = context + .revealed_pubkeys + .get(&context.channel_ready.second_per_commitment_point) + && *origin != known_origin + { + return Err(Violation::InvalidChannelReady( + context.channel_ready.channel_id, + format!( + "second_per_commitment_point {} reuses {origin}", + context.channel_ready.second_per_commitment_point, + ), + )); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bolt::{ChannelId, ChannelReadyTlvs, ShortChannelId}; + use crate::channel_tx::{ChannelConfig, ChannelPartyConfig, HolderIdentity, Side}; + use bitcoin::OutPoint; + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + /// Number of confirmations required to reach the minimum depth and expect + /// a `channel_ready` message from the target. + const CONFIRMED_DEPTH: u32 = 6; + + fn secret_key(seed: u8) -> SecretKey { + SecretKey::from_slice(&[seed; 32]).expect("valid secret key") + } + + fn pubkey(seed: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &secret_key(seed)) + } + + /// Valid `channel_ready` message for testing. + fn channel_ready() -> ChannelReady { + ChannelReady { + channel_id: ChannelId::new([1u8; 32]), + second_per_commitment_point: pubkey(2), + tlvs: ChannelReadyTlvs::default(), + } + } + + /// Funded channel state for testing. + fn channel_state() -> ChannelState { + let pkey1 = pubkey(1); + let pkey2 = pubkey(2); + let party = |key| ChannelPartyConfig { + funding_pubkey: key, + payment_basepoint: key, + revocation_basepoint: key, + delayed_payment_basepoint: key, + dust_limit_satoshis: 546, + to_self_delay: 144, + }; + + let config = ChannelConfig { + funding_outpoint: OutPoint::null(), + funding_satoshis: 10_000_000, + channel_type: Features::from_bits(&[Features::OPTION_STATIC_REMOTEKEY]), + opener: party(pkey1), + acceptor: party(pkey2), + minimum_depth: CONFIRMED_DEPTH, + }; + let commitments = config + .new_initial_commitments(3_000_000_000, 15_000, pkey1, pkey2) + .expect("valid initial commitments"); + let holder = HolderIdentity { + side: Side::Opener, + funding_privkey: secret_key(1), + }; + + ChannelState::new(config, holder, commitments, true, false, false) + } + + #[track_caller] + fn assert_pass( + channel_ready: &ChannelReady, + channel: Option<&ChannelState>, + negotiated_features: &Features, + revealed_pubkeys: &HashMap, + revealed_scids: &HashMap, + funding_confirmations: u32, + ) { + if let Err(err) = ChannelReadyOracle.evaluate(&ChannelReadyContext { + channel_ready, + channel, + negotiated_features, + revealed_pubkeys, + revealed_scids, + funding_confirmations, + }) { + panic!("expected pass, got: {err}"); + } + } + + #[track_caller] + fn assert_fail( + channel_ready: &ChannelReady, + channel: Option<&ChannelState>, + negotiated_features: &Features, + revealed_pubkeys: &HashMap, + revealed_scids: &HashMap, + funding_confirmations: u32, + expected: &str, + ) { + match ChannelReadyOracle.evaluate(&ChannelReadyContext { + channel_ready, + channel, + negotiated_features, + revealed_pubkeys, + revealed_scids, + funding_confirmations, + }) { + Err(Violation::InvalidChannelReady(chan_id, reason)) => { + assert_eq!(channel_ready.channel_id, chan_id); + assert!( + reason.contains(expected), + "unexpected failure reason: {reason}" + ); + } + _ => panic!("expected failure: {expected}"), + } + } + + #[test] + fn conforming_channel_ready_passes() { + assert_pass( + &channel_ready(), + Some(&channel_state()), + &Features::new(), + &HashMap::new(), + &HashMap::new(), + CONFIRMED_DEPTH, + ); + } + + #[test] + fn unknown_channel_id_fails() { + assert_fail( + &channel_ready(), + None, + &Features::new(), + &HashMap::new(), + &HashMap::new(), + CONFIRMED_DEPTH, + "unknown channel_id: no channel was funded for it", + ); + } + + #[test] + fn channel_ready_with_invalid_funding_outpoint_fails() { + let mut channel = channel_state(); + channel.is_funding_outpoint_valid = false; + + assert_fail( + &channel_ready(), + Some(&channel), + &Features::new(), + &HashMap::new(), + &HashMap::new(), + CONFIRMED_DEPTH, + "funding output does not match the negotiated funding script and amount", + ); + } + + #[test] + fn channel_ready_below_minimum_depth_fails() { + assert_fail( + &channel_ready(), + Some(&channel_state()), + &Features::new(), + &HashMap::new(), + &HashMap::new(), + CONFIRMED_DEPTH - 1, + "channel_ready received with 5 funding confirmation(s), below minimum_depth 6", + ); + } + + #[test] + fn channel_ready_exactly_at_minimum_depth_passes() { + assert_pass( + &channel_ready(), + Some(&channel_state()), + &Features::new(), + &HashMap::new(), + &HashMap::new(), + CONFIRMED_DEPTH, + ); + } + + #[test] + fn conforming_option_scid_alias_with_an_alias_passes() { + let negotiated_features = Features::from_bits(&[Features::OPTION_SCID_ALIAS]); + let mut cr = channel_ready(); + cr.tlvs.short_channel_id = Some(ShortChannelId::new(800_000, 1, 0)); + + assert_pass( + &cr, + Some(&channel_state()), + &negotiated_features, + &HashMap::new(), + &HashMap::new(), + CONFIRMED_DEPTH, + ); + } + + #[test] + fn alias_without_option_scid_alias_passes() { + let mut cr = channel_ready(); + cr.tlvs.short_channel_id = Some(ShortChannelId::new(800_000, 1, 0)); + + assert_pass( + &cr, + Some(&channel_state()), + &Features::new(), + &HashMap::new(), + &HashMap::new(), + CONFIRMED_DEPTH, + ); + } + + #[test] + fn option_scid_alias_without_an_alias_fails() { + let negotiated_features = Features::from_bits(&[Features::OPTION_SCID_ALIAS]); + + assert_fail( + &channel_ready(), + Some(&channel_state()), + &negotiated_features, + &HashMap::new(), + &HashMap::new(), + CONFIRMED_DEPTH, + "option_scid_alias negotiated but short_channel_id alias is missing", + ); + } + + #[test] + fn channel_ready_alias_reuses_another_channels_scid_fails() { + let alias = ShortChannelId::new(800_000, 1, 0); + let origin = ChannelId::new([2u8; 32]); + let mut cr = channel_ready(); + cr.tlvs.short_channel_id = Some(alias); + let revealed_scids = HashMap::from([(alias, origin)]); + + assert_fail( + &cr, + Some(&channel_state()), + &Features::new(), + &HashMap::new(), + &revealed_scids, + CONFIRMED_DEPTH, + &format!( + "short_channel_id alias {alias} reuses the short_channel_id of channel {origin}" + ), + ); + } + + #[test] + fn channel_ready_alias_resend_for_same_channel_passes() { + let alias = ShortChannelId::new(800_000, 1, 0); + let mut cr = channel_ready(); + let origin = cr.channel_id; + cr.tlvs.short_channel_id = Some(alias); + let revealed_scids = HashMap::from([(alias, origin)]); + + assert_pass( + &cr, + Some(&channel_state()), + &Features::new(), + &HashMap::new(), + &revealed_scids, + CONFIRMED_DEPTH, + ); + } + + #[test] + fn channel_ready_resend_different_per_commitment_point_fails() { + let mut channel = channel_state(); + channel.received_channel_ready_point = Some(pubkey(3)); + let cr = channel_ready(); + + assert_fail( + &cr, + Some(&channel), + &Features::new(), + &HashMap::new(), + &HashMap::new(), + CONFIRMED_DEPTH, + &format!( + "channel_ready resend changed second_per_commitment_point from {} to {}", + pubkey(3), + cr.second_per_commitment_point, + ), + ); + } + + #[test] + fn channel_ready_resend_repeats_per_commitment_point_passes() { + let cr = channel_ready(); + let mut channel = channel_state(); + channel.received_channel_ready_point = Some(cr.second_per_commitment_point); + + assert_pass( + &cr, + Some(&channel), + &Features::new(), + &HashMap::new(), + &HashMap::new(), + CONFIRMED_DEPTH, + ); + } + + #[test] + fn channel_ready_reuses_per_commitment_point_from_previously_revealed_key_fails() { + let cr = channel_ready(); + let origin = KeyOrigin::TargetPcp { + channel: ChannelId::new([2u8; 32]), + commitment_number: 1, + }; + let revealed_pubkeys = HashMap::from([(cr.second_per_commitment_point, origin)]); + + assert_fail( + &cr, + Some(&channel_state()), + &Features::new(), + &revealed_pubkeys, + &HashMap::new(), + CONFIRMED_DEPTH, + &format!( + "second_per_commitment_point {} reuses {origin}", + cr.second_per_commitment_point, + ), + ); + } + + #[test] + fn channel_ready_resends_per_commitment_point_for_same_channel_passes() { + let cr = channel_ready(); + let origin = KeyOrigin::TargetPcp { + channel: cr.channel_id, + commitment_number: 1, + }; + let revealed_pubkeys = HashMap::from([(cr.second_per_commitment_point, origin)]); + + assert_pass( + &cr, + Some(&channel_state()), + &Features::new(), + &revealed_pubkeys, + &HashMap::new(), + CONFIRMED_DEPTH, + ); + } +} diff --git a/smite/src/violation.rs b/smite/src/violation.rs index a0987795..5e45eb03 100644 --- a/smite/src/violation.rs +++ b/smite/src/violation.rs @@ -47,8 +47,17 @@ pub enum Violation { #[error("invalid funding_signed for channel_id {0}: {1}")] InvalidFundingSigned(ChannelId, String), - /// The target sent a `channel_ready` for a `channel_id` we never opened, - /// i.e. one for which no state was ever established. - #[error("unknown channel: no tracked state for channel_id {0}")] - UnknownChannel(ChannelId), + /// The target's `channel_ready` broke a BOLT 2 requirement, as judged by + /// [`crate::oracles::ChannelReadyOracle`]. The reason names the breached + /// requirement, one of: + /// - it names a `channel_id` we have not funded, + /// - its funding output does not match the negotiated funding script and + /// amount, + /// - it arrives before the funding transaction reached `minimum_depth`, + /// - it omits the `short_channel_id` alias `option_scid_alias` requires, + /// - its alias reuses a `short_channel_id` revealed by another channel, + /// - it resends with a different `second_per_commitment_point`, or + /// - it reuses a per-commitment point from a previously revealed key. + #[error("invalid channel_ready for channel_id {0}: {1}")] + InvalidChannelReady(ChannelId, String), }