dnscrypt 0.2.0

A DNSCrypt v2 client library
Documentation
//! `DNSCrypt` v2 certificate parsing and Ed25519 signature verification.
//!
//! A `DNSCrypt` certificate is fetched as a TXT record from the resolver's
//! provider name (e.g. `2.dnscrypt-cert.quad9.net`) and has the following
//! on-wire layout (all integers big-endian):
//!
//! ```text
//! Offset  Size  Field
//! ------  ----  -----
//!      0     4  Magic ("DNSC")
//!      4     2  ES version (0x00 0x01 = XSalsa20-Poly1305, 0x00 0x02 = XChaCha20-Poly1305)
//!      6     2  Minor version (ignored)
//!      8    64  Ed25519 signature over bytes [72..]
//!     72    32  Short-term resolver public key (X25519)
//!    104     8  Client magic prefix
//!    112     4  Serial number
//!    116     4  Not-before timestamp (Unix seconds, u32)
//!    120     4  Not-after  timestamp (Unix seconds, u32)
//! ```

use aws_lc_rs::signature::{self, UnparsedPublicKey};

use crate::crypto::CipherSuite;

const MIN_KNOWN_TIME: u64 = 1_782_864_000; // July 1st, 2026

/// A parsed and signature-verified `DNSCrypt` v2 certificate.
#[derive(Clone, Debug)]
pub struct DnscryptCert {
    /// Short-term resolver X25519 public key used for key agreement.
    pub resolver_pk: [u8; 32],
    /// 8-byte magic prefix that the client must prepend to every encrypted query.
    pub client_magic: [u8; 8],
    /// Monotonically increasing serial number; prefer the highest among valid certs.
    pub serial: u32,
    /// Certificate validity window start (Unix timestamp, u32 big-endian).
    pub ts_start: u32,
    /// Certificate validity window end (Unix timestamp, u32 big-endian).
    pub ts_end: u32,
    /// AEAD construction this certificate's ES version selects.
    pub cipher_suite: CipherSuite,
}

#[must_use]
/// Parse and verify a raw `DNSCrypt` certificate blob.
///
/// Returns `None` if:
/// - the blob is shorter than 124 bytes
/// - the magic or ES-version fields are wrong
/// - the Ed25519 signature does not verify against `provider_pk`
/// - the certificate is outside its validity window (when the system clock
///   looks trustworthy, i.e. `now > 2023-11-14`)
pub fn verify_cert(cert: &[u8], provider_pk: &[u8; 32]) -> Option<DnscryptCert> {
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    verify_cert_at(cert, provider_pk, now)
}

/// Core of [`verify_cert`], with the "current time" passed in explicitly so
/// the clock-sanity and validity-window branches are deterministically
/// testable.
fn verify_cert_at(cert: &[u8], provider_pk: &[u8; 32], now: u64) -> Option<DnscryptCert> {
    if cert.len() < 124 {
        return None;
    }
    // Magic: "DNSC"
    if cert.get(0..4)? != b"DNSC" {
        return None;
    }
    // ES version 0x0001 = XSalsa20-Poly1305, 0x0002 = XChaCha20-Poly1305
    let cipher_suite = match cert.get(4..6)? {
        [0x00, 0x01] => CipherSuite::XSalsa20Poly1305,
        [0x00, 0x02] => CipherSuite::XChaCha20Poly1305,
        _ => return None,
    };

    // Verify Ed25519 signature.  The signature covers cert[72..] and is stored
    // at cert[8..72].
    let pk = UnparsedPublicKey::new(&signature::ED25519, provider_pk);
    pk.verify(cert.get(72..)?, cert.get(8..72)?).ok()?;

    let resolver_pk: [u8; 32] = cert.get(72..104)?.try_into().ok()?;
    let client_magic: [u8; 8] = cert.get(104..112)?.try_into().ok()?;

    let serial = u32::from_be_bytes(cert.get(112..116)?.try_into().ok()?);
    let ts_start = u32::from_be_bytes(cert.get(116..120)?.try_into().ok()?);
    let ts_end = u32::from_be_bytes(cert.get(120..124)?.try_into().ok()?);

    if now < MIN_KNOWN_TIME {
        return None; // System clock is compromised or severely desynced.
    }
    if now < u64::from(ts_start) || now > u64::from(ts_end) {
        return None;
    }

    Some(DnscryptCert {
        resolver_pk,
        client_magic,
        serial,
        ts_start,
        ts_end,
        cipher_suite,
    })
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::cast_possible_truncation,
    clippy::indexing_slicing,
    clippy::arithmetic_side_effects,
    clippy::as_conversions
)]
mod tests {
    use super::*;
    use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair};

    fn generate_test_cert(
        modify_signature: bool,
        ts_start: u32,
        ts_end: u32,
        magic: [u8; 4],
        es_version: [u8; 2],
    ) -> (Vec<u8>, [u8; 32]) {
        let mut seed = [0u8; 32];
        aws_lc_rs::rand::fill(&mut seed).expect("aws-lc-rs RNG failed");
        let signing_key = Ed25519KeyPair::from_seed_unchecked(&seed).expect("valid seed");
        let provider_pk: [u8; 32] = signing_key
            .public_key()
            .as_ref()
            .try_into()
            .expect("ed25519 public key is 32 bytes");

        let mut cert = vec![0u8; 124];
        cert[0..4].copy_from_slice(&magic);
        cert[4..6].copy_from_slice(&es_version);

        cert[72..104].copy_from_slice(&[1u8; 32]);
        cert[104..112].copy_from_slice(&[2u8; 8]);
        cert[112..116].copy_from_slice(&1u32.to_be_bytes());
        cert[116..120].copy_from_slice(&ts_start.to_be_bytes());
        cert[120..124].copy_from_slice(&ts_end.to_be_bytes());

        let mut signature: [u8; 64] = signing_key
            .sign(&cert[72..])
            .as_ref()
            .try_into()
            .expect("ed25519 signature is 64 bytes");
        if modify_signature {
            signature[0] ^= 0xFF;
        }

        cert[8..72].copy_from_slice(&signature);

        (cert, provider_pk)
    }

    #[test]
    fn test_verify_cert_valid() {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs() as u32;

        let (cert, pk) = generate_test_cert(false, now - 1000, now + 1000, *b"DNSC", [0x00, 0x02]);
        let parsed = verify_cert(&cert, &pk);
        assert!(parsed.is_some());
        let parsed = parsed.unwrap();
        assert_eq!(parsed.client_magic, [2u8; 8]);
        assert_eq!(parsed.serial, 1);
        assert_eq!(parsed.cipher_suite, CipherSuite::XChaCha20Poly1305);
    }

    #[test]
    fn test_verify_cert_xsalsa20_suite() {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs() as u32;

        let (cert, pk) = generate_test_cert(false, now - 1000, now + 1000, *b"DNSC", [0x00, 0x01]);
        let parsed = verify_cert(&cert, &pk).unwrap();
        assert_eq!(parsed.cipher_suite, CipherSuite::XSalsa20Poly1305);
    }

    #[test]
    fn test_verify_cert_invalid_signature() {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs() as u32;

        let (cert, pk) = generate_test_cert(true, now - 1000, now + 1000, *b"DNSC", [0x00, 0x02]);
        let parsed = verify_cert(&cert, &pk);
        assert!(parsed.is_none());
    }

    #[test]
    fn test_verify_cert_expired() {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs() as u32;

        // ts_end is in the past
        let (cert1, pk1) =
            generate_test_cert(false, now - 2000, now - 1000, *b"DNSC", [0x00, 0x02]);
        assert!(verify_cert(&cert1, &pk1).is_none());

        // ts_start is in the future
        let (cert2, pk2) =
            generate_test_cert(false, now + 1000, now + 2000, *b"DNSC", [0x00, 0x02]);
        assert!(verify_cert(&cert2, &pk2).is_none());
    }

    #[test]
    fn test_verify_cert_at_rejects_untrustworthy_clock() {
        // A `now` before MIN_KNOWN_TIME means the system clock itself looks
        // compromised or desynced; the cert must be rejected regardless of
        // its own validity window.
        let (cert, pk) = generate_test_cert(false, 0, u32::MAX, *b"DNSC", [0x00, 0x02]);
        assert!(verify_cert_at(&cert, &pk, MIN_KNOWN_TIME - 1).is_none());
        assert!(verify_cert_at(&cert, &pk, MIN_KNOWN_TIME).is_some());
    }

    #[test]
    fn test_verify_cert_invalid_magic_or_version() {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs() as u32;

        let (cert1, pk1) =
            generate_test_cert(false, now - 1000, now + 1000, *b"DNXX", [0x00, 0x02]);
        assert!(verify_cert(&cert1, &pk1).is_none());

        let (cert2, pk2) =
            generate_test_cert(false, now - 1000, now + 1000, *b"DNSC", [0x00, 0x03]);
        assert!(verify_cert(&cert2, &pk2).is_none());
    }

    #[test]
    fn test_verify_cert_truncated() {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs() as u32;

        let (mut cert, pk) =
            generate_test_cert(false, now - 1000, now + 1000, *b"DNSC", [0x00, 0x02]);
        cert.truncate(123);
        assert!(verify_cert(&cert, &pk).is_none());
    }
}