From 744b0f35ea2b2879c6c357eed07272d1f980832c Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sat, 10 Feb 2024 22:42:27 +0000 Subject: [PATCH 1/1] Initial checkin --- Cargo.toml | 17 ++ src/lib.rs | 480 +++++++++++++++++++++++++++++++++++++++++++++++++++++ test.sh | 5 + 3 files changed, 502 insertions(+) create mode 100644 Cargo.toml create mode 100644 src/lib.rs create mode 100755 test.sh diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..00560d8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "lightning-resolver" +version = "0.1.0" +edition = "2021" + +[features] +domain_resolver = ["lightning/std", "tokio/rt", "dnssec-prover/std"] +name_resolver = ["dnssec-prover/validation", "lightning/no-std", "hex-conservative/alloc"] + +[dependencies] +lightning = { version = "0.0.121", default-features = false } +dnssec-prover = { version = "0.3", default-features = false } +tokio = { version = "1.0", default-features = false, optional = true } +hex-conservative = { version = "0.1", default-features = false, optional = true } + +[dev-dependencies] +secp256k1 = { version = "0.27" } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4bc7996 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,480 @@ +//! + +#[deny(missing_docs)] + +#[cfg(feature = "domain_resolver")] +use std::net::SocketAddr; + +use std::sync::Mutex; // XXX: wont work without, should support no-std + +extern crate alloc; +extern crate core; + +use core::str::FromStr; + +#[cfg(feature = "domain_resolver")] +use alloc::sync::Arc; +#[cfg(any(feature = "name_resolver", feature = "domain_resolver"))] +use core::sync::atomic::{AtomicUsize, Ordering}; + +use dnssec_prover::rr::Name; + +#[cfg(feature = "domain_resolver")] +use dnssec_prover::query::build_txt_proof; + +use lightning::onion_message::packet::OnionMessageContents; +use lightning::util::ser::{Readable, Writeable, Writer}; +use lightning::ln::msgs::DecodeError; +use lightning::offers::offer::Offer; + +#[cfg(any(feature = "domain_resolver", feature = "name_resolver"))] +use lightning::onion_message::messenger::{CustomOnionMessageHandler, PendingOnionMessage}; + +#[cfg(feature = "name_resolver")] +use lightning::blinded_path::BlindedPath; +#[cfg(feature = "name_resolver")] +use lightning::onion_message::messenger::Destination; + +#[derive(Debug)] +pub enum ResolverMessages { + DNSSECQuery(DNSSECQuery), + DNSSECProof(DNSSECProof), + OfferRequest(OfferRequest), + OfferResponse(OfferResponse), +} + +#[derive(Debug)] +/// A Query for a proof of all TXT records at a given name. +pub struct DNSSECQuery(pub Name); +const DNSSEC_QUERY_TYPE: u64 = 65536; + +#[derive(Debug)] +pub struct DNSSECProof { + pub name: Name, + pub proof: Vec, +} +const DNSSEC_PROOF_TYPE: u64 = 65538; + +#[derive(Debug)] +pub struct OfferRequest { + pub user: String, + pub domain: String, +} +const OFFER_REQUEST_TYPE: u64 = 44; // XXX + +#[derive(Debug)] +pub struct OfferResponse { + pub user: String, + pub domain: String, + pub offer: Offer, +} +const OFFER_RESPONSE_TYPE: u64 = 45; // XXX + +impl Writeable for ResolverMessages { + fn write(&self, w: &mut W) -> Result<(), lightning::io::Error> { + match self { + Self::DNSSECQuery(DNSSECQuery(q)) => { + (q.as_str().len() as u8).write(w)?; + w.write_all(&q.as_str().as_bytes()) + }, + Self::DNSSECProof(DNSSECProof { name, proof }) => { + (name.as_str().len() as u8).write(w)?; + w.write_all(&name.as_str().as_bytes())?; + proof.write(w) + }, + Self::OfferRequest(OfferRequest { user, domain }) => { + (user.len() as u8).write(w)?; + w.write_all(&user.as_bytes())?; + (domain.len() as u8).write(w)?; + w.write_all(&domain.as_bytes()) + }, + Self::OfferResponse(OfferResponse { user, domain, offer }) => { + (user.len() as u8).write(w)?; + w.write_all(&user.as_bytes())?; + (domain.len() as u8).write(w)?; + w.write_all(&domain.as_bytes())?; + offer.to_string().write(w) + }, + } + } +} + +fn read_byte_len_ascii_string(buffer: &mut R) +-> Result { + let len: u8 = Readable::read(buffer)?; + let mut bytes = [0; 255]; + buffer.read_exact(&mut bytes[..len as usize])?; + if bytes[..len as usize].iter().any(|b| *b < 0x20 || *b > 0x7e) { + // If the bytes are not entirely in the printable ASCII range, fail + return Err(DecodeError::InvalidValue); + } + let s = String::from_utf8(bytes[..len as usize].to_vec()) + .map_err(|_| DecodeError::InvalidValue)?; + Ok(s) +} + +impl ResolverMessages { + pub fn read_message(message_type: u64, buffer: &mut R) + -> Result, DecodeError> { + match message_type { + DNSSEC_QUERY_TYPE => { + let s = read_byte_len_ascii_string(buffer)?; + let name = s.try_into().map_err(|_| DecodeError::InvalidValue)?; + Ok(Some(ResolverMessages::DNSSECQuery(DNSSECQuery(name)))) + }, + DNSSEC_PROOF_TYPE => { + let s = read_byte_len_ascii_string(buffer)?; + let name = s.try_into().map_err(|_| DecodeError::InvalidValue)?; + let proof = Readable::read(buffer)?; + Ok(Some(ResolverMessages::DNSSECProof(DNSSECProof { name, proof }))) + }, + OFFER_REQUEST_TYPE => { + let user = read_byte_len_ascii_string(buffer)?; + let domain = read_byte_len_ascii_string(buffer)?; + Ok(Some(ResolverMessages::OfferRequest(OfferRequest { user, domain }))) + }, + OFFER_RESPONSE_TYPE => { + let user = read_byte_len_ascii_string(buffer)?; + let domain = read_byte_len_ascii_string(buffer)?; + let offer_string: String = Readable::read(buffer)?; + if !offer_string.is_ascii() { return Err(DecodeError::InvalidValue); } + if let Ok(offer) = Offer::from_str(&offer_string) { + Ok(Some(ResolverMessages::OfferResponse(OfferResponse { user, domain, offer }))) + } else { + Err(DecodeError::InvalidValue) + } + }, + _ => Ok(None), + } + } +} + +impl OnionMessageContents for ResolverMessages { + fn tlv_type(&self) -> u64 { + match self { + ResolverMessages::DNSSECQuery(_) => DNSSEC_QUERY_TYPE, + ResolverMessages::DNSSECProof(_) => DNSSEC_PROOF_TYPE, + ResolverMessages::OfferRequest(_) => OFFER_REQUEST_TYPE, + ResolverMessages::OfferResponse(_) => OFFER_RESPONSE_TYPE, + } + } +} + +#[cfg(not(any(target_pointer_width = "32", target_pointer_width = "64")))] +const WE_REQUIRE_32_OR_64_BIT_USIZE: u8 = 424242; + +#[cfg(feature = "domain_resolver")] +pub struct OMDomainResolver { + state: Arc, +} + +#[cfg(feature = "domain_resolver")] +mod domain_resolver { + use super::*; + + const MAX_PENDING_RESPONSES: usize = 1024; + pub(super) struct OMResolverState { + resolver: SocketAddr, + pending_msgs: Mutex>>, + pending_query_count: AtomicUsize, + } + + impl OMDomainResolver { + pub fn new(resolver: SocketAddr) -> Self { + Self { state: Arc::new(OMResolverState { + resolver, + pending_msgs: Mutex::new(Vec::new()), + pending_query_count: AtomicUsize::new(0), + }) } + } + } + + impl CustomOnionMessageHandler for OMDomainResolver { + type CustomMessage = ResolverMessages; + + fn handle_custom_message(&self, msg: ResolverMessages) -> Option { + match msg { + ResolverMessages::DNSSECQuery(q) => { + if self.state.pending_query_count.fetch_add(1, Ordering::Relaxed) > MAX_PENDING_RESPONSES { + self.state.pending_query_count.fetch_sub(1, Ordering::Relaxed); + return None; + } + let us = Arc::clone(&self.state); + // TODO: Make this async after https://github.com/lightningdevkit/rust-lightning/issues/2882 + //tokio::spawn(async move { + //if let Ok(proof) = build_txt_proof_async(us.resolver, &q.0).await { + if let Ok((proof, _ttl)) = build_txt_proof(us.resolver, &q.0) { + let contents = ResolverMessages::DNSSECProof(DNSSECProof { + name: q.0, proof, + }); + us.pending_query_count.fetch_sub(1, Ordering::Relaxed); + return Some(contents); + /*let response = PendingOnionMessage { + contents, + destination: Destination::BlindedPath(reply_path), + reply_path: None, + }; + us.pending_msgs.lock().unwrap().push(response);*/ + } else { None } + //}); + }, + _ => None, + } + } + fn read_custom_message(&self, message_type: u64, buffer: &mut R) + -> Result, DecodeError> { + ResolverMessages::read_message(message_type, buffer) + } + fn release_pending_custom_messages(&self) -> Vec> { + let mut res = Vec::new(); + std::mem::swap(&mut res, &mut self.state.pending_msgs.lock().unwrap()); + res + } + } +} + +#[cfg(feature = "name_resolver")] +pub struct OMNameResolver { + resolver: Destination, + reply_path: BlindedPath, + pending_msgs: Mutex>>, + pending_resolves: Mutex)>)>>, + latest_block_time: AtomicUsize, + latest_block_height: AtomicUsize, +} + +#[cfg(feature = "name_resolver")] +mod name_resolver { + use super::*; + + use dnssec_prover::ser::parse_rr_stream; + use dnssec_prover::rr::RR; + use dnssec_prover::validation::verify_rr_stream; + + use hex_conservative::FromHex; + + impl OMNameResolver { + pub fn new(resolver: Destination, reply_path: BlindedPath, latest_block_time: u32, latest_block_height: u32) -> Self { + Self { + resolver, + reply_path, + pending_msgs: Mutex::new(Vec::new()), + pending_resolves: Mutex::new(Vec::new()), + latest_block_time: AtomicUsize::new(latest_block_time as usize), + latest_block_height: AtomicUsize::new(latest_block_height as usize), + } + } + + pub fn set_block_time(&self, height: u32, time: u32) { + self.latest_block_time.store(time as usize, Ordering::Release); + self.latest_block_height.store(height as usize, Ordering::Release); + let mut resolves = self.pending_resolves.lock().unwrap(); + resolves.retain_mut(|(res_height, _, callback)| { + if *res_height < height - 1 { + callback(None); + false + } else { + true + } + }); + } + + pub fn resolve_name(&self, name: String, resolution_callback: Box)>) -> Result<(), ()> { + if let Some((user, domain)) = name.split_once("@") { + let name_query = + Name::try_from(format!("{}.user._bitcoin-payment.{}.", user, domain)) + .map(|q| ResolverMessages::DNSSECQuery(DNSSECQuery(q))); + if let Ok(q) = name_query { + let mut pending_msgs = self.pending_msgs.lock().unwrap(); + let destination = self.resolver.clone(); + let reply_path = Some(self.reply_path.clone()); + pending_msgs.push(PendingOnionMessage { + contents: q, destination, reply_path, + }); + } else { + return Err(()); + } + let height = self.latest_block_height.load(Ordering::Acquire); + let mut pending_resolves = self.pending_resolves.lock().unwrap(); + pending_resolves.push((height as u32, name, resolution_callback)); + Ok(()) + } else { + Err(()) + } + } + } + + impl CustomOnionMessageHandler for OMNameResolver { + type CustomMessage = ResolverMessages; + + fn handle_custom_message(&self, msg: ResolverMessages) -> Option { + match msg { + ResolverMessages::DNSSECQuery(_) => {}, + ResolverMessages::OfferRequest(_) => {}, + ResolverMessages::DNSSECProof(DNSSECProof { name: answer_name, proof }) => { + let parsed_rrs = parse_rr_stream(&proof); + let validated_rrs = + parsed_rrs.as_ref().and_then(|rrs| verify_rr_stream(rrs).map_err(|_| &())); + if let Ok(validated_rrs) = validated_rrs { + let block_time = self.latest_block_time.load(Ordering::Acquire) as u64; + if validated_rrs.valid_from > block_time + 60 * 2 { + return None; + } + if validated_rrs.expires < block_time - 60 * 2 { + return None; + } + let qname = answer_name.as_str(); + if qname.len() <= "._bitcoin_payment.".len() { return None; } + let resolved_rrs = validated_rrs.resolve_name(&answer_name); + if resolved_rrs.is_empty() { return None; } + + let mut pending_resolves = self.pending_resolves.lock().unwrap(); + pending_resolves.retain_mut(|(_height, query, resolution_callback)| { + debug_assert_eq!(qname.chars().last().unwrap_or('X'), '.'); + let (user, domain) = if let Some(r) = query.split_once("@") { + r + } else { + debug_assert!(false, "This should be checked before insertion"); + return false; + }; + + let expected_len = user.len() + ".user._bitcoin-payment.".len() + domain.len() + 1; + if qname.len() == expected_len && + qname.starts_with(user) && + qname[user.len()..].starts_with(".user._bitcoin-payment.") && + qname[..qname.len() - 1].ends_with(domain) + { + const URI_PREFIX: &str = "bitcoin:"; + let mut candidate_records = resolved_rrs.iter() + .filter_map(|rr| if let RR::Txt(txt) = rr { Some(&txt.data) } else { None }) + .filter_map(|data| if let Ok(s) = core::str::from_utf8(data) { Some(s) } else { None }) + .filter(|data_string| data_string.len() > URI_PREFIX.len()) + .filter(|data_string| data_string[..URI_PREFIX.len()].eq_ignore_ascii_case(URI_PREFIX)); + let txt_record = + match (candidate_records.next(), candidate_records.next()) { + (Some(txt), None) => txt, + (_, _) => { + resolution_callback(None); + return false; + }, + }; + let (_onchain, params) = if let Some(split) = txt_record.split_once("?") { + split + } else { + resolution_callback(None); + return false; + }; + for param in params.split("&") { + let (k, v) = if let Some(split) = param.split_once("=") { + split + } else { + continue; + }; + if k.eq_ignore_ascii_case("b12") { + if let Ok(offer) = Offer::from_str(v) { + resolution_callback(Some(offer)); + } else { + resolution_callback(None); + } + return false; + } else if k.eq_ignore_ascii_case("omlookup") { + let data_hex = Vec::from_hex(v).map_err(|_| ()); + let request_path = data_hex + .and_then(|data| BlindedPath::read(&mut &data[..]) + .map_err(|_| ())); + let request_path = if let Ok(path) = request_path { + path + } else { + resolution_callback(None); + return false; + }; + let contents = ResolverMessages::OfferRequest(OfferRequest { + user: user.to_owned(), + domain: domain.to_owned(), + }); + let response = PendingOnionMessage { + contents, + destination: Destination::BlindedPath(request_path), + reply_path: Some(self.reply_path.clone()), + }; + self.pending_msgs.lock().unwrap().push(response); + return true; + } + } + resolution_callback(None); + false + } else { + true + } + }); + } + }, + ResolverMessages::OfferResponse(OfferResponse { user, domain, offer }) => { + let mut pending_resolves = self.pending_resolves.lock().unwrap(); + pending_resolves.retain_mut(|(_height, query, resolution_callback)| { + let (query_user, query_domain) = if let Some(r) = query.split_once("@") { + r + } else { + debug_assert!(false, "This should be checked before insertion"); + return false; + }; + if query_user != user || query_domain != domain { return true; } + resolution_callback(Some(offer.clone())); + false + }); + }, + } + None + } + fn read_custom_message(&self, message_type: u64, buffer: &mut R) + -> Result, DecodeError> { + ResolverMessages::read_message(message_type, buffer) + } + fn release_pending_custom_messages(&self) -> Vec> { + let mut res = Vec::new(); + std::mem::swap(&mut res, &mut self.pending_msgs.lock().unwrap()); + res + } + } +} + +#[cfg(all(test, feature = "name_resolver", feature = "domain_resolver"))] +mod test { + use super::*; + + use secp256k1::{Secp256k1, PublicKey}; + + use lightning::sign::KeysManager; + + use std::time::SystemTime; + + #[test] + fn basic() { + let secp_ctx = Secp256k1::new(); + let dummy_pk = PublicKey::from_slice(&[2; 33]).unwrap(); + + let resolver = OMDomainResolver::new("8.8.8.8:53".parse().unwrap()); + + let resolver_keys = KeysManager::new(&[0; 32], 42, 43); + let resolver_dest = Destination::Node(dummy_pk); + let payer_path = + BlindedPath::one_hop_for_message(dummy_pk, &resolver_keys, &secp_ctx).unwrap(); + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + let payer = OMNameResolver::new(resolver_dest.clone(), payer_path.clone(), now as u32, 1); + + let resolved_offer = Arc::new(Mutex::new(None)); + let offer_ref = Arc::clone(&resolved_offer); + let resolve_offer = Box::new(move |offer| *resolved_offer.lock().unwrap() = offer); + payer.resolve_name("matt@mattcorallo.com".to_owned(), resolve_offer).unwrap(); + let mut msgs = payer.release_pending_custom_messages(); + assert_eq!(msgs.len(), 1); + + let msg = msgs.pop().unwrap(); + assert_eq!(msg.destination, resolver_dest); + assert_eq!(msg.reply_path, Some(payer_path)); + let response = resolver.handle_custom_message(msg.contents).unwrap(); + + assert!(payer.handle_custom_message(response).is_none()); + offer_ref.lock().unwrap().take().unwrap(); + } +} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..76891a1 --- /dev/null +++ b/test.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eox +cargo test --features domain_resolver +cargo test --features name_resolver +cargo test --features domain_resolver,name_resolver -- 2.39.5