Skip to content

Improve privacy for Blinded Message Paths using Dummy Hops #3726

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lightning-background-processor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1127,7 +1127,7 @@ mod tests {
use lightning::routing::gossip::{NetworkGraph, P2PGossipSync};
use lightning::routing::router::{CandidateRouteHop, DefaultRouter, Path, RouteHop};
use lightning::routing::scoring::{ChannelUsage, LockableScore, ScoreLookUp, ScoreUpdate};
use lightning::sign::{ChangeDestinationSource, InMemorySigner, KeysManager};
use lightning::sign::{ChangeDestinationSource, InMemorySigner, KeysManager, NodeSigner};
use lightning::types::features::{ChannelFeatures, NodeFeatures};
use lightning::types::payment::PaymentHash;
use lightning::util::config::UserConfig;
Expand Down Expand Up @@ -1603,6 +1603,7 @@ mod tests {
let msg_router = Arc::new(DefaultMessageRouter::new(
network_graph.clone(),
Arc::clone(&keys_manager),
keys_manager.get_inbound_payment_key(),
));
let chain_source = Arc::new(test_utils::TestChainSource::new(Network::Bitcoin));
let kv_store =
Expand Down
19 changes: 16 additions & 3 deletions lightning-dns-resolver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,14 @@ mod test {
secp_ctx: &Secp256k1<T>,
) -> Result<Vec<BlindedMessagePath>, ()> {
let keys = KeysManager::new(&[0; 32], 42, 43);
Ok(vec![BlindedMessagePath::one_hop(recipient, context, &keys, secp_ctx).unwrap()])
Ok(vec![BlindedMessagePath::one_hop(
recipient,
context,
keys.get_inbound_payment_key(),
&keys,
secp_ctx,
)
.unwrap()])
}
}
impl Deref for DirectlyConnectedRouter {
Expand Down Expand Up @@ -334,8 +341,14 @@ mod test {
let (msg, context) =
payer.resolver.resolve_name(payment_id, name.clone(), &*payer_keys).unwrap();
let query_context = MessageContext::DNSResolver(context);
let reply_path =
BlindedMessagePath::one_hop(payer_id, query_context, &*payer_keys, &secp_ctx).unwrap();
let reply_path = BlindedMessagePath::one_hop(
payer_id,
query_context,
payer_keys.get_inbound_payment_key(),
&*payer_keys,
&secp_ctx,
)
.unwrap();
payer.pending_messages.lock().unwrap().push((
DNSResolverMessage::DNSSECQuery(msg),
MessageSendInstructions::WithSpecifiedReplyPath {
Expand Down
9 changes: 6 additions & 3 deletions lightning-liquidity/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
#![allow(unused_macros)]

use lightning::chain::Filter;
use lightning::sign::EntropySource;
use lightning::sign::{EntropySource, NodeSigner};

use bitcoin::blockdata::constants::{genesis_block, ChainHash};
use bitcoin::blockdata::transaction::Transaction;
Expand Down Expand Up @@ -418,8 +418,11 @@ pub(crate) fn create_liquidity_node(
scorer.clone(),
Default::default(),
));
let msg_router =
Arc::new(DefaultMessageRouter::new(Arc::clone(&network_graph), Arc::clone(&keys_manager)));
let msg_router = Arc::new(DefaultMessageRouter::new(
Arc::clone(&network_graph),
Arc::clone(&keys_manager),
keys_manager.get_inbound_payment_key(),
));
let chain_source = Arc::new(test_utils::TestChainSource::new(Network::Bitcoin));
let kv_store =
Arc::new(FilesystemStore::new(format!("{}_persister_{}", &persist_dir, i).into()));
Expand Down
104 changes: 93 additions & 11 deletions lightning/src/blinded_path/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey};

use crate::offers::signer;
#[allow(unused_imports)]
use crate::prelude::*;

Expand All @@ -19,9 +20,9 @@ use crate::blinded_path::{BlindedHop, BlindedPath, Direction, IntroductionNode,
use crate::crypto::streams::ChaChaPolyReadAdapter;
use crate::io;
use crate::io::Cursor;
use crate::ln::channelmanager::PaymentId;
use crate::ln::channelmanager::{PaymentId, Verification};
use crate::ln::msgs::DecodeError;
use crate::ln::onion_utils;
use crate::ln::{inbound_payment, onion_utils};
use crate::offers::nonce::Nonce;
use crate::onion_message::packet::ControlTlvs;
use crate::routing::gossip::{NodeId, ReadOnlyNetworkGraph};
Expand Down Expand Up @@ -55,23 +56,51 @@ impl Readable for BlindedMessagePath {
impl BlindedMessagePath {
/// Create a one-hop blinded path for a message.
pub fn one_hop<ES: Deref, T: secp256k1::Signing + secp256k1::Verification>(
recipient_node_id: PublicKey, context: MessageContext, entropy_source: ES,
secp_ctx: &Secp256k1<T>,
recipient_node_id: PublicKey, context: MessageContext,
expanded_key: inbound_payment::ExpandedKey, entropy_source: ES, secp_ctx: &Secp256k1<T>,
) -> Result<Self, ()>
where
ES::Target: EntropySource,
{
Self::new(&[], recipient_node_id, context, entropy_source, secp_ctx)
Self::new(&[], recipient_node_id, context, entropy_source, expanded_key, secp_ctx)
}

/// Create a path for an onion message, to be forwarded along `node_pks`. The last node
/// pubkey in `node_pks` will be the destination node.
///
/// Errors if no hops are provided or if `node_pk`(s) are invalid.
// TODO: make all payloads the same size with padding + add dummy hops
pub fn new<ES: Deref, T: secp256k1::Signing + secp256k1::Verification>(
intermediate_nodes: &[MessageForwardNode], recipient_node_id: PublicKey,
context: MessageContext, entropy_source: ES, secp_ctx: &Secp256k1<T>,
context: MessageContext, entropy_source: ES, expanded_key: inbound_payment::ExpandedKey,
secp_ctx: &Secp256k1<T>,
) -> Result<Self, ()>
where
ES::Target: EntropySource,
{
BlindedMessagePath::new_with_dummy_hops(
intermediate_nodes,
0,
recipient_node_id,
context,
entropy_source,
expanded_key,
secp_ctx,
)
}

/// Create a path for an onion message, to be forwarded along `node_pks`.
///
/// Additionally allows appending a number of dummy hops before the final hop,
/// increasing the total path length and enhancing privacy by obscuring the true
/// distance between sender and recipient.
///
/// The last node pubkey in `node_pks` will be the destination node.
///
/// Errors if no hops are provided or if `node_pk`(s) are invalid.
pub fn new_with_dummy_hops<ES: Deref, T: secp256k1::Signing + secp256k1::Verification>(
intermediate_nodes: &[MessageForwardNode], dummy_hops_count: u8,
recipient_node_id: PublicKey, context: MessageContext, entropy_source: ES,
expanded_key: inbound_payment::ExpandedKey, secp_ctx: &Secp256k1<T>,
) -> Result<Self, ()>
where
ES::Target: EntropySource,
Expand All @@ -88,9 +117,12 @@ impl BlindedMessagePath {
blinding_point: PublicKey::from_secret_key(secp_ctx, &blinding_secret),
blinded_hops: blinded_hops(
secp_ctx,
entropy_source,
expanded_key,
intermediate_nodes,
recipient_node_id,
context,
dummy_hops_count,
&blinding_secret,
)
.map_err(|_| ())?,
Expand Down Expand Up @@ -258,6 +290,45 @@ pub(crate) struct ForwardTlvs {
pub(crate) next_blinding_override: Option<PublicKey>,
}

pub(crate) struct UnauthenticatedDummyTlvs {}

impl Writeable for UnauthenticatedDummyTlvs {
fn write<W: Writer>(&self, _writer: &mut W) -> Result<(), io::Error> {
Ok(())
}
}

impl Verification for UnauthenticatedDummyTlvs {
/// Constructs an HMAC to include in [`OffersContext`] for the data along with the given
/// [`Nonce`].
fn hmac_data(&self, nonce: Nonce, expanded_key: &inbound_payment::ExpandedKey) -> Hmac<Sha256> {
signer::hmac_for_dummy_tlvs(self, nonce, expanded_key)
}

/// Authenticates the data using an HMAC and a [`Nonce`] taken from an [`OffersContext`].
fn verify_data(
&self, hmac: Hmac<Sha256>, nonce: Nonce, expanded_key: &inbound_payment::ExpandedKey,
) -> Result<(), ()> {
signer::verify_dummy_tlvs(self, hmac, nonce, expanded_key)
}
}

pub(crate) struct DummyTlvs {
pub(crate) dummy_tlvs: UnauthenticatedDummyTlvs,
/// An HMAC of `tlvs` along with a nonce used to construct it.
pub(crate) authentication: (Hmac<Sha256>, Nonce),
}

impl Writeable for DummyTlvs {
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
encode_tlv_stream!(writer, {
(65539, self.authentication, required),
});

Ok(())
}
}

/// Similar to [`ForwardTlvs`], but these TLVs are for the final node.
pub(crate) struct ReceiveTlvs {
/// If `context` is `Some`, it is used to identify the blinded path that this onion message is
Expand Down Expand Up @@ -505,13 +576,18 @@ impl_writeable_tlv_based!(DNSResolverContext, {
pub(crate) const MESSAGE_PADDING_ROUND_OFF: usize = 100;

/// Construct blinded onion message hops for the given `intermediate_nodes` and `recipient_node_id`.
pub(super) fn blinded_hops<T: secp256k1::Signing + secp256k1::Verification>(
secp_ctx: &Secp256k1<T>, intermediate_nodes: &[MessageForwardNode],
recipient_node_id: PublicKey, context: MessageContext, session_priv: &SecretKey,
) -> Result<Vec<BlindedHop>, secp256k1::Error> {
pub(super) fn blinded_hops<ES: Deref, T: secp256k1::Signing + secp256k1::Verification>(
secp_ctx: &Secp256k1<T>, entropy_source: ES, expanded_key: inbound_payment::ExpandedKey,
intermediate_nodes: &[MessageForwardNode], recipient_node_id: PublicKey,
context: MessageContext, dummy_hops_count: u8, session_priv: &SecretKey,
) -> Result<Vec<BlindedHop>, secp256k1::Error>
where
ES::Target: EntropySource,
{
let pks = intermediate_nodes
.iter()
.map(|node| node.node_id)
.chain((0..dummy_hops_count).map(|_| recipient_node_id))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked up the bolt spec and read

"MAY add additional "dummy" hops at the end of the path (which it will ignore on receipt) to obscure the path length."

What does ignore mean exactly? It seems in the next commit that it means to keep peeling? Using the recipient node id for all the dummy hops isn't really described in the bolt I think. Maybe mistaking.

Also a mention of padding is made:

"The padding field can be used to ensure that all encrypted_recipient_data have the same length. It's particularly useful when adding dummy hops at the end of a blinded route, to prevent the sender from figuring out which node is the final recipient"

Not sure if that is done now too?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does “ignore” mean exactly? It seems in the next commit that it means to keep peeling? Using the recipient node id for all the dummy hops isn't really described in the bolt I think. Maybe mistaking.

The thinking behind this approach was that if dummy hops were added after the ReceiveTlvs, it could open up timing-based attacks—where an attacker might estimate the position of the actual recipient based on how quickly a response is returned.

To avoid that, I added the dummy hops just before the final node. This way, even after receiving a dummy hop (with ForwardTlvs directed to self), the node still has to keep peeling until it reaches the actual ReceiveTlvs. This helps make response timing more uniform and avoids leaking information about path length.

Also a mention of padding is made

Yes! In PR #3177, we added support for padding in both BlindedMessagePaths and BlindedPaymentPaths, ensuring all payloads are a multiple of PADDING_ROUND_OFF.

Since the MESSAGE_PADDING_ROUND_OFF buffer is large enough, every payload—whether it's a ForwardTlvs, dummy hop, or ReceiveTlvs—ends up with the same total length. This helps prevent the sender from inferring the number of hops based on packet size.

I've also updated the padding tests to use new_with_dummy_hops, so we make sure even dummy hops are padded the same way as real ones.

Thanks so much again for the super helpful feedback!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid that, I added the dummy hops just before the final node.

If this is strictly better, it could be worth a PR to the bolt spec? At the minimum it might get you some feedback on this line of thinking.

I am not sure if the timing attack is avoided though, and worth the extra complexity. Peeling seems to be so much faster than an actual hop with network latency etc. Some random delay might be more effective?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code also still works for blinded paths where dummy hops are added after the ReceiveTlvs right? Just making sure.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code also still works for blinded paths where dummy hops are added after the ReceiveTlvs right? Just making sure.

There shouldn't be a need to support that because we only support receiving to blinded paths that we create.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timing attack does seem like a potential issue though. Not sure how to address that without adding some kind of ProcessPendingHtlcsForwardable event for onion messages, which seems like overkill. I think we can maybe document it on the issue and push to follow-up? @TheBlueMatt do you have any thoughts on how to simulate a fake onion message forward when processing dummy hops?

.chain(core::iter::once(recipient_node_id));
let is_compact = intermediate_nodes.iter().any(|node| node.short_channel_id.is_some());

Expand All @@ -526,6 +602,12 @@ pub(super) fn blinded_hops<T: secp256k1::Signing + secp256k1::Verification>(
.map(|next_hop| {
ControlTlvs::Forward(ForwardTlvs { next_hop, next_blinding_override: None })
})
.chain((0..dummy_hops_count).map(|_| {
let dummy_tlvs = UnauthenticatedDummyTlvs {};
let nonce = Nonce::from_entropy_source(&*entropy_source);
let hmac = dummy_tlvs.hmac_data(nonce, &expanded_key);
ControlTlvs::Dummy(DummyTlvs { dummy_tlvs, authentication: (hmac, nonce) })
}))
.chain(core::iter::once(ControlTlvs::Receive(ReceiveTlvs { context: Some(context) })));

if is_compact {
Expand Down
5 changes: 1 addition & 4 deletions lightning/src/blinded_path/payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,7 @@ impl UnauthenticatedReceiveTlvs {
/// Creates an authenticated [`ReceiveTlvs`], which includes an HMAC and the provide [`Nonce`]
/// that can be use later to verify it authenticity.
pub fn authenticate(self, nonce: Nonce, expanded_key: &ExpandedKey) -> ReceiveTlvs {
ReceiveTlvs {
authentication: (self.hmac_for_offer_payment(nonce, expanded_key), nonce),
tlvs: self,
}
ReceiveTlvs { authentication: (self.hmac_data(nonce, expanded_key), nonce), tlvs: self }
}
}

Expand Down
30 changes: 15 additions & 15 deletions lightning/src/ln/channelmanager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,42 +475,42 @@ impl Ord for ClaimableHTLC {
pub trait Verification {
/// Constructs an HMAC to include in [`OffersContext`] for the data along with the given
/// [`Nonce`].
fn hmac_for_offer_payment(
fn hmac_data(
&self, nonce: Nonce, expanded_key: &inbound_payment::ExpandedKey,
) -> Hmac<Sha256>;

/// Authenticates the data using an HMAC and a [`Nonce`] taken from an [`OffersContext`].
fn verify_for_offer_payment(
fn verify_data(
&self, hmac: Hmac<Sha256>, nonce: Nonce, expanded_key: &inbound_payment::ExpandedKey,
) -> Result<(), ()>;
}

impl Verification for PaymentHash {
/// Constructs an HMAC to include in [`OffersContext::InboundPayment`] for the payment hash
/// along with the given [`Nonce`].
fn hmac_for_offer_payment(
fn hmac_data(
&self, nonce: Nonce, expanded_key: &inbound_payment::ExpandedKey,
) -> Hmac<Sha256> {
signer::hmac_for_payment_hash(*self, nonce, expanded_key)
}

/// Authenticates the payment id using an HMAC and a [`Nonce`] taken from an
/// [`OffersContext::InboundPayment`].
fn verify_for_offer_payment(
fn verify_data(
&self, hmac: Hmac<Sha256>, nonce: Nonce, expanded_key: &inbound_payment::ExpandedKey,
) -> Result<(), ()> {
signer::verify_payment_hash(*self, hmac, nonce, expanded_key)
}
}

impl Verification for UnauthenticatedReceiveTlvs {
fn hmac_for_offer_payment(
fn hmac_data(
&self, nonce: Nonce, expanded_key: &inbound_payment::ExpandedKey,
) -> Hmac<Sha256> {
signer::hmac_for_payment_tlvs(self, nonce, expanded_key)
}

fn verify_for_offer_payment(
fn verify_data(
&self, hmac: Hmac<Sha256>, nonce: Nonce, expanded_key: &inbound_payment::ExpandedKey,
) -> Result<(), ()> {
signer::verify_payment_tlvs(self, hmac, nonce, expanded_key)
Expand Down Expand Up @@ -550,15 +550,15 @@ impl PaymentId {
impl Verification for PaymentId {
/// Constructs an HMAC to include in [`OffersContext::OutboundPayment`] for the payment id
/// along with the given [`Nonce`].
fn hmac_for_offer_payment(
fn hmac_data(
&self, nonce: Nonce, expanded_key: &inbound_payment::ExpandedKey,
) -> Hmac<Sha256> {
signer::hmac_for_offer_payment_id(*self, nonce, expanded_key)
}

/// Authenticates the payment id using an HMAC and a [`Nonce`] taken from an
/// [`OffersContext::OutboundPayment`].
fn verify_for_offer_payment(
fn verify_data(
&self, hmac: Hmac<Sha256>, nonce: Nonce, expanded_key: &inbound_payment::ExpandedKey,
) -> Result<(), ()> {
signer::verify_offer_payment_id(*self, hmac, nonce, expanded_key)
Expand Down Expand Up @@ -10535,7 +10535,7 @@ where
};
let invoice_request = builder.build_and_sign()?;

let hmac = payment_id.hmac_for_offer_payment(nonce, expanded_key);
let hmac = payment_id.hmac_data(nonce, expanded_key);
let context = MessageContext::Offers(
OffersContext::OutboundPayment { payment_id, nonce, hmac: Some(hmac) }
);
Expand Down Expand Up @@ -10639,7 +10639,7 @@ where
let invoice = builder.allow_mpp().build_and_sign(secp_ctx)?;

let nonce = Nonce::from_entropy_source(entropy);
let hmac = payment_hash.hmac_for_offer_payment(nonce, expanded_key);
let hmac = payment_hash.hmac_data(nonce, expanded_key);
let context = MessageContext::Offers(OffersContext::InboundPayment {
payment_hash: invoice.payment_hash(), nonce, hmac
});
Expand Down Expand Up @@ -12419,7 +12419,7 @@ where
.release_invoice_requests_awaiting_invoice()
{
let RetryableInvoiceRequest { invoice_request, nonce, .. } = retryable_invoice_request;
let hmac = payment_id.hmac_for_offer_payment(nonce, &self.inbound_payment_key);
let hmac = payment_id.hmac_data(nonce, &self.inbound_payment_key);
let context = MessageContext::Offers(OffersContext::OutboundPayment {
payment_id,
nonce,
Expand Down Expand Up @@ -12602,7 +12602,7 @@ where
match response {
Ok(invoice) => {
let nonce = Nonce::from_entropy_source(&*self.entropy_source);
let hmac = payment_hash.hmac_for_offer_payment(nonce, expanded_key);
let hmac = payment_hash.hmac_data(nonce, expanded_key);
let context = MessageContext::Offers(OffersContext::InboundPayment { payment_hash, nonce, hmac });
Some((OffersMessage::Invoice(invoice), responder.respond_with_reply_path(context)))
},
Expand Down Expand Up @@ -12639,7 +12639,7 @@ where
OffersMessage::StaticInvoice(invoice) => {
let payment_id = match context {
Some(OffersContext::OutboundPayment { payment_id, nonce, hmac: Some(hmac) }) => {
if payment_id.verify_for_offer_payment(hmac, nonce, expanded_key).is_err() {
if payment_id.verify_data(hmac, nonce, expanded_key).is_err() {
return None
}
payment_id
Expand All @@ -12652,7 +12652,7 @@ where
OffersMessage::InvoiceError(invoice_error) => {
let payment_hash = match context {
Some(OffersContext::InboundPayment { payment_hash, nonce, hmac }) => {
match payment_hash.verify_for_offer_payment(hmac, nonce, expanded_key) {
match payment_hash.verify_data(hmac, nonce, expanded_key) {
Ok(_) => Some(payment_hash),
Err(_) => None,
}
Expand All @@ -12665,7 +12665,7 @@ where

match context {
Some(OffersContext::OutboundPayment { payment_id, nonce, hmac: Some(hmac) }) => {
if let Ok(()) = payment_id.verify_for_offer_payment(hmac, nonce, expanded_key) {
if let Ok(()) = payment_id.verify_data(hmac, nonce, expanded_key) {
self.abandon_payment_with_reason(
payment_id, PaymentFailureReason::InvoiceRequestRejected,
);
Expand Down
Loading
Loading