Skip to content
Merged
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
4 changes: 4 additions & 0 deletions crates/flint-dial/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ mod race;
mod strategy;

pub use engine::{dial, dial_alpn, dial_over, dial_over_alpn};
// Re-exported because `BootstrapStrategy::wire` is part of this crate's public API: a consumer cannot
// compose opening-handshake shaping (record fragmentation, segment splitting, inter-segment jitter)
// without these types, and should not have to take a direct `flint-shaping` dependency to do it.
pub use flint_shaping::{DelaySpec, RecordFragment, SegmentSplit, WirePlan};
pub use flint_tls::CertVerification;
pub use race::{probe_windowed, race, race_windowed, race_with};
pub use strategy::{BootstrapStrategy, TlsEngine};
Expand Down
11 changes: 7 additions & 4 deletions crates/flint-dns/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "flint-dns"
description = "Resilient DoH resolver (race, validate, cache) + a minimal A/AAAA DNS codec."
description = "Resilient DNS resolver (DoH/DoT/TCP/UDP/system; race, validate, cache) + a minimal A/AAAA DNS codec."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
Expand All @@ -22,14 +22,17 @@ futures = { workspace = true }
h2 = { workspace = true }
http = { workspace = true }
postcard = { workspace = true }
# CSPRNG for plaintext DNS transaction IDs (the off-path-injection defense). The same `ring` the rest
# of flint already uses, so this adds no new crypto dependency.
ring = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
tokio = { version = "1", default-features = false, features = ["io-util", "rt", "time"] }
# `net` is required by the non-DoH transports: a UDP socket, a plain TCP connect, and the system
# resolver's `lookup_host`.
tokio = { version = "1", default-features = false, features = ["io-util", "net", "rt", "time"] }

[dev-dependencies]
# A local in-memory h2 server for the DoH round-trip test (no network).
tokio = { version = "1", default-features = false, features = ["macros", "rt", "rt-multi-thread", "net", "io-util"] }
h2 = { workspace = true }
http = { workspace = true }
# Ed25519 *signing* to forge a signed pool in the signed-update tests (clients only ever verify).
ring = { workspace = true }
91 changes: 90 additions & 1 deletion crates/flint-dns/src/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,36 @@ pub enum DnsError {
/// The server returned a non-zero RCODE (e.g. 3 = NXDOMAIN, 2 = SERVFAIL).
#[error("DNS server returned RCODE {0}")]
Rcode(u8),
/// The response's transaction ID did not match the query's. Only checked on plaintext transports
/// (see [`parse_response_with_id`]), where a mismatch means an injected/stale answer.
#[error("DNS response ID {got:#06x} does not match query ID {want:#06x}")]
IdMismatch {
/// The ID the response carried.
got: u16,
/// The ID our query used.
want: u16,
},
}

/// Build a standard recursive query for `name`/`qtype` (class IN). The transaction ID is `0`, as
/// recommended for DoH (RFC 8484 §4.1 — improves cache friendliness since DoH has its own framing).
///
/// **Encrypted transports only.** On a plaintext transport an ID of `0` is guessable, so an off-path
/// injector can forge an answer; use [`build_query_with_id`] with a random ID there, and verify it with
/// [`parse_response_with_id`].
pub fn build_query(name: &str, qtype: u16) -> Result<Vec<u8>, DnsError> {
build_query_with_id(name, qtype, 0)
}

/// Like [`build_query`], but with an explicit transaction `id`.
///
/// Plaintext DNS (UDP/TCP) has no channel binding, so the 16-bit ID plus a random source port is all
/// that stops an **off-path** attacker from injecting a forged answer — a technique censors use in
/// practice (the GFW blasts forged responses without seeing the query). Callers on those transports
/// must pass a CSPRNG-drawn `id` and check it on the way back.
pub fn build_query_with_id(name: &str, qtype: u16, id: u16) -> Result<Vec<u8>, DnsError> {
let mut q = Vec::with_capacity(name.len() + 18);
q.extend_from_slice(&[0x00, 0x00]); // ID = 0
q.extend_from_slice(&id.to_be_bytes());
q.extend_from_slice(&[0x01, 0x00]); // flags: RD (recursion desired)
q.extend_from_slice(&[0x00, 0x01]); // QDCOUNT = 1
q.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); // AN/NS/AR counts = 0
Expand All @@ -59,9 +82,31 @@ fn encode_name(name: &str, out: &mut Vec<u8>) -> Result<(), DnsError> {
Ok(())
}

/// Like [`parse_response`], but first requires the response's transaction ID to equal `expected_id`.
///
/// **Use this on every plaintext transport.** Inside DoH/DoT the encrypted stream already binds a
/// response to our query, but on UDP/TCP the ID is the binding, so an unchecked ID means accepting
/// whatever arrives first — including an off-path forgery. Pair with [`build_query_with_id`].
pub fn parse_response_with_id(buf: &[u8], expected_id: u16) -> Result<Vec<IpAddr>, DnsError> {
if buf.len() < 12 {
return Err(DnsError::Truncated);
}
let got = u16::from_be_bytes([buf[0], buf[1]]);
if got != expected_id {
return Err(DnsError::IdMismatch {
got,
want: expected_id,
});
}
parse_response(buf)
}

/// Parse a DNS response, returning the A/AAAA addresses in its answer section. Errors on a truncated
/// message, a query (not a response), or a non-zero RCODE. An empty answer list is *not* an error here
/// (the validation layer decides what an empty/poisoned answer means).
///
/// Does **not** check the transaction ID — safe for DoH/DoT (the channel binds the response) but not
/// for plaintext; see [`parse_response_with_id`].
pub fn parse_response(buf: &[u8]) -> Result<Vec<IpAddr>, DnsError> {
if buf.len() < 12 {
return Err(DnsError::Truncated);
Expand Down Expand Up @@ -223,4 +268,48 @@ mod tests {
let long = "a".repeat(64);
assert_eq!(build_query(&long, TYPE_A), Err(DnsError::BadName));
}

#[test]
fn build_query_with_id_sets_the_id_and_plain_build_query_stays_zero() {
let q = build_query_with_id("example.com", TYPE_A, 0xbeef).unwrap();
assert_eq!(&q[0..2], &[0xbe, 0xef]);
// DoH deliberately keeps ID 0 (RFC 8484 §4.1); only the plaintext paths randomize.
assert_eq!(&build_query("example.com", TYPE_A).unwrap()[0..2], &[0, 0]);
// The ID must be the only difference from the ID-0 query.
let mut zeroed = q.clone();
zeroed[0] = 0;
zeroed[1] = 0;
assert_eq!(zeroed, build_query("example.com", TYPE_A).unwrap());
}

#[test]
fn parse_response_with_id_rejects_a_mismatched_id() {
let mut resp = build_response(
"example.com",
TYPE_A,
0,
&[(TYPE_A, vec![93, 184, 216, 34])],
);
resp[0] = 0x12;
resp[1] = 0x34;

// The matching ID is accepted.
assert_eq!(
parse_response_with_id(&resp, 0x1234).unwrap(),
vec!["93.184.216.34".parse::<IpAddr>().unwrap()]
);
// An answer carrying anyone else's ID is an injection or a stale reply — reject it.
assert_eq!(
parse_response_with_id(&resp, 0x9999),
Err(DnsError::IdMismatch {
got: 0x1234,
want: 0x9999
})
);
// A runt message is rejected before the ID is even read.
assert_eq!(
parse_response_with_id(&[0u8; 4], 0x1234),
Err(DnsError::Truncated)
);
}
}
143 changes: 126 additions & 17 deletions crates/flint-dns/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,31 +1,50 @@
//! Resilient DNS-over-HTTPS: un-poisoned answers in censored regions (design §6).
//! Resilient DNS: un-poisoned answers in censored regions (design §6).
//!
//! The first [`flint_dial`] consumer. [`resolve`] races a diverse [`pool`] of DoH resolvers, each
//! reached by a composable bootstrap dial (boring Chrome-mimicry TLS), runs a [`codec`]-built A/AAAA
//! query over [`doh`] (HTTP/2), [`validate`]s the answer (drops poison/bogons), and returns the first
//! resolver that yields a real answer. Because DoH is encrypted transport, a censor can't poison an
//! answer — only block a connection — so "uncensored DNS" reduces to "reach *one* resolver", which is
//! exactly what the raced bootstrap dials are for.
//! The first [`flint_dial`] consumer. [`resolve`] races a diverse [`pool`] of resolvers, each reached
//! by a composable bootstrap dial (boring Chrome-mimicry TLS), runs a [`codec`]-built A/AAAA query,
//! [`validate`]s the answer (drops poison/bogons), and returns the first resolver that yields a real
//! answer. Because an encrypted transport keeps a censor from *rewriting* an answer in flight — mostly
//! leaving it the blunter option of blocking the connection — "uncensored DNS" largely reduces to
//! "reach *one* resolver", which is exactly what the raced bootstrap dials are for.
//!
//! **Caveat, and it is a real one:** the default dial does not authenticate the resolver
//! ([`CertVerification::None`](flint_dial::CertVerification::None) via
//! [`BootstrapStrategy::boring_chrome`](flint_dial::BootstrapStrategy::boring_chrome)), so an
//! **on-path** attacker can terminate TLS with any certificate and inject answers. [`validate`] would
//! catch a clumsy sentinel but not a plausible attacker-chosen address. Callers wanting that closed
//! must pass [`CertVerification::Roots`](flint_dial::CertVerification::Roots); see [`Kind`].
//!
//! **Two independent axes.** A resolver's [`Kind`] picks the DNS protocol and endpoint (DoH, DoT,
//! plaintext TCP/UDP, or the system resolver); a [`WirePlan`] picks how the opening handshake looks on
//! the wire (record fragmentation, segment splitting, inter-segment jitter). [`resolve_one_shaped`]
//! composes them, so a DoH lookup can itself be carried over a fragmented, jittered ClientHello — the
//! same shaping vocabulary used for a destination dial, aimed at the DNS dial. Encrypted kinds are the
//! trustworthy ones; the plaintext kinds are poisonable and stay out of [`default_pool`] (see there).
//!
//! Build pieces: [`codec`] (minimal A/AAAA wire codec), [`validate`] (poison rejection), [`pool`]
//! (the diverse resolver set), [`doh`] (DoH-over-h2), and [`resolve`] (the smart-dialer). Per-network
//! caching of the winning composition and Ed25519-signed pool updates are follow-ups (design §6).
//! (the diverse resolver set + the [`Kind`] axis), [`doh`] (DoH-over-h2), [`plain`] (DoT/TCP framing +
//! UDP), and [`resolve`] (the smart-dialer). Per-network caching of the winning composition and
//! Ed25519-signed pool updates are follow-ups (design §6).
#![forbid(unsafe_code)]

use std::io;
use std::net::IpAddr;
use std::time::Duration;

use ring::rand::{SecureRandom, SystemRandom};

pub mod cache;
pub mod codec;
pub mod doh;
pub mod plain;
pub mod pool;
pub mod signed;
pub mod validate;

pub use cache::ResolverCache;
pub use codec::{TYPE_A, TYPE_AAAA};
pub use pool::{default_pool, Resolver};
pub use flint_dial::WirePlan;
pub use pool::{default_pool, Kind, Resolver};
pub use signed::{load_signed_pool, PoolUpdate};

/// Why a resolution failed.
Expand All @@ -50,17 +69,107 @@ const DEFAULT_WINDOW: usize = 16;
/// instead of hanging on the slowest resolver.
const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(5);

/// Resolve `name`/`qtype` through a single `resolver`: dial it (composable bootstrap dial), run the
/// DoH query, parse, and validate. Returns the validated public addresses, or an `io::Error` (which
/// the smart-dialer funnels into the race's per-resolver failures).
/// Resolve `name`/`qtype` through a single `resolver`: reach it over whatever transport its
/// [`Kind`] names, run the query, parse, and validate. Returns the validated public addresses, or an
/// `io::Error` (which the smart-dialer funnels into the race's per-resolver failures).
///
/// Applies no opening-handshake shaping; see [`resolve_one_shaped`].
pub async fn resolve_one(resolver: &Resolver, name: &str, qtype: u16) -> io::Result<Vec<IpAddr>> {
let query = codec::build_query(name, qtype).map_err(io::Error::other)?;
let stream = flint_dial::dial(&resolver.strategy()).await?;
let response = doh::query(stream, &resolver.host, &resolver.path, &query).await?;
let answers = codec::parse_response(&response).map_err(io::Error::other)?;
resolve_one_shaped(resolver, name, qtype, &WirePlan::default()).await
}

/// Like [`resolve_one`], but composes opening-handshake shaping `wire` onto the dial that reaches the
/// resolver.
///
/// This is the seam that makes the two axes independent: `resolver` picks *which DNS protocol and
/// endpoint*, `wire` picks *how the opening handshake looks on the wire* (record fragmentation, segment
/// splitting, inter-segment jitter). So a DoH lookup can itself be carried over a fragmented, jittered
/// ClientHello — the same shaping vocabulary applied to a destination dial, pointed at the DNS dial.
///
/// `wire` is ignored for kinds that expose no ClientHello to shape ([`Kind::is_shapeable`]).
///
/// Transaction IDs: DoH uses ID 0 per RFC 8484 §4.1, since its own framing binds the response. Every
/// other transport draws a random ID and verifies it on return — mandatory for the plaintext kinds,
/// harmless for DoT.
pub async fn resolve_one_shaped(
resolver: &Resolver,
name: &str,
qtype: u16,
wire: &WirePlan,
) -> io::Result<Vec<IpAddr>> {
let answers = match resolver.kind {
Kind::Doh => {
let query = codec::build_query(name, qtype).map_err(io::Error::other)?;
let stream = flint_dial::dial(&tls_strategy(resolver, wire)?).await?;
let response = doh::query(stream, &resolver.host, &resolver.path, &query).await?;
codec::parse_response(&response).map_err(io::Error::other)?
}
Kind::Dot => {
let id = random_id()?;
let query = codec::build_query_with_id(name, qtype, id).map_err(io::Error::other)?;
let stream = flint_dial::dial(&tls_strategy(resolver, wire)?).await?;
let response = plain::query_stream(stream, &query).await?;
codec::parse_response_with_id(&response, id).map_err(io::Error::other)?
}
Kind::Tcp => {
let id = random_id()?;
let query = codec::build_query_with_id(name, qtype, id).map_err(io::Error::other)?;
let stream = tokio::net::TcpStream::connect(resolver.target).await?;
let response = plain::query_stream(stream, &query).await?;
codec::parse_response_with_id(&response, id).map_err(io::Error::other)?
}
Kind::Udp => {
let id = random_id()?;
let query = codec::build_query_with_id(name, qtype, id).map_err(io::Error::other)?;
let response = plain::query_udp(resolver.target, &query).await?;
codec::parse_response_with_id(&response, id).map_err(io::Error::other)?
}
Kind::System => system_lookup(name, qtype).await?,
};
validate::validate_answers(answers).map_err(io::Error::other)
}

/// The TLS strategy for a resolver whose kind is known to be TLS-based.
///
/// The `Doh`/`Dot` match arms have already established that, so `None` here would mean
/// [`Kind::is_shapeable`] and this dispatch disagree — a bug, not a runtime condition. Surfacing it as
/// an error rather than unwrapping keeps that impossible case from becoming a panic.
fn tls_strategy(resolver: &Resolver, wire: &WirePlan) -> io::Result<flint_dial::BootstrapStrategy> {
resolver.tls_strategy_with(wire.clone()).ok_or_else(|| {
io::Error::other(format!(
"resolver {} has kind {:?}, which has no TLS dial strategy",
resolver.name, resolver.kind
))
})
}

/// A CSPRNG-drawn DNS transaction ID. Uses `ring` like the rest of flint rather than adding an RNG.
fn random_id() -> io::Result<u16> {
let mut bytes = [0u8; 2];
SystemRandom::new()
.fill(&mut bytes)
.map_err(|_| io::Error::other("CSPRNG failure drawing a DNS transaction ID"))?;
Ok(u16::from_be_bytes(bytes))
}

/// Resolve through the OS resolver, keeping only the family `qtype` asked for.
///
/// Worth trying because plenty of networks do not interfere with DNS at all, and it costs no
/// connection of our own. Trust it exactly as much as any plaintext answer: the OS resolver usually
/// speaks unencrypted DNS to a network-provided server, so the result is poisonable.
async fn system_lookup(name: &str, qtype: u16) -> io::Result<Vec<IpAddr>> {
let addrs = tokio::net::lookup_host((name, 0u16)).await?;
Ok(addrs
.map(|addr| addr.ip())
.filter(|ip| match qtype {
TYPE_A => ip.is_ipv4(),
TYPE_AAAA => ip.is_ipv6(),
// Not a family query — the codec only builds A/AAAA, so this is unreachable in practice.
_ => true,
})
.collect())
}

/// Resolve `name`/`qtype` resiliently: race every resolver in `pool` and return the first that yields
/// a **validated** answer. Slower resolvers are cancelled once one succeeds. Errors only if all fail.
pub async fn resolve(
Expand Down
Loading
Loading