]> git.bitcoin.ninja Git - lightning-resolver/commitdiff
Initial checkin
authorMatt Corallo <git@bluematt.me>
Sat, 10 Feb 2024 22:42:27 +0000 (22:42 +0000)
committerMatt Corallo <git@bluematt.me>
Sat, 10 Feb 2024 23:07:08 +0000 (23:07 +0000)
Cargo.toml [new file with mode: 0644]
src/lib.rs [new file with mode: 0644]
test.sh [new file with mode: 0755]

diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644 (file)
index 0000000..00560d8
--- /dev/null
@@ -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 (file)
index 0000000..4bc7996
--- /dev/null
@@ -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<u8>,
+}
+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<W: Writer>(&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<R: lightning::io::Read>(buffer: &mut R)
+-> Result<String, DecodeError> {
+       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<R: lightning::io::Read>(message_type: u64, buffer: &mut R)
+       -> Result<Option<ResolverMessages>, 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<domain_resolver::OMResolverState>,
+}
+
+#[cfg(feature = "domain_resolver")]
+mod domain_resolver {
+       use super::*;
+
+       const MAX_PENDING_RESPONSES: usize = 1024;
+       pub(super) struct OMResolverState {
+               resolver: SocketAddr,
+               pending_msgs: Mutex<Vec<PendingOnionMessage<ResolverMessages>>>,
+               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<Self::CustomMessage> {
+                       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<R: std::io::Read>(&self, message_type: u64, buffer: &mut R)
+               -> Result<Option<ResolverMessages>, DecodeError> {
+                       ResolverMessages::read_message(message_type, buffer)
+               }
+               fn release_pending_custom_messages(&self) -> Vec<PendingOnionMessage<ResolverMessages>> {
+                       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<Vec<PendingOnionMessage<ResolverMessages>>>,
+       pending_resolves: Mutex<Vec<(u32, String, Box<dyn FnMut(Option<Offer>)>)>>,
+       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<dyn FnMut(Option<Offer>)>) -> 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<Self::CustomMessage> {
+                       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<R: lightning::io::Read>(&self, message_type: u64, buffer: &mut R)
+               -> Result<Option<ResolverMessages>, DecodeError> {
+                       ResolverMessages::read_message(message_type, buffer)
+               }
+               fn release_pending_custom_messages(&self) -> Vec<PendingOnionMessage<ResolverMessages>> {
+                       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 (executable)
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