Skip to content

Commit e999c63

Browse files
committed
Add the core functionality required to resolve Human Readable Names
This adds a new utility struct, `OMNameResolver`, which implements the core functionality required to resolve Human Readable Names, namely generating `DNSSECQuery` onion messages, tracking the state of requests, and ultimately receiving and verifying `DNSSECProof` onion messages. It tracks pending requests with a `PaymentId`, allowing for easy integration into `ChannelManager` in a coming commit - mapping received proofs to `PaymentId`s which we can then complete by handing them `Offer`s to pay. It does not, directly, implement `DNSResolverMessageHandler`, but an implementation of `DNSResolverMessageHandler` becomes trivial with `OMNameResolver` handling the inbound messages and creating the messages to send.
1 parent 75d20e5 commit e999c63

File tree

3 files changed

+219
-1
lines changed

3 files changed

+219
-1
lines changed

ci/ci-tests.sh

+4
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ for DIR in "${WORKSPACE_MEMBERS[@]}"; do
5656
cargo doc -p "$DIR" --document-private-items
5757
done
5858

59+
echo -e "\n\nChecking and testing lightning crate with dnssec feature"
60+
cargo test -p lightning --verbose --color always --features dnssec
61+
cargo check -p lightning --verbose --color always --features dnssec
62+
5963
echo -e "\n\nChecking and testing Block Sync Clients with features"
6064

6165
cargo test -p lightning-block-sync --verbose --color always --features rest-client

lightning/Cargo.toml

+3-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Still missing tons of error-handling. See GitHub issues for suggested projects i
1212
edition = "2021"
1313

1414
[package.metadata.docs.rs]
15-
features = ["std"]
15+
features = ["std", "dnssec"]
1616
rustdoc-args = ["--cfg", "docsrs"]
1717

1818
[features]
@@ -31,6 +31,8 @@ unsafe_revoked_tx_signing = []
3131

3232
std = []
3333

34+
dnssec = ["dnssec-prover/validation"]
35+
3436
# Generates low-r bitcoin signatures, which saves 1 byte in 50% of the cases
3537
grind_signatures = []
3638

lightning/src/onion_message/dns_resolution.rs

+212
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,42 @@
1212
//! It contains [`DNSResolverMessage`]s as well as a [`DNSResolverMessageHandler`] trait to handle
1313
//! such messages using an [`OnionMessenger`].
1414
//!
15+
//! With the `dnssec` feature enabled, it also contains `OMNameResolver`, which does all the work
16+
//! required to resolve BIP 353 [`HumanReadableName`]s using [bLIP 32] - sending onion messages to
17+
//! a DNS resolver, validating the proofs, and ultimately surfacing validated data back to the
18+
//! caller.
19+
//!
1520
//! [bLIP 32]: https://github.com/lightning/blips/blob/master/blip-0032.md
1621
//! [`OnionMessenger`]: super::messenger::OnionMessenger
1722
23+
#[cfg(feature = "dnssec")]
24+
use core::str::FromStr;
25+
#[cfg(feature = "dnssec")]
26+
use core::sync::atomic::{AtomicUsize, Ordering};
27+
28+
#[cfg(feature = "dnssec")]
29+
use dnssec_prover::rr::RR;
30+
#[cfg(feature = "dnssec")]
31+
use dnssec_prover::ser::parse_rr_stream;
32+
#[cfg(feature = "dnssec")]
33+
use dnssec_prover::validation::verify_rr_stream;
34+
1835
use dnssec_prover::rr::Name;
1936

2037
use crate::blinded_path::message::DNSResolverContext;
2138
use crate::io;
39+
#[cfg(feature = "dnssec")]
40+
use crate::ln::channelmanager::PaymentId;
2241
use crate::ln::msgs::DecodeError;
42+
#[cfg(feature = "dnssec")]
43+
use crate::offers::offer::Offer;
2344
use crate::onion_message::messenger::{MessageSendInstructions, Responder, ResponseInstruction};
2445
use crate::onion_message::packet::OnionMessageContents;
2546
use crate::prelude::*;
47+
#[cfg(feature = "dnssec")]
48+
use crate::sign::EntropySource;
49+
#[cfg(feature = "dnssec")]
50+
use crate::sync::Mutex;
2651
use crate::util::ser::{Hostname, Readable, ReadableArgs, Writeable, Writer};
2752

2853
/// A handler for an [`OnionMessage`] containing a DNS(SEC) query or a DNSSEC proof
@@ -235,3 +260,190 @@ impl Readable for HumanReadableName {
235260
HumanReadableName::new(user, domain).map_err(|()| DecodeError::InvalidValue)
236261
}
237262
}
263+
264+
#[cfg(feature = "dnssec")]
265+
struct PendingResolution {
266+
start_height: u32,
267+
context: DNSResolverContext,
268+
name: HumanReadableName,
269+
payment_id: PaymentId,
270+
}
271+
272+
/// A stateful resolver which maps BIP 353 Human Readable Names to URIs and BOLT12 [`Offer`]s.
273+
///
274+
/// It does not directly implement [`DNSResolverMessageHandler`] but implements all the core logic
275+
/// which is required in a client which intends to.
276+
///
277+
/// It relies on being made aware of the passage of time with regular calls to
278+
/// [`Self::new_best_block`] in order to time out existing queries. Queries time out after two
279+
/// blocks.
280+
#[cfg(feature = "dnssec")]
281+
pub struct OMNameResolver {
282+
pending_resolves: Mutex<HashMap<Name, Vec<PendingResolution>>>,
283+
latest_block_time: AtomicUsize,
284+
latest_block_height: AtomicUsize,
285+
}
286+
287+
#[cfg(feature = "dnssec")]
288+
impl OMNameResolver {
289+
/// Builds a new [`OMNameResolver`].
290+
pub fn new(latest_block_time: u32, latest_block_height: u32) -> Self {
291+
Self {
292+
pending_resolves: Mutex::new(new_hash_map()),
293+
latest_block_time: AtomicUsize::new(latest_block_time as usize),
294+
latest_block_height: AtomicUsize::new(latest_block_height as usize),
295+
}
296+
}
297+
298+
/// Informs the [`OMNameResolver`] of the passage of time in the form of a new best Bitcoin
299+
/// block.
300+
///
301+
/// This will call back to resolve some pending queries which have timed out.
302+
pub fn new_best_block(&self, height: u32, time: u32) {
303+
self.latest_block_time.store(time as usize, Ordering::Release);
304+
self.latest_block_height.store(height as usize, Ordering::Release);
305+
let mut resolves = self.pending_resolves.lock().unwrap();
306+
resolves.retain(|_, queries| {
307+
queries.retain(|query| query.start_height >= height - 1);
308+
!queries.is_empty()
309+
});
310+
}
311+
312+
/// Begins the process of resolving a BIP 353 Human Readable Name.
313+
///
314+
/// Returns a [`DNSSECQuery`] onion message and a [`DNSResolverContext`] which should be sent
315+
/// to a resolver (with the context used to generate the blinded response path) on success.
316+
pub fn resolve_name<ES: EntropySource + ?Sized>(
317+
&self, payment_id: PaymentId, name: HumanReadableName, entropy_source: &ES,
318+
) -> Result<(DNSSECQuery, DNSResolverContext), ()> {
319+
let dns_name =
320+
Name::try_from(format!("{}.user._bitcoin-payment.{}.", name.user, name.domain));
321+
debug_assert!(
322+
dns_name.is_ok(),
323+
"The HumanReadableName constructor shouldn't allow names which are too long"
324+
);
325+
let mut context = DNSResolverContext { nonce: [0; 16] };
326+
context.nonce.copy_from_slice(&entropy_source.get_secure_random_bytes()[..16]);
327+
if let Ok(dns_name) = dns_name {
328+
let start_height = self.latest_block_height.load(Ordering::Acquire) as u32;
329+
let mut pending_resolves = self.pending_resolves.lock().unwrap();
330+
let context_ret = context.clone();
331+
let resolution = PendingResolution { start_height, context, name, payment_id };
332+
pending_resolves.entry(dns_name.clone()).or_insert_with(Vec::new).push(resolution);
333+
Ok((DNSSECQuery(dns_name), context_ret))
334+
} else {
335+
Err(())
336+
}
337+
}
338+
339+
/// Handles a [`DNSSECProof`] message, attempting to verify it and match it against a pending
340+
/// query.
341+
///
342+
/// If verification succeeds, the resulting bitcoin: URI is parsed to find a contained
343+
/// [`Offer`].
344+
///
345+
/// Note that a single proof for a wildcard DNS entry may complete several requests for
346+
/// different [`HumanReadableName`]s.
347+
///
348+
/// If an [`Offer`] is found, it, as well as the [`PaymentId`] and original `name` passed to
349+
/// [`Self::resolve_name`] are returned.
350+
pub fn handle_dnssec_proof_for_offer(
351+
&self, msg: DNSSECProof, context: DNSResolverContext,
352+
) -> Option<(Vec<(HumanReadableName, PaymentId)>, Offer)> {
353+
let (completed_requests, uri) = self.handle_dnssec_proof_for_uri(msg, context)?;
354+
if let Some((_onchain, params)) = uri.split_once("?") {
355+
for param in params.split("&") {
356+
let (k, v) = if let Some(split) = param.split_once("=") {
357+
split
358+
} else {
359+
continue;
360+
};
361+
if k.eq_ignore_ascii_case("lno") {
362+
if let Ok(offer) = Offer::from_str(v) {
363+
return Some((completed_requests, offer));
364+
}
365+
return None;
366+
}
367+
}
368+
}
369+
None
370+
}
371+
372+
/// Handles a [`DNSSECProof`] message, attempting to verify it and match it against any pending
373+
/// queries.
374+
///
375+
/// If verification succeeds, all matching [`PaymentId`] and [`HumanReadableName`]s passed to
376+
/// [`Self::resolve_name`], as well as the resolved bitcoin: URI are returned.
377+
///
378+
/// Note that a single proof for a wildcard DNS entry may complete several requests for
379+
/// different [`HumanReadableName`]s.
380+
///
381+
/// This method is useful for those who handle bitcoin: URIs already, handling more than just
382+
/// BOLT12 [`Offer`]s.
383+
pub fn handle_dnssec_proof_for_uri(
384+
&self, msg: DNSSECProof, context: DNSResolverContext,
385+
) -> Option<(Vec<(HumanReadableName, PaymentId)>, String)> {
386+
let DNSSECProof { name: answer_name, proof } = msg;
387+
let mut pending_resolves = self.pending_resolves.lock().unwrap();
388+
if let hash_map::Entry::Occupied(entry) = pending_resolves.entry(answer_name) {
389+
if !entry.get().iter().any(|query| query.context == context) {
390+
// If we don't have any pending queries with the context included in the blinded
391+
// path (implying someone sent us this response not using the blinded path we gave
392+
// when making the query), return immediately to avoid the extra time for the proof
393+
// validation giving away that we were the node that made the query.
394+
//
395+
// If there was at least one query with the same context, we go ahead and complete
396+
// all queries for the same name, as there's no point in waiting for another proof
397+
// for the same name.
398+
return None;
399+
}
400+
let parsed_rrs = parse_rr_stream(&proof);
401+
let validated_rrs =
402+
parsed_rrs.as_ref().and_then(|rrs| verify_rr_stream(rrs).map_err(|_| &()));
403+
if let Ok(validated_rrs) = validated_rrs {
404+
let block_time = self.latest_block_time.load(Ordering::Acquire) as u64;
405+
// Block times may be up to two hours in the future and some time into the past
406+
// (we assume no more than two hours, though the actual limits are rather
407+
// complicated).
408+
// Thus, we have to let the proof times be rather fuzzy.
409+
if validated_rrs.valid_from > block_time + 60 * 2 {
410+
return None;
411+
}
412+
if validated_rrs.expires < block_time - 60 * 2 {
413+
return None;
414+
}
415+
let resolved_rrs = validated_rrs.resolve_name(&entry.key());
416+
if resolved_rrs.is_empty() {
417+
return None;
418+
}
419+
420+
let (_, requests) = entry.remove_entry();
421+
422+
const URI_PREFIX: &str = "bitcoin:";
423+
let mut candidate_records = resolved_rrs
424+
.iter()
425+
.filter_map(
426+
|rr| if let RR::Txt(txt) = rr { Some(txt.data.as_vec()) } else { None },
427+
)
428+
.filter_map(
429+
|data| if let Ok(s) = String::from_utf8(data) { Some(s) } else { None },
430+
)
431+
.filter(|data_string| data_string.len() > URI_PREFIX.len())
432+
.filter(|data_string| {
433+
data_string[..URI_PREFIX.len()].eq_ignore_ascii_case(URI_PREFIX)
434+
});
435+
// Check that there is exactly one TXT record that begins with
436+
// bitcoin: as required by BIP 353 (and is valid UTF-8).
437+
match (candidate_records.next(), candidate_records.next()) {
438+
(Some(txt), None) => {
439+
let completed_requests =
440+
requests.into_iter().map(|r| (r.name, r.payment_id)).collect();
441+
return Some((completed_requests, txt));
442+
},
443+
_ => {},
444+
}
445+
}
446+
}
447+
None
448+
}
449+
}

0 commit comments

Comments
 (0)