diff --git a/Cargo.toml b/Cargo.toml index 11b4dc7..3c14386 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,14 +15,14 @@ rustdoc-args = ["--cfg", "docsrs"] [features] http = ["reqwest", "std", "serde", "serde_json"] -std = ["dnssec-prover"] +std = ["dnssec-prover", "getrandom"] default = ["std"] [dependencies] lightning-invoice = { version = "0.33", default-features = false } lightning = { version = "0.1", default-features = false, features = ["dnssec"] } bitcoin = { version = "0.32", default-features = false } -getrandom = { version = "0.3", default-features = false } +getrandom = { version = "0.3", default-features = false, optional = true } dnssec-prover = { version = "0.6", default-features = false, optional = true, features = ["validation", "std", "tokio"] } reqwest = { version = "0.11", default-features = false, optional = true, features = ["rustls-tls-webpki-roots", "json"] } serde = { version = "1.0", default-features = false, optional = true, features = ["derive"] } @@ -30,3 +30,5 @@ serde_json = { version = "1.0", default-features = false, optional = true, featu [dev-dependencies] tokio = { version = "1.0", default-features = false, features = ["rt", "macros"] } +lightning = { version = "0.1", features = ["std"] } +lightning-net-tokio = { version = "0.1", default-features = false } diff --git a/ci/ci-tests.sh b/ci/ci-tests.sh index a5f071b..06082ba 100755 --- a/ci/ci-tests.sh +++ b/ci/ci-tests.sh @@ -13,7 +13,10 @@ cargo check --verbose --color always cargo check --release --verbose --color always cargo test --no-default-features [ "$RUSTC_MINOR_VERSION" -gt 81 ] && cargo test --features http -cargo test --features std +# One std test syncs much of the lightning network graph, so --release is a must +# At least until https://github.com/lightningdevkit/rust-lightning/pull/3687 gets backported +export RUSTFLAGS="-C debug-assertions=on" +cargo test --features std --release [ "$RUSTC_MINOR_VERSION" -gt 81 ] && cargo doc --document-private-items --no-default-features [ "$RUSTC_MINOR_VERSION" -gt 81 ] && cargo doc --document-private-items --features http,std exit 0 diff --git a/fuzz/src/parse.rs b/fuzz/src/parse.rs index 6252e34..753ac9c 100644 --- a/fuzz/src/parse.rs +++ b/fuzz/src/parse.rs @@ -10,9 +10,9 @@ use bitcoin::Network; use bitcoin_payment_instructions::amount::Amount; -use bitcoin_payment_instructions::hrn::HumanReadableName; use bitcoin_payment_instructions::hrn_resolution::{ - DummyHrnResolver, HrnResolution, HrnResolutionFuture, HrnResolver, LNURLResolutionFuture, + DummyHrnResolver, HrnResolution, HrnResolutionFuture, HrnResolver, HumanReadableName, + LNURLResolutionFuture, }; use bitcoin_payment_instructions::PaymentInstructions; diff --git a/src/dns_resolver.rs b/src/dns_resolver.rs index 16ca973..1937274 100644 --- a/src/dns_resolver.rs +++ b/src/dns_resolver.rs @@ -9,9 +9,8 @@ use dnssec_prover::rr::Name; use crate::amount::Amount; use crate::dnssec_utils::resolve_proof; -use crate::hrn::HumanReadableName; use crate::hrn_resolution::{ - HrnResolution, HrnResolutionFuture, HrnResolver, LNURLResolutionFuture, + HrnResolution, HrnResolutionFuture, HrnResolver, HumanReadableName, LNURLResolutionFuture, }; /// An [`HrnResolver`] which resolves BIP 353 Human Readable Names to payment instructions using a @@ -59,7 +58,7 @@ mod tests { use crate::*; #[tokio::test] - async fn test_http_hrn_resolver() { + async fn test_dns_hrn_resolver() { let resolver = DNSHrnResolver(SocketAddr::from_str("8.8.8.8:53").unwrap()); let instructions = PaymentInstructions::parse( "send.some@satsto.me", diff --git a/src/hrn.rs b/src/hrn.rs deleted file mode 100644 index f103f15..0000000 --- a/src/hrn.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! A type for storing Human Readable Names (HRNs) which can be resolved using BIP 353 and the DNS -//! or LNURL-Pay and LN-Address. - -// Note that `REQUIRED_EXTRA_LEN` includes the (implicit) trailing `.` -const REQUIRED_EXTRA_LEN: usize = ".user._bitcoin-payment.".len() + 1; - -/// A struct containing the two parts of a BIP 353 Human Readable Name - the user and domain parts. -/// -/// The `user` and `domain` parts, together, cannot exceed 231 bytes in length, and both must be -/// non-empty. -/// -/// If you intend to handle non-ASCII `user` or `domain` parts, you must handle [Homograph Attacks] -/// and do punycode en-/de-coding yourself. This struc will always handle only plain ASCII `user` -/// and `domain` parts. -/// -/// This struct can also be used for LN-Address recipients. -/// -/// [Homograph Attacks]: https://en.wikipedia.org/wiki/IDN_homograph_attack -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub struct HumanReadableName { - contents: [u8; 255 - REQUIRED_EXTRA_LEN], - user_len: u8, - domain_len: u8, -} - -/// Check if the chars in `s` are allowed to be included in a hostname. -pub(crate) fn str_chars_allowed(s: &str) -> bool { - s.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') -} - -impl HumanReadableName { - /// Constructs a new [`HumanReadableName`] from the `user` and `domain` parts. See the - /// struct-level documentation for more on the requirements on each. - pub fn new(user: &str, mut domain: &str) -> Result { - // First normalize domain and remove the optional trailing `.` - if domain.ends_with('.') { - domain = &domain[..domain.len() - 1]; - } - if user.len() + domain.len() + REQUIRED_EXTRA_LEN > 255 { - return Err(()); - } - if user.is_empty() || domain.is_empty() { - return Err(()); - } - if !str_chars_allowed(user) || !str_chars_allowed(domain) { - return Err(()); - } - let mut contents = [0; 255 - REQUIRED_EXTRA_LEN]; - contents[..user.len()].copy_from_slice(user.as_bytes()); - contents[user.len()..user.len() + domain.len()].copy_from_slice(domain.as_bytes()); - Ok(HumanReadableName { - contents, - user_len: user.len() as u8, - domain_len: domain.len() as u8, - }) - } - - /// Constructs a new [`HumanReadableName`] from the standard encoding - `user`@`domain`. - /// - /// If `user` includes the standard BIP 353 ₿ prefix it is automatically removed as required by - /// BIP 353. - pub fn from_encoded(encoded: &str) -> Result { - if let Some((user, domain)) = encoded.strip_prefix('₿').unwrap_or(encoded).split_once('@') - { - Self::new(user, domain) - } else { - Err(()) - } - } - - /// Gets the `user` part of this Human Readable Name - pub fn user(&self) -> &str { - let bytes = &self.contents[..self.user_len as usize]; - core::str::from_utf8(bytes).expect("Checked in constructor") - } - - /// Gets the `domain` part of this Human Readable Name - pub fn domain(&self) -> &str { - let user_len = self.user_len as usize; - let bytes = &self.contents[user_len..user_len + self.domain_len as usize]; - core::str::from_utf8(bytes).expect("Checked in constructor") - } -} diff --git a/src/hrn_resolution.rs b/src/hrn_resolution.rs index 1d47a03..7852e33 100644 --- a/src/hrn_resolution.rs +++ b/src/hrn_resolution.rs @@ -7,10 +7,11 @@ //! associated types in this module. use crate::amount::Amount; -use crate::hrn::HumanReadableName; use lightning_invoice::Bolt11Invoice; +pub use lightning::onion_message::dns_resolution::HumanReadableName; + use core::future::Future; use core::pin::Pin; diff --git a/src/http_resolver.rs b/src/http_resolver.rs index ea02610..de7bbe6 100644 --- a/src/http_resolver.rs +++ b/src/http_resolver.rs @@ -17,9 +17,8 @@ use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescriptionRef}; use crate::amount::Amount; use crate::dnssec_utils::resolve_proof; -use crate::hrn::HumanReadableName; use crate::hrn_resolution::{ - HrnResolution, HrnResolutionFuture, HrnResolver, LNURLResolutionFuture, + HrnResolution, HrnResolutionFuture, HrnResolver, HumanReadableName, LNURLResolutionFuture, }; const DOH_ENDPOINT: &'static str = "https://dns.google/dns-query?dns="; diff --git a/src/lib.rs b/src/lib.rs index 04dcd07..6ef9834 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,17 +57,17 @@ pub mod dns_resolver; #[cfg(feature = "http")] pub mod http_resolver; +#[cfg(feature = "std")] // TODO: Drop once we upgrade to LDK 0.2 +pub mod onion_message_resolver; + pub mod amount; pub mod receive; pub mod hrn_resolution; -pub mod hrn; - use amount::Amount; -use hrn::HumanReadableName; -use hrn_resolution::{HrnResolution, HrnResolver}; +use hrn_resolution::{HrnResolution, HrnResolver, HumanReadableName}; /// A method which can be used to make a payment #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/src/onion_message_resolver.rs b/src/onion_message_resolver.rs new file mode 100644 index 0000000..136af21 --- /dev/null +++ b/src/onion_message_resolver.rs @@ -0,0 +1,416 @@ +//! A [`HrnResolver`] which uses lightning onion messages and DNSSEC proofs to request DNS +//! resolution directly from untrusted lightning nodes, providing privacy through onion routing. + +use std::boxed::Box; +use std::collections::HashMap; +use std::future::Future; +use std::ops::Deref; +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use std::task::{Context, Poll, Waker}; +use std::vec::Vec; + +use lightning::blinded_path::message::{DNSResolverContext, MessageContext}; +use lightning::ln::channelmanager::PaymentId; +use lightning::onion_message::dns_resolution::{ + DNSResolverMessage, DNSResolverMessageHandler, DNSSECProof, DNSSECQuery, OMNameResolver, +}; +use lightning::onion_message::messenger::{ + Destination, MessageSendInstructions, Responder, ResponseInstruction, +}; +use lightning::routing::gossip::NetworkGraph; +use lightning::sign::EntropySource; +use lightning::util::logger::Logger; + +use crate::hrn_resolution::{ + HrnResolution, HrnResolutionFuture, HrnResolver, HumanReadableName, LNURLResolutionFuture, +}; +use crate::Amount; + +struct OsRng; +impl EntropySource for OsRng { + fn get_secure_random_bytes(&self) -> [u8; 32] { + let mut res = [0; 32]; + getrandom::fill(&mut res).expect("Fetching system randomness should always succeed"); + res + } +} + +struct ChannelState { + waker: Option, + result: Option, +} + +struct ChannelSend(Arc>); + +impl ChannelSend { + fn complete(self, result: HrnResolution) { + let mut state = self.0.lock().unwrap(); + state.result = Some(result); + if let Some(waker) = state.waker.take() { + waker.wake(); + } + } + + fn receiver_alive(&self) -> bool { + Arc::strong_count(&self.0) > 1 + } +} + +struct ChannelRecv(Arc>); + +impl Future for ChannelRecv { + type Output = HrnResolution; + fn poll(self: Pin<&mut Self>, context: &mut Context) -> Poll { + let mut state = self.0.lock().unwrap(); + if let Some(res) = state.result.take() { + state.waker = None; + Poll::Ready(res) + } else { + state.waker = Some(context.waker().clone()); + Poll::Pending + } + } +} + +fn channel() -> (ChannelSend, ChannelRecv) { + let state = Arc::new(Mutex::new(ChannelState { waker: None, result: None })); + (ChannelSend(Arc::clone(&state)), ChannelRecv(state)) +} + +/// A [`HrnResolver`] which uses lightning onion messages and DNSSEC proofs to request DNS +/// resolution directly from untrusted lightning nodes, providing privacy through onion routing. +/// +/// This implements LDK's [`DNSResolverMessageHandler`], which it uses to send onion messages and +/// process response messages. +/// +/// Note that after a query begines, [`PeerManager::process_events`] should be called to ensure the +/// query message goes out in a timely manner. You can call [`Self::register_post_queue_action`] to +/// have this happen automatically. +/// +/// [`PeerManager::process_events`]: lightning::ln::peer_handler::PeerManager::process_events +pub struct LDKOnionMessageDNSSECHrnResolver>, L: Deref> +where + L::Target: Logger, +{ + network_graph: N, + resolver: OMNameResolver, + next_id: AtomicUsize, + pending_resolutions: Mutex>>, + message_queue: Mutex>, + pm_event_poker: RwLock>>, +} + +impl>, L: Deref> LDKOnionMessageDNSSECHrnResolver +where + L::Target: Logger, +{ + /// Constructs a new [`LDKOnionMessageDNSSECHrnResolver`]. + /// + /// See the struct-level documentation for more info. + pub fn new(network_graph: N) -> Self { + Self { + network_graph, + next_id: AtomicUsize::new(0), + // TODO: Swap for `new_without_expiry_validation` when we upgrade to LDK 0.2 + resolver: OMNameResolver::new(0, 0), + pending_resolutions: Mutex::new(HashMap::new()), + message_queue: Mutex::new(Vec::new()), + pm_event_poker: RwLock::new(None), + } + } + + /// Sets a callback which is called any time a new resolution begins and a message is available + /// to be sent. This should generally call [`PeerManager::process_events`]. + /// + /// [`PeerManager::process_events`]: lightning::ln::peer_handler::PeerManager::process_events + pub fn register_post_queue_action(&self, callback: Box) { + *self.pm_event_poker.write().unwrap() = Some(callback); + } + + fn init_resolve_hrn<'a>( + &'a self, hrn: &HumanReadableName, + ) -> Result { + #[cfg(feature = "std")] + { + use std::time::SystemTime; + let clock_err = + "DNSSEC validation relies on having a correct system clock. It is currently set before 1970."; + let now = + SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_err(|_| clock_err)?; + // Use `now / 60` as the block height to expire pending requests after 1-2 minutes. + self.resolver.new_best_block((now.as_secs() / 60) as u32, now.as_secs() as u32); + } + + let mut dns_resolvers = Vec::new(); + for (node_id, node) in self.network_graph.read_only().nodes().unordered_iter() { + if let Some(info) = &node.announcement_info { + // Sadly, 31 nodes currently squat on the DNS Resolver feature bit + // without speaking it. + // Its unclear why they're doing so, but none of them currently + // also have the onion messaging feature bit set, so here we check + // for both. + let supports_dns = info.features().supports_dns_resolution(); + let supports_om = info.features().supports_onion_messages(); + if supports_dns && supports_om { + if let Ok(pubkey) = node_id.as_pubkey() { + dns_resolvers.push(Destination::Node(pubkey)); + } + } + } + if dns_resolvers.len() > 5 { + break; + } + } + if dns_resolvers.is_empty() { + return Err( + "Failed to find any DNS resolving nodes, check your network graph is synced", + ); + } + + let counter = self.next_id.fetch_add(1, Ordering::Relaxed) as u64; + let mut payment_id = [0; 32]; + payment_id[..8].copy_from_slice(&counter.to_ne_bytes()); + let payment_id = PaymentId(payment_id); + + let err = "The provided HRN did not fit in a DNS request"; + // TODO: Once LDK 0.2 ships with a new context authentication method, we shouldn't need the + // RNG here and can stop depending on std. + let (query, dns_context) = + self.resolver.resolve_name(payment_id, hrn.clone(), &OsRng).map_err(|_| err)?; + let context = MessageContext::DNSResolver(dns_context); + + let (send, recv) = channel(); + { + let mut pending_resolutions = self.pending_resolutions.lock().unwrap(); + let senders = pending_resolutions.entry(hrn.clone()).or_insert_with(Vec::new); + senders.push((payment_id, send)); + + // If we're running in no-std, we won't expire lookups with the time updates above, so walk + // the pending resolution list and expire them here. + pending_resolutions.retain(|_name, resolutions| { + resolutions.retain(|(_payment_id, resolution)| { + let has_receiver = resolution.receiver_alive(); + if !has_receiver { + // TODO: Once LDK 0.2 ships, expire the pending resolution in the resolver: + // self.resolver.expire_pending_resolution(name, payment_id); + } + has_receiver + }); + !resolutions.is_empty() + }); + } + + { + let mut queue = self.message_queue.lock().unwrap(); + for destination in dns_resolvers { + let instructions = MessageSendInstructions::WithReplyPath { + destination, + context: context.clone(), + }; + queue.push((DNSResolverMessage::DNSSECQuery(query.clone()), instructions)); + } + } + + let callback = self.pm_event_poker.read().unwrap(); + if let Some(callback) = &*callback { + callback(); + } + + Ok(recv) + } +} + +impl>, L: Deref> DNSResolverMessageHandler + for LDKOnionMessageDNSSECHrnResolver +where + L::Target: Logger, +{ + fn handle_dnssec_query( + &self, _: DNSSECQuery, _: Option, + ) -> Option<(DNSResolverMessage, ResponseInstruction)> { + None + } + + fn handle_dnssec_proof(&self, msg: DNSSECProof, context: DNSResolverContext) { + let results = self.resolver.handle_dnssec_proof_for_uri(msg.clone(), context); + if let Some((resolved, res)) = results { + let mut pending_resolutions = self.pending_resolutions.lock().unwrap(); + for (name, _payment_id) in resolved { + if let Some(requests) = pending_resolutions.remove(&name) { + for (_id, send) in requests { + send.complete(HrnResolution::DNSSEC { + proof: Some(msg.proof.clone()), + result: res.clone(), + }); + } + } + } + } + } + + fn release_pending_messages(&self) -> Vec<(DNSResolverMessage, MessageSendInstructions)> { + std::mem::take(&mut self.message_queue.lock().unwrap()) + } +} + +impl> + Sync, L: Deref> HrnResolver + for LDKOnionMessageDNSSECHrnResolver +where + L::Target: Logger, +{ + fn resolve_hrn<'a>(&'a self, hrn: &'a HumanReadableName) -> HrnResolutionFuture<'a> { + match self.init_resolve_hrn(hrn) { + Err(e) => Box::pin(async move { Err(e) }), + Ok(recv) => Box::pin(async move { Ok(recv.await) }), + } + } + + fn resolve_lnurl<'a>(&'a self, _url: &'a str) -> HrnResolutionFuture<'a> { + let err = "DNS resolver does not support LNURL resolution"; + Box::pin(async move { Err(err) }) + } + + fn resolve_lnurl_to_invoice<'a>( + &'a self, _: String, _: Amount, _: [u8; 32], + ) -> LNURLResolutionFuture<'a> { + let err = "resolve_lnurl_to_invoice shouldn't be called when we don't resolve LNURL"; + debug_assert!(false, "{err}"); + Box::pin(async move { Err(err) }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::*; + + use std::net::ToSocketAddrs; + + use bitcoin::hex::FromHex; + use bitcoin::secp256k1::PublicKey; + + use lightning::blinded_path::NodeIdLookUp; + use lightning::ln::peer_handler::{ + ErroringMessageHandler, IgnoringMessageHandler, MessageHandler, PeerManager, + }; + use lightning::onion_message::messenger::{DefaultMessageRouter, OnionMessenger}; + use lightning::routing::gossip::{NodeId, P2PGossipSync}; + use lightning::routing::utxo::UtxoLookup; + use lightning::sign::KeysManager; + use lightning::util::logger::Record; + + struct TestLogger; + impl Logger for TestLogger { + fn log(&self, r: Record) { + eprintln!("{}", r.args); + } + } + + struct NoPeers; + impl NodeIdLookUp for NoPeers { + fn next_node_id(&self, _scid: u64) -> Option { + None + } + } + + #[tokio::test] + async fn test_dns_om_hrn_resolver() { + let graph = Arc::new(NetworkGraph::new(Network::Bitcoin, &TestLogger)); + let resolver = Arc::new(LDKOnionMessageDNSSECHrnResolver::new(Arc::clone(&graph))); + let signer = Arc::new(KeysManager::new(&OsRng.get_secure_random_bytes(), 0, 0)); + let message_router = Arc::new(DefaultMessageRouter::new(Arc::clone(&graph), &OsRng)); + let messenger = Arc::new(OnionMessenger::new( + &OsRng, + Arc::clone(&signer), + &TestLogger, + &NoPeers, + message_router, + &IgnoringMessageHandler {}, + &IgnoringMessageHandler {}, + Arc::clone(&resolver), + &IgnoringMessageHandler {}, + )); + let no_utxos = None::<&(dyn UtxoLookup + Sync + Send)>; + let handlers = MessageHandler { + chan_handler: Arc::new(ErroringMessageHandler::new()), + route_handler: Arc::new(P2PGossipSync::new(Arc::clone(&graph), no_utxos, &TestLogger)), + onion_message_handler: Arc::clone(&messenger), + custom_message_handler: &IgnoringMessageHandler {}, + }; + let rand = OsRng.get_secure_random_bytes(); + let peer_manager = + Arc::new(PeerManager::new(handlers, 0, &rand, &TestLogger, Arc::clone(&signer))); + + // Connect to a static LDK node which we know will do DNS resolutions for us. + let their_id_hex = "03db10aa09ff04d3568b0621750794063df401e6853c79a21a83e1a3f3b5bfb0c8"; + let their_id = PublicKey::from_slice(&Vec::::from_hex(their_id_hex).unwrap()).unwrap(); + let addr = "ldk-ln-node.bitcoin.ninja:9735".to_socket_addrs().unwrap().next().unwrap(); + let _ = lightning_net_tokio::connect_outbound(Arc::clone(&peer_manager), their_id, addr) + .await + .unwrap(); + + let pm_reference = Arc::clone(&peer_manager); + tokio::spawn(async move { + pm_reference.process_events(); + tokio::time::sleep(Duration::from_micros(10)).await; + }); + + let their_node_id = NodeId::from_pubkey(&their_id); + loop { + { + let graph = graph.read_only(); + let have_announcement = + graph.nodes().get(&their_node_id).map(|node| node.announcement_info.is_some()); + if have_announcement.unwrap_or(false) { + break; + } + } + tokio::time::sleep(Duration::from_millis(5)).await; + peer_manager.process_events(); + } + + let instructions = PaymentInstructions::parse( + "send.some@satsto.me", + bitcoin::Network::Bitcoin, + &*resolver, + true, + ) + .await + .unwrap(); + + let resolved = if let PaymentInstructions::ConfigurableAmount(instr) = instructions { + assert_eq!(instr.min_amt(), None); + assert_eq!(instr.max_amt(), None); + + assert_eq!(instr.pop_callback(), None); + assert!(instr.bip_353_dnssec_proof().is_some()); + + let hrn = instr.human_readable_name().as_ref().unwrap(); + assert_eq!(hrn.user(), "send.some"); + assert_eq!(hrn.domain(), "satsto.me"); + + instr.set_amount(Amount::from_sats(100_000).unwrap(), &*resolver).await.unwrap() + } else { + panic!(); + }; + + assert_eq!(resolved.pop_callback(), None); + assert!(resolved.bip_353_dnssec_proof().is_some()); + + let hrn = resolved.human_readable_name().as_ref().unwrap(); + assert_eq!(hrn.user(), "send.some"); + assert_eq!(hrn.domain(), "satsto.me"); + + for method in resolved.methods() { + match method { + PaymentMethod::LightningBolt11(_) => { + panic!("Should only have static payment instructions"); + }, + PaymentMethod::LightningBolt12(_) => {}, + PaymentMethod::OnChain { .. } => {}, + } + } + } +}