Skip to main content

spectreq/core/
ech.rs

1//! Encrypted Client Hello (ECH) support
2//!
3//! ECH (formerly ESNI) encrypts the ClientHello message, hiding the SNI
4//! from network observers. This module provides ECH config fetching and
5//! parsing from DNS HTTPS records.
6
7use serde::{Deserialize, Serialize};
8
9/// ECH configuration
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct EchConfig {
12    /// Raw ECH config bytes
13    pub config: Vec<u8>,
14    /// Retry configs for ECH retry
15    pub retry_configs: Vec<u8>,
16    /// ECH version
17    pub version: u16,
18}
19
20impl EchConfig {
21    /// Create a new ECH config
22    pub fn new(config: Vec<u8>) -> Self {
23        let version = if config.len() >= 2 {
24            u16::from_be_bytes([config[0], config[1]])
25        } else {
26            0xfe0a // Default ECH version
27        };
28        Self {
29            config,
30            retry_configs: Vec::new(),
31            version,
32        }
33    }
34
35    /// Create a new ECH config with retry configs
36    pub fn with_retry(config: Vec<u8>, retry_configs: Vec<u8>) -> Self {
37        let version = if config.len() >= 2 {
38            u16::from_be_bytes([config[0], config[1]])
39        } else {
40            0xfe0a
41        };
42        Self {
43            config,
44            retry_configs,
45            version,
46        }
47    }
48
49    /// Check if ECH config is empty
50    pub fn is_empty(&self) -> bool {
51        self.config.is_empty()
52    }
53
54    /// Get the ECH version
55    pub fn version(&self) -> u16 {
56        self.version
57    }
58}
59
60impl Default for EchConfig {
61    fn default() -> Self {
62        Self {
63            config: Vec::new(),
64            retry_configs: Vec::new(),
65            version: 0xfe0a,
66        }
67    }
68}
69
70/// ECH fetch result
71#[derive(Debug, Clone)]
72pub struct EchFetchResult {
73    /// ECH config if found
74    pub config: Option<EchConfig>,
75    /// Whether the server supports ECH
76    pub supports_ech: bool,
77}
78
79impl EchFetchResult {
80    /// Create a new result with ECH config
81    pub fn with_config(config: EchConfig) -> Self {
82        Self {
83            config: Some(config),
84            supports_ech: true,
85        }
86    }
87
88    /// Create a new result without ECH config
89    pub fn without_config() -> Self {
90        Self {
91            config: None,
92            supports_ech: false,
93        }
94    }
95
96    /// Create a new result indicating ECH support but no config
97    pub fn supported_only() -> Self {
98        Self {
99            config: None,
100            supports_ech: true,
101        }
102    }
103}
104
105/// DNS over HTTPS response for ECH config fetching
106#[derive(Debug, Clone, Deserialize)]
107#[serde(rename_all = "camelCase")]
108#[allow(dead_code)]
109struct DnsResponse {
110    status: u32,
111    answer: Option<Vec<DnsRecord>>,
112}
113
114/// DNS record from DoH response
115#[derive(Debug, Clone, Deserialize)]
116#[allow(dead_code)]
117struct DnsRecord {
118    name: String,
119    #[serde(rename = "type")]
120    rr_type: u16,
121    data: Option<String>,
122}
123
124/// Fetch ECH configuration for a domain
125///
126/// This queries DNS over HTTPS (RFC 8484) for HTTPS records (RFC 9460/9461)
127/// which may contain ECH configs.
128///
129/// The implementation:
130/// 1. Queries a DoH resolver for HTTPS records
131/// 2. Parses SVCB/HTTPS resource records
132/// 3. Extracts ech-config fields from the records
133/// 4. Returns the parsed ECH configs
134///
135/// # Arguments
136///
137/// * `domain` - The domain to fetch ECH configs for
138///
139/// # Returns
140///
141/// Returns an EchFetchResult with the config if found.
142///
143/// # Examples
144///
145/// ```rust,ignore
146/// let result = fetch_ech_configs("cloudflare.com").await;
147/// if let Some(config) = result.config {
148///     println!("Found ECH config, version: 0x{:04x}", config.version());
149/// }
150/// ```
151pub async fn fetch_ech_configs(domain: &str) -> EchFetchResult {
152    // First check if domain is known to support ECH
153    if !domain_supports_ech(domain) {
154        return EchFetchResult::without_config();
155    }
156
157    // Build DoH query for HTTPS records (type 65)
158    // We use Cloudflare's DoH service as the resolver
159    let doh_url = format!("https://1.1.1.1/dns-query?name={}&type=HTTPS", domain);
160
161    // Perform DoH query
162    #[cfg(feature = "ech")]
163    {
164        match fetch_doh_https(&doh_url).await {
165            Ok(configs) => {
166                if let Some(config) = configs.first() {
167                    EchFetchResult::with_config(config.clone())
168                } else {
169                    EchFetchResult::supported_only()
170                }
171            }
172            Err(_) => EchFetchResult::supported_only(),
173        }
174    }
175
176    #[cfg(not(feature = "ech"))]
177    {
178        let _ = doh_url; // Suppress unused warning
179        EchFetchResult::supported_only()
180    }
181}
182
183/// Fetch and parse DNS over HTTPS response for HTTPS records
184#[cfg(feature = "ech")]
185async fn fetch_doh_https(url: &str) -> Result<Vec<EchConfig>, Box<dyn std::error::Error>> {
186    use std::time::Duration;
187
188    // Create HTTP client with timeout
189    let client = reqwest::Client::builder()
190        .timeout(Duration::from_secs(5))
191        .build()?;
192
193    // Make DoH request
194    let response = client
195        .get(url)
196        .header("Accept", "application/dns-json")
197        .send()
198        .await?;
199
200    // Parse JSON response
201    let dns_response: DnsResponse = response.json().await?;
202
203    // Extract ECH configs from HTTPS records
204    let mut configs = Vec::new();
205
206    if let Some(records) = dns_response.answer {
207        for record in records {
208            // HTTPS records have type 65
209            if record.rr_type == 65 {
210                // Parse SVCB/HTTPS record data for ECH config
211                // The data is base64-encoded wire format
212                if let Some(data) = record.data {
213                    if let Some(ech_config) = parse_https_record_ech(&data) {
214                        configs.push(ech_config);
215                    }
216                }
217            }
218        }
219    }
220
221    Ok(configs)
222}
223
224/// Parse ECH config from HTTPS record data (base64 encoded wire format)
225#[cfg(feature = "ech")]
226fn parse_https_record_ech(data: &str) -> Option<EchConfig> {
227    use base64::Engine;
228
229    // Decode base64
230    let wire_data = base64::engine::general_purpose::URL_SAFE_NO_PAD
231        .decode(data)
232        .ok()?;
233
234    // Parse SvcParamKey in wire format (RFC 9460)
235    // SvcParamKey ech_config = 5
236    parse_ech_config_from_wire(&wire_data)
237}
238
239/// Parse ECH config from wire format (binary)
240///
241/// This parses the ECHConfigList format from draft-ietf-tls-esni
242/// The wire format includes:
243/// - SvcParamKey (2 bytes)
244/// - SvcParamValue length (2 bytes)
245/// - ECHConfigList (variable)
246#[allow(dead_code)]
247fn parse_ech_config_from_wire(data: &[u8]) -> Option<EchConfig> {
248    if data.len() < 4 {
249        return None;
250    }
251
252    // Skip SvcParamKey and length, find the actual ECH config
253    let mut pos = 4;
254    if pos >= data.len() {
255        return None;
256    }
257
258    // Try to parse as ECHConfigList
259    // Format: ech_config_list(0), ech_config_list(1), ...
260    // Each ech_config_list has: len(2), ech_config(len)
261    let list_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
262    pos += 2;
263
264    if pos + list_len > data.len() {
265        return None;
266    }
267
268    // Get the first ECH config
269    let config_len = if pos + 2 <= data.len() {
270        u16::from_be_bytes([data[pos], data[pos + 1]]) as usize
271    } else {
272        return None;
273    };
274    pos += 2;
275
276    if pos + config_len > data.len() {
277        return None;
278    }
279
280    let config_bytes = data[pos..pos + config_len].to_vec();
281    Some(EchConfig::new(config_bytes))
282}
283
284/// Parse ECH config from binary data
285///
286/// This parses the ECHConfig format from draft-ietf-tls-esni-13
287/// The format is:
288/// - version (2 bytes) - ECH version (e.g., 0xfe0a for draft-13)
289/// - length (2 bytes) - length of config contents
290/// - config contents (variable) including:
291///   - cipher_suite (2 bytes)
292///   - key_exchange (2 bytes)
293///   - public_key (variable)
294///   - extensions (variable)
295///
296/// Returns the parsed ECH config with version information.
297pub fn parse_ech_config(data: &[u8]) -> Option<EchConfig> {
298    if data.len() < 4 {
299        return None;
300    }
301
302    // Parse version (currently unused but reserved for future use)
303    let _version = u16::from_be_bytes([data[0], data[1]]);
304
305    // Parse length
306    let _length = u16::from_be_bytes([data[2], data[3]]);
307
308    // For now, return the full config with version
309    // A full implementation would parse:
310    // - HpkeKeyExchange (2 bytes)
311    // - HpkeSymmetricCipherSuites (list)
312    // - maximum_name_length (1 byte)
313    // - public_key (variable)
314    // - extensions (variable)
315    Some(EchConfig::with_retry(data.to_vec(), Vec::new()))
316}
317
318/// Check if a domain is known to support ECH
319///
320/// Returns true for domains known to support ECH (Cloudflare, Fastly, etc.)
321pub fn domain_supports_ech(domain: &str) -> bool {
322    let domain = domain.to_lowercase();
323
324    // Cloudflare domains support ECH
325    if domain.ends_with(".cloudflare.com")
326        || domain.ends_with(".cloudflareinsights.com")
327        || domain == "cloudflare.com"
328    {
329        return true;
330    }
331
332    // Fastly domains support ECH
333    if domain.ends_with(".fastly.com") || domain.ends_with(".fastly.net") {
334        return true;
335    }
336
337    // Google domains support ECH
338    if domain.ends_with(".google.com")
339        || domain.ends_with(".googlevideo.com")
340        || domain.ends_with(".googleapis.com")
341        || domain == "google.com"
342    {
343        return true;
344    }
345
346    // Cloudfront domains support ECH
347    if domain.ends_with(".cloudfront.net") || domain.ends_with(".awscloudfront.com") {
348        return true;
349    }
350
351    // Firefox telemetry endpoints
352    if domain.contains("firefox.settings.services.mozilla.com")
353        || domain.contains("shavar.services.mozilla.com")
354    {
355        return true;
356    }
357
358    false
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_ech_config_empty() {
367        let config = EchConfig::default();
368        assert!(config.is_empty());
369    }
370
371    #[test]
372    fn test_ech_config_new() {
373        let config = EchConfig::new(vec![0xfe, 0x0a, 0x00, 0x01, 0x02]);
374        assert!(!config.is_empty());
375        assert_eq!(config.version(), 0xfe0a);
376    }
377
378    #[test]
379    fn test_domain_supports_ech() {
380        // Cloudflare
381        assert!(domain_supports_ech("cloudflare.com"));
382        assert!(domain_supports_ech("example.cloudflare.com"));
383
384        // Fastly
385        assert!(domain_supports_ech("example.fastly.com"));
386
387        // Google
388        assert!(domain_supports_ech("google.com"));
389        assert!(domain_supports_ech("www.google.com"));
390
391        // Not known to support ECH
392        assert!(!domain_supports_ech("example.com"));
393    }
394
395    #[test]
396    fn test_parse_ech_config_empty() {
397        assert!(parse_ech_config(&[]).is_none());
398        assert!(parse_ech_config(&[0x01, 0x02]).is_none());
399    }
400
401    #[test]
402    fn test_parse_ech_config_valid() {
403        // Valid ECH config (minimal)
404        // version: 0xfe0a (draft-13)
405        // length: 0x0003
406        // data: 0x01 0x02 0x03
407        let data = vec![0xfe, 0x0a, 0x00, 0x03, 0x01, 0x02, 0x03];
408        let config = parse_ech_config(&data).unwrap();
409        assert_eq!(config.version, 0xfe0a);
410        assert_eq!(config.config, data);
411    }
412}