dnscrypt 0.1.1

A pure-Rust DNSCrypt v2 client library — sync and async support.
Documentation
//! `dnscrypt` — a pure-Rust, synchronous `DNSCrypt` v2 client library.
//!
//! # Overview
//!
//! This library implements the [DNSCrypt v2 protocol] for encrypted, authenticated
//! DNS resolution using only pure-Rust cryptographic primitives.  No C bindings,
//! no `aws-lc-rs`, no `openssl`, no async runtime.
//!
//! It ships two primary abstractions:
//!
//! 1. **Manual API** — call [`resolve`] or work step-by-step with
//!    [`establish_dnscrypt_session`] + [`resolve_domain_via_dnscrypt_session`].
//! 2. **Reqwest drop-in** — plug [`DnscryptResolver`] into any
//!    `reqwest::Client` via `.dns_resolver(...)` to transparently route all
//!    hostname lookups through `DNSCrypt`.
//!
//! You can also supply your own custom static list of `DNSCrypt` resolvers, bypassing
//! the built-in defaults.
//!
//! # Cryptography
//!
//! | Primitive | Purpose |
//! |-----------|---------|
//! | X25519 DH | Per-session key agreement with the resolver |
//! | `HChaCha20` | Key derivation from the raw DH shared secret |
//! | XChaCha20-Poly1305 | Authenticated encryption of DNS queries/responses |
//! | Ed25519 | Resolver certificate signature verification |
//!
//! All resolvers are **hardcoded** with their Ed25519 public keys pinned at
//! compile time, preventing MITM via CA compromise.
//!
//! # Quick Start
//!
//! ### Synchronous (Blocking) API:
//! ```no_run
//! use dnscrypt::{resolve, Error, HARDCODED_RESOLVERS};
//! fn main() -> Result<(), Error> {
//!     let ips = resolve(HARDCODED_RESOLVERS, "github.com")?;
//!     println!("github.com => {:?}", ips);
//!     Ok(())
//! }
//! ```
//!
//! ### Asynchronous API:
//! ```no_run
//! # #[cfg(feature = "tokio")]
//! # {
//! use dnscrypt::{resolve_async, Error, HARDCODED_RESOLVERS};
//! # async fn run() -> Result<(), Error> {
//! let ips = resolve_async(HARDCODED_RESOLVERS, "github.com").await?;
//! println!("github.com => {:?}", ips);
//! # Ok(())
//! # }
//! # }
//! ```
//!
//! ## Reqwest integration
//!
//! ```no_run
//! # #[cfg(feature = "reqwest")]
//! # {
//! use dnscrypt::DnscryptResolver;
//! use std::sync::Arc;
//!
//! // Build a reqwest client that resolves every hostname through DNSCrypt.
//! // The resolver is synchronous internally; reqwest calls it from a thread pool.
//! let client = reqwest::Client::builder()
//!     .dns_resolver(Arc::new(DnscryptResolver::new()))
//!     .build()
//!     .unwrap();
//! # }
//! ```
//!
//! [DNSCrypt v2 protocol]: https://dnscrypt.info/protocol

#![forbid(unsafe_code, elided_lifetimes_in_paths)]
#![cfg_attr(docsrs, feature(doc_cfg))]

#[cfg(not(feature = "max-security"))]
mod cache;
pub mod cert;
pub mod crypto;
pub mod error;
pub mod net;
pub mod packet;
#[cfg(feature = "reqwest")]
pub mod reqwest_resolver;
pub mod resolver;
mod rng;

pub use error::Error;
#[cfg(feature = "reqwest")]
pub use reqwest_resolver::DnscryptResolver;
pub use resolver::{
    DnscryptClientState, HARDCODED_RESOLVERS, HardcodedResolver, establish_dnscrypt_session,
    resolve_domain_via_dnscrypt_session,
};

#[cfg(feature = "tokio")]
pub use resolver::{
    establish_dnscrypt_session_async, resolve_async, resolve_domain_via_dnscrypt_session_async,
};

use std::net::IpAddr;

/// Resolve a domain name to an [`IpAddr`] using `DNSCrypt`.
///
/// This is the **simplest** entry point.  It establishes an ephemeral
/// `DNSCrypt` session against one of the bundled hardcoded resolvers, sends an
/// encrypted A-record query, and returns the first IPv4 address found in the
/// response.
///
/// The `rng` must be a cryptographically secure source of randomness.  Use
/// [`rand_core::OsRng`] unless you have a specific reason to substitute.
///
/// # Errors
///
/// Returns [`Error`] if:
/// - no hardcoded resolver can be reached
/// - certificate validation fails for all resolvers
/// - the DNS response contains no A record
///
/// # Example
///
/// ```no_run
/// use dnscrypt::HARDCODED_RESOLVERS;
///
/// let ips = dnscrypt::resolve(HARDCODED_RESOLVERS, "github.com")?;
/// # Ok::<(), dnscrypt::Error>(())
/// ```
pub fn resolve(
    resolvers: &'static [HardcodedResolver],
    domain: &str,
) -> Result<Vec<IpAddr>, Error> {
    let sessions = establish_dnscrypt_session(resolvers)?;

    #[cfg(feature = "max-security")]
    {
        let mut results = std::collections::HashMap::new();
        for session in &sessions {
            if let Ok(ips) = resolve_domain_via_dnscrypt_session(session, domain) {
                for ip in ips {
                    *results.entry(ip).or_insert(0) += 1;
                }
            }
        }

        if results.is_empty() {
            return Err(Error::Protocol(
                "max-security: all providers failed to resolve domain".into(),
            ));
        }

        Ok(resolver::majority_ips(results))
    }

    #[cfg(not(feature = "max-security"))]
    {
        resolver::resolve_with_cache(&sessions, domain)
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use crate::crypto::{xchacha20_djb_poly1305_decrypt, xchacha20_djb_poly1305_encrypt};
    use crate::packet::{build_a_record_query, pad_query, parse_dns_response, unpad_response};
    use std::net::{IpAddr, Ipv4Addr};

    #[test]
    fn test_build_a_record_query() {
        let query = build_a_record_query("github.com", [0xab, 0xcd]);
        assert_eq!(
            &query[0..12],
            &[
                0xab, 0xcd, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
            ]
        );
        assert_eq!(query[12], 6);
        assert_eq!(&query[13..19], b"github");
        assert_eq!(query[19], 3);
        assert_eq!(&query[20..23], b"com");
        assert_eq!(query[23], 0);
        assert_eq!(&query[24..28], &[0x00, 0x01, 0x00, 0x01]);
    }

    #[test]
    fn test_parse_dns_response_valid() {
        let mut response = Vec::new();
        response.extend_from_slice(&[
            0xab, 0xcd, 0x81, 0x80, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
        ]);
        response.extend_from_slice(&[6]);
        response.extend_from_slice(b"github");
        response.extend_from_slice(&[3]);
        response.extend_from_slice(b"com");
        response.extend_from_slice(&[0]);
        response.extend_from_slice(&[0x00, 0x01, 0x00, 0x01]);
        response.extend_from_slice(&[0xc0, 0x0c]);
        response.extend_from_slice(&[0x00, 0x01, 0x00, 0x01]);
        response.extend_from_slice(&[0x00, 0x00, 0x00, 0x3c]);
        response.extend_from_slice(&[0x00, 0x04]);
        response.extend_from_slice(&[140, 82, 121, 4]);

        let parsed = parse_dns_response(&response);
        assert_eq!(parsed[0], IpAddr::V4(Ipv4Addr::new(140, 82, 121, 4)));
    }

    #[test]
    fn test_parse_dns_response_truncated() {
        assert!(parse_dns_response(&[]).is_empty());
        assert!(parse_dns_response(&[0; 10]).is_empty());

        let mut response = Vec::new();
        response.extend_from_slice(&[
            0xab, 0xcd, 0x81, 0x80, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
        ]);
        response.extend_from_slice(&[6]);
        response.extend_from_slice(b"github");
        response.extend_from_slice(&[3]);
        response.extend_from_slice(b"com");
        response.extend_from_slice(&[0]);
        response.extend_from_slice(&[0x00, 0x01, 0x00, 0x01]);
        response.extend_from_slice(&[0xc0, 0x0c]);
        assert!(parse_dns_response(&response).is_empty());
    }

    #[test]
    fn test_pad_query() {
        let query = vec![1, 2, 3];
        let padded = pad_query(&query, 64);
        assert!(padded.len() >= 64 && padded.len().is_multiple_of(64));
        assert_eq!(padded[0..3], [1, 2, 3]);
        assert_eq!(padded[3], 0x80);
        assert!(padded[4..].iter().all(|&b| b == 0));

        // Already-full block pushes to the next multiple of 64.
        let query2 = vec![5; 64];
        let padded2 = pad_query(&query2, 64);
        assert!(padded2.len() >= 128 && padded2.len().is_multiple_of(64));
        assert_eq!(padded2[64], 0x80);
    }

    #[test]
    fn test_unpad_response() {
        let valid = vec![1, 2, 3, 0x80, 0, 0, 0];
        assert_eq!(unpad_response(&valid).unwrap(), vec![1, 2, 3]);

        // Trailing zero without a 0x80 marker.
        assert!(unpad_response(&[1, 2, 3, 0, 0, 0]).is_err());

        // Empty input.
        assert!(unpad_response(&[]).is_err());
    }

    #[test]
    fn test_xchacha20_djb_poly1305_roundtrip() {
        let key = [9u8; 32];
        let nonce = [12u8; 24];
        let msg = b"Hello, DNSCrypt!";

        let encrypted = xchacha20_djb_poly1305_encrypt(&key, &nonce, msg);
        assert_eq!(encrypted.len(), msg.len() + 16);

        let decrypted = xchacha20_djb_poly1305_decrypt(&key, &nonce, &encrypted).unwrap();
        assert_eq!(decrypted, msg);
    }

    #[test]
    fn test_xchacha20_djb_poly1305_tamper_detection() {
        let key = [9u8; 32];
        let nonce = [12u8; 24];
        let msg = b"Hello, DNSCrypt!";

        let mut encrypted = xchacha20_djb_poly1305_encrypt(&key, &nonce, msg);
        // Flip a bit in the ciphertext (after the 16-byte tag).
        encrypted[16] ^= 0xff;
        assert!(xchacha20_djb_poly1305_decrypt(&key, &nonce, &encrypted).is_err());
    }
}