dnscrypt 0.2.0

A DNSCrypt v2 client library
Documentation
//! `dnscrypt` — an asynchronous (Tokio-backed) `DNSCrypt` v2 client library.
//!
//! # Overview
//!
//! This library implements the [DNSCrypt v2 protocol] for encrypted, authenticated
//! DNS resolution. X25519, Ed25519, constant-time comparison, and randomness are
//! backed by `aws-lc-rs`; the certificate's ES version selects between
//! `XChaCha20-Poly1305` and `XSalsa20-Poly1305` (neither supported by
//! `aws-lc-rs`), provided by the pure-Rust `chacha20`/`salsa20`/`poly1305`
//! crates.
//!
//! 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` / `HSalsa20` | Key derivation from the raw DH shared secret |
//! | XChaCha20-Poly1305 / XSalsa20-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
//!
//! ```no_run
//! use dnscrypt::{resolve, Error, HARDCODED_RESOLVERS};
//! # async fn run() -> Result<(), Error> {
//! let ips = resolve(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.
//! 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)]
#![allow(clippy::multiple_crate_versions)]

#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

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;

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

/// Fill and return a fresh array of cryptographically secure random bytes.
///
/// # Panics
///
/// Panics if the OS random number generator is unavailable. This is treated
/// as an unrecoverable environment fault: silently continuing without a
/// working CSPRNG would mean generating predictable session keys and
/// nonces, which is a far worse outcome than aborting.
#[allow(clippy::expect_used)]
pub(crate) fn random_bytes<const N: usize>() -> [u8; N] {
    let mut buf = [0u8; N];
    aws_lc_rs::rand::fill(&mut buf).expect("aws-lc-rs RNG failed: no OS CSPRNG available");
    buf
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::indexing_slicing,
    clippy::arithmetic_side_effects,
    clippy::as_conversions
)]
mod tests {
    use crate::crypto::{CipherSuite, djb_poly1305_decrypt, 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_djb_poly1305_roundtrip() {
        for suite in [
            CipherSuite::XChaCha20Poly1305,
            CipherSuite::XSalsa20Poly1305,
        ] {
            let key = [9u8; 32];
            let nonce = [12u8; 24];
            let msg = b"Hello, DNSCrypt!";

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

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

    #[test]
    fn test_djb_poly1305_tamper_detection() {
        for suite in [
            CipherSuite::XChaCha20Poly1305,
            CipherSuite::XSalsa20Poly1305,
        ] {
            let key = [9u8; 32];
            let nonce = [12u8; 24];
            let msg = b"Hello, DNSCrypt!";

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