Skip to main content

termivar_scanner/web_runtime/
tls_observation.rs

1//! Bounded reduction of TLS metadata attached to existing validated responses.
2
3use std::{
4    sync::{Arc, Mutex, MutexGuard},
5    time::{SystemTime, UNIX_EPOCH},
6};
7
8use sha2::{Digest, Sha256};
9use x509_parser::{
10    certificate::X509CertificateParser,
11    extensions::{GeneralName, SubjectAlternativeName},
12    nom::Parser,
13    oid_registry::OID_X509_EXT_SUBJECT_ALT_NAME,
14    prelude::FromDer,
15};
16
17/// Schema of the immutable TLS observation audit.
18pub const TLS_OBSERVATION_AUDIT_SCHEMA: &str = "security.tls-observation-audit/v1";
19/// Policy governing observation of an existing validated connection.
20pub const TLS_OBSERVATION_POLICY_ID: &str = "termivar.existing-connection-tls-observation/v1";
21/// Value used for facts that reqwest's public metadata surface does not expose.
22pub const TLS_OBSERVATION_BACKEND_LIMIT: &str = "not_exposed_by_backend";
23/// Revocation scope of this passive observer.
24pub const TLS_OBSERVATION_REVOCATION_STATUS: &str = "not_checked";
25/// Scope of the standard transport-validation statement.
26pub const TLS_OBSERVATION_VALIDATION_SCOPE: &str = "successful_https_response_connection/v1";
27/// Privacy-preserving source scope for every retained observation.
28pub const TLS_OBSERVATION_SOURCE_SCOPE: &str = "assessment_exact_origin_existing_connections/v1";
29/// Assurance attached to the local wall clock used for validity classification.
30pub const TLS_OBSERVATION_CLOCK_ASSURANCE: &str = "local_system_clock_not_independently_verified";
31
32/// Maximum peer leaf DER bytes inspected per response.
33pub const MAX_TLS_LEAF_CERTIFICATE_BYTES: usize = 64 * 1024;
34/// Maximum unique peer leaves retained per assessment.
35pub const MAX_TLS_RETAINED_LEAF_OBSERVATIONS: usize = 16;
36/// Maximum SAN entries classified per retained peer leaf.
37pub const MAX_TLS_SAN_ENTRIES_PER_CERTIFICATE: usize = 256;
38/// Maximum shallow X.509 extension records admitted per peer leaf.
39pub const MAX_TLS_CERTIFICATE_EXTENSIONS: usize = 64;
40/// Maximum raw Subject Alternative Name extension bytes parsed per peer leaf.
41pub const MAX_TLS_SUBJECT_ALTERNATIVE_NAME_BYTES: usize = 16 * 1024;
42
43/// Scheme selected by the assessment authority.
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum TlsObservationTargetScheme {
46    /// Plain HTTP, for which TLS is not applicable.
47    Http,
48    /// HTTPS through the ordinary verified transport path.
49    Https,
50    /// A scheme outside the web-assessment contract.
51    Other,
52}
53
54impl TlsObservationTargetScheme {
55    pub(crate) fn from_scheme(value: &str) -> Self {
56        match value {
57            "http" => Self::Http,
58            "https" => Self::Https,
59            _ => Self::Other,
60        }
61    }
62
63    /// Stable wire spelling.
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::Http => "http",
67            Self::Https => "https",
68            Self::Other => "other",
69        }
70    }
71}
72
73/// Relationship between a leaf's validity window and its observation instant.
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75pub enum TlsCertificateTimeStatus {
76    /// Observation fell within the declared validity window.
77    ValidAtObservation,
78    /// Observation preceded the declared validity window.
79    NotYetValidAtObservation,
80    /// Observation followed the declared validity window.
81    ExpiredAtObservation,
82}
83
84impl TlsCertificateTimeStatus {
85    /// Stable wire spelling.
86    pub const fn as_str(self) -> &'static str {
87        match self {
88            Self::ValidAtObservation => "valid_at_observation",
89            Self::NotYetValidAtObservation => "not_yet_valid_at_observation",
90            Self::ExpiredAtObservation => "expired_at_observation",
91        }
92    }
93}
94
95/// Value-safe reduction of one unique parsed peer leaf certificate.
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct TlsLeafObservation {
98    sha256: String,
99    byte_length: u64,
100    not_before_epoch_seconds: i64,
101    not_after_epoch_seconds: i64,
102    observed_at_epoch_seconds: i64,
103    dns_san_count: u16,
104    ip_san_count: u16,
105    other_san_count: u16,
106    san_count_truncated: bool,
107    certificate_time_status: TlsCertificateTimeStatus,
108    response_occurrence_count: u64,
109}
110
111impl TlsLeafObservation {
112    /// Full lower-case SHA-256 of the exact observed leaf DER bytes.
113    pub fn sha256(&self) -> &str {
114        &self.sha256
115    }
116    /// Exact observed leaf DER byte length.
117    pub const fn byte_length(&self) -> u64 {
118        self.byte_length
119    }
120    /// Declared not-before time as Unix epoch seconds.
121    pub const fn not_before_epoch_seconds(&self) -> i64 {
122        self.not_before_epoch_seconds
123    }
124    /// Declared not-after time as Unix epoch seconds.
125    pub const fn not_after_epoch_seconds(&self) -> i64 {
126        self.not_after_epoch_seconds
127    }
128    /// First observation time as Unix epoch seconds.
129    pub const fn observed_at_epoch_seconds(&self) -> i64 {
130        self.observed_at_epoch_seconds
131    }
132    /// Number of classified DNS SANs, without retaining their values.
133    pub const fn dns_san_count(&self) -> u16 {
134        self.dns_san_count
135    }
136    /// Number of classified IP SANs, without retaining their values.
137    pub const fn ip_san_count(&self) -> u16 {
138        self.ip_san_count
139    }
140    /// Number of classified SANs of all other kinds.
141    pub const fn other_san_count(&self) -> u16 {
142        self.other_san_count
143    }
144    /// Whether SAN classification stopped at its explicit ceiling.
145    pub const fn san_count_truncated(&self) -> bool {
146        self.san_count_truncated
147    }
148    /// Validity-window relationship at first observation.
149    pub const fn certificate_time_status(&self) -> TlsCertificateTimeStatus {
150        self.certificate_time_status
151    }
152    /// Successful HTTPS responses carrying this same leaf identity.
153    pub const fn response_occurrence_count(&self) -> u64 {
154        self.response_occurrence_count
155    }
156    /// Whether the normal transport accepted the response connection.
157    pub const fn standard_transport_validation_succeeded(&self) -> bool {
158        true
159    }
160}
161
162/// Immutable assessment-level passive TLS audit.
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct WebAssessmentTlsObservationAudit {
165    target_scheme: TlsObservationTargetScheme,
166    successful_https_response_count: u64,
167    plaintext_response_count: u64,
168    tls_info_unavailable_count: u64,
169    malformed_certificate_count: u64,
170    certificate_limit_rejection_count: u64,
171    unretained_leaf_response_count: u64,
172    leaf_observations: Vec<TlsLeafObservation>,
173}
174
175impl WebAssessmentTlsObservationAudit {
176    /// Audit schema.
177    pub const fn schema(&self) -> &'static str {
178        TLS_OBSERVATION_AUDIT_SCHEMA
179    }
180    /// Observation policy.
181    pub const fn policy(&self) -> &'static str {
182        TLS_OBSERVATION_POLICY_ID
183    }
184    /// An audit exists only when the capability was selected.
185    pub const fn selected(&self) -> bool {
186        true
187    }
188    /// This capability dispatches no additional requests.
189    pub const fn additional_request_count(&self) -> u64 {
190        0
191    }
192    /// Assessment target scheme.
193    pub const fn target_scheme(&self) -> TlsObservationTargetScheme {
194        self.target_scheme
195    }
196    /// Source scope shared by all responses represented by this audit.
197    pub const fn observation_source_scope(&self) -> &'static str {
198        TLS_OBSERVATION_SOURCE_SCOPE
199    }
200    /// Assurance for the wall clock used by observation-time classifications.
201    pub const fn observation_clock_assurance(&self) -> &'static str {
202        TLS_OBSERVATION_CLOCK_ASSURANCE
203    }
204    /// Successful HTTPS responses observed after ordinary validation.
205    pub const fn successful_https_response_count(&self) -> u64 {
206        self.successful_https_response_count
207    }
208    /// Successful plain HTTP responses, for which TLS is not applicable.
209    pub const fn plaintext_response_count(&self) -> u64 {
210        self.plaintext_response_count
211    }
212    /// HTTPS successes for which the backend exposed no leaf information.
213    pub const fn tls_info_unavailable_count(&self) -> u64 {
214        self.tls_info_unavailable_count
215    }
216    /// Leaf values rejected because they were not one exact parsed certificate.
217    pub const fn malformed_certificate_count(&self) -> u64 {
218        self.malformed_certificate_count
219    }
220    /// Leaf values rejected by the certificate, extension, or SAN ceilings.
221    pub const fn certificate_limit_rejection_count(&self) -> u64 {
222        self.certificate_limit_rejection_count
223    }
224    /// Parsed leaf-bearing responses omitted after the unique-leaf ceiling.
225    pub const fn unretained_leaf_response_count(&self) -> u64 {
226        self.unretained_leaf_response_count
227    }
228    /// Unique retained leaves in first-observation order.
229    pub fn leaf_observations(&self) -> &[TlsLeafObservation] {
230        &self.leaf_observations
231    }
232    /// Negotiated TLS protocol is not exposed by this backend seam.
233    pub const fn protocol(&self) -> &'static str {
234        TLS_OBSERVATION_BACKEND_LIMIT
235    }
236    /// Negotiated cipher suite is not exposed by this backend seam.
237    pub const fn cipher_suite(&self) -> &'static str {
238        TLS_OBSERVATION_BACKEND_LIMIT
239    }
240    /// Negotiated ALPN protocol is not exposed by this backend seam.
241    pub const fn alpn_protocol(&self) -> &'static str {
242        TLS_OBSERVATION_BACKEND_LIMIT
243    }
244    /// The full peer or validated chain is not exposed by this backend seam.
245    pub const fn full_chain(&self) -> &'static str {
246        TLS_OBSERVATION_BACKEND_LIMIT
247    }
248    /// Connection reuse is not exposed and cannot be inferred from a repeated leaf.
249    pub const fn connection_reuse(&self) -> &'static str {
250        TLS_OBSERVATION_BACKEND_LIMIT
251    }
252    /// Session resumption is not exposed by this backend seam.
253    pub const fn session_resumption(&self) -> &'static str {
254        TLS_OBSERVATION_BACKEND_LIMIT
255    }
256    /// Full-versus-resumed handshake kind is not exposed by this backend seam.
257    pub const fn handshake_kind(&self) -> &'static str {
258        TLS_OBSERVATION_BACKEND_LIMIT
259    }
260    /// Revocation status for this capability.
261    pub const fn revocation(&self) -> &'static str {
262        TLS_OBSERVATION_REVOCATION_STATUS
263    }
264    /// Scope of the standard transport-validation statement.
265    pub const fn standard_transport_validation_scope(&self) -> &'static str {
266        TLS_OBSERVATION_VALIDATION_SCOPE
267    }
268}
269
270#[derive(Default)]
271struct TlsObservationState {
272    successful_https_response_count: u64,
273    plaintext_response_count: u64,
274    tls_info_unavailable_count: u64,
275    malformed_certificate_count: u64,
276    certificate_limit_rejection_count: u64,
277    unretained_leaf_response_count: u64,
278    leaf_observations: Vec<TlsLeafObservation>,
279}
280
281/// Shared assessment-owned sink used by each selected broker connection pool.
282#[derive(Clone)]
283pub(crate) struct TlsObservationCollector {
284    target_scheme: TlsObservationTargetScheme,
285    state: Arc<Mutex<TlsObservationState>>,
286}
287
288impl TlsObservationCollector {
289    pub(crate) fn new(target_scheme: &str) -> Self {
290        Self {
291            target_scheme: TlsObservationTargetScheme::from_scheme(target_scheme),
292            state: Arc::new(Mutex::new(TlsObservationState::default())),
293        }
294    }
295
296    pub(crate) fn observe_plaintext_response(&self) {
297        let mut state = self.state();
298        state.plaintext_response_count = state.plaintext_response_count.saturating_add(1);
299    }
300
301    pub(crate) fn observe_https_response(&self, peer_leaf_der: Option<&[u8]>) {
302        self.observe_https_response_at(peer_leaf_der, epoch_seconds(SystemTime::now()));
303    }
304
305    fn observe_https_response_at(&self, peer_leaf_der: Option<&[u8]>, observed_at: i64) {
306        let mut state = self.state();
307        state.successful_https_response_count =
308            state.successful_https_response_count.saturating_add(1);
309        let Some(der) = peer_leaf_der else {
310            state.tls_info_unavailable_count = state.tls_info_unavailable_count.saturating_add(1);
311            return;
312        };
313        if der.len() > MAX_TLS_LEAF_CERTIFICATE_BYTES {
314            state.certificate_limit_rejection_count =
315                state.certificate_limit_rejection_count.saturating_add(1);
316            return;
317        }
318        let observation = match parse_leaf(der, observed_at) {
319            Ok(observation) => observation,
320            Err(LeafParseError::Malformed) => {
321                state.malformed_certificate_count =
322                    state.malformed_certificate_count.saturating_add(1);
323                return;
324            },
325            Err(LeafParseError::LimitRejected) => {
326                state.certificate_limit_rejection_count =
327                    state.certificate_limit_rejection_count.saturating_add(1);
328                return;
329            },
330        };
331        if let Some(existing) = state.leaf_observations.iter_mut().find(|existing| {
332            existing.sha256 == observation.sha256 && existing.byte_length == observation.byte_length
333        }) {
334            existing.response_occurrence_count =
335                existing.response_occurrence_count.saturating_add(1);
336        } else if state.leaf_observations.len() < MAX_TLS_RETAINED_LEAF_OBSERVATIONS {
337            state.leaf_observations.push(observation);
338        } else {
339            state.unretained_leaf_response_count =
340                state.unretained_leaf_response_count.saturating_add(1);
341        }
342    }
343
344    pub(crate) fn audit(&self) -> WebAssessmentTlsObservationAudit {
345        let state = self.state();
346        WebAssessmentTlsObservationAudit {
347            target_scheme: self.target_scheme,
348            successful_https_response_count: state.successful_https_response_count,
349            plaintext_response_count: state.plaintext_response_count,
350            tls_info_unavailable_count: state.tls_info_unavailable_count,
351            malformed_certificate_count: state.malformed_certificate_count,
352            certificate_limit_rejection_count: state.certificate_limit_rejection_count,
353            unretained_leaf_response_count: state.unretained_leaf_response_count,
354            leaf_observations: state.leaf_observations.clone(),
355        }
356    }
357
358    #[cfg(test)]
359    pub(crate) fn shares_state_with(&self, other: &Self) -> bool {
360        Arc::ptr_eq(&self.state, &other.state)
361    }
362
363    fn state(&self) -> MutexGuard<'_, TlsObservationState> {
364        self.state
365            .lock()
366            .unwrap_or_else(std::sync::PoisonError::into_inner)
367    }
368}
369
370#[derive(Clone, Copy, Debug, Eq, PartialEq)]
371enum LeafParseError {
372    Malformed,
373    LimitRejected,
374}
375
376fn parse_leaf(der: &[u8], observed_at: i64) -> Result<TlsLeafObservation, LeafParseError> {
377    let mut parser = X509CertificateParser::new().with_deep_parse_extensions(false);
378    let (remainder, certificate) = parser.parse(der).map_err(|_| LeafParseError::Malformed)?;
379    if !remainder.is_empty() {
380        return Err(LeafParseError::Malformed);
381    }
382    let extensions = certificate.extensions();
383    if extensions.len() > MAX_TLS_CERTIFICATE_EXTENSIONS {
384        return Err(LeafParseError::LimitRejected);
385    }
386    let not_before = certificate.validity().not_before.timestamp();
387    let not_after = certificate.validity().not_after.timestamp();
388    if not_after < not_before {
389        return Err(LeafParseError::Malformed);
390    }
391    let raw_san = select_unique_san_value(
392        extensions.len(),
393        extensions
394            .iter()
395            .filter(|extension| extension.oid == OID_X509_EXT_SUBJECT_ALT_NAME)
396            .map(|extension| extension.value),
397    )?;
398    let (dns_san_count, ip_san_count, other_san_count, san_count_truncated) =
399        classify_san(raw_san)?;
400    let certificate_time_status = if observed_at < not_before {
401        TlsCertificateTimeStatus::NotYetValidAtObservation
402    } else if observed_at > not_after {
403        TlsCertificateTimeStatus::ExpiredAtObservation
404    } else {
405        TlsCertificateTimeStatus::ValidAtObservation
406    };
407    Ok(TlsLeafObservation {
408        sha256: format!("{:x}", Sha256::digest(der)),
409        byte_length: u64::try_from(der.len()).unwrap_or(u64::MAX),
410        not_before_epoch_seconds: not_before,
411        not_after_epoch_seconds: not_after,
412        observed_at_epoch_seconds: observed_at,
413        dns_san_count,
414        ip_san_count,
415        other_san_count,
416        san_count_truncated,
417        certificate_time_status,
418        response_occurrence_count: 1,
419    })
420}
421
422fn select_unique_san_value<'a>(
423    extension_count: usize,
424    mut san_values: impl Iterator<Item = &'a [u8]>,
425) -> Result<Option<&'a [u8]>, LeafParseError> {
426    if extension_count > MAX_TLS_CERTIFICATE_EXTENSIONS {
427        return Err(LeafParseError::LimitRejected);
428    }
429    let first = san_values.next();
430    if san_values.next().is_some() {
431        return Err(LeafParseError::Malformed);
432    }
433    Ok(first)
434}
435
436fn classify_san(raw_san: Option<&[u8]>) -> Result<(u16, u16, u16, bool), LeafParseError> {
437    let Some(raw_san) = raw_san else {
438        return Ok((0, 0, 0, false));
439    };
440    if raw_san.len() > MAX_TLS_SUBJECT_ALTERNATIVE_NAME_BYTES {
441        return Err(LeafParseError::LimitRejected);
442    }
443    let (remainder, san) =
444        SubjectAlternativeName::from_der(raw_san).map_err(|_| LeafParseError::Malformed)?;
445    if !remainder.is_empty() {
446        return Err(LeafParseError::Malformed);
447    }
448    let mut dns = 0_u16;
449    let mut ip = 0_u16;
450    let mut other = 0_u16;
451    let mut truncated = false;
452    for (index, name) in san.general_names.iter().enumerate() {
453        if index >= MAX_TLS_SAN_ENTRIES_PER_CERTIFICATE {
454            truncated = true;
455            break;
456        }
457        match name {
458            GeneralName::DNSName(_) => dns = dns.saturating_add(1),
459            GeneralName::IPAddress(value) if matches!(value.len(), 4 | 16) => {
460                ip = ip.saturating_add(1);
461            },
462            _ => other = other.saturating_add(1),
463        }
464    }
465    Ok((dns, ip, other, truncated))
466}
467
468fn epoch_seconds(now: SystemTime) -> i64 {
469    match now.duration_since(UNIX_EPOCH) {
470        Ok(value) => i64::try_from(value.as_secs()).unwrap_or(i64::MAX),
471        Err(value) => -i64::try_from(value.duration().as_secs()).unwrap_or(i64::MAX),
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use base64::{engine::general_purpose::STANDARD, Engine as _};
479
480    const EXPIRED_LEAF: &str = concat!(
481        "MIIFWzCCBEOgAwIBAgISAyBIAwu7NBD5CTxX8suDCMgFMA0GCSqGSIb3DQEBCwUAMEox",
482        "CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQDExpMZXQn",
483        "cyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0xOTA3MTIxMTEyMzBaFw0xOTEwMTAxMTEy",
484        "MzBaMB0xGzAZBgNVBAMTEmxpc3RzLmZvci1vdXIuaW5mbzCCASIwDQYJKoZIhvcNAQEB",
485        "BQADggEPADCCAQoCggEBAMVoti34X46DaI2nX24C+aZ2OfkmhKbidiXiRTon1MLSMGl1",
486        "oNW9MyRyYYCzP4j6DNKChJnr8ZnVShh2oZD+yHWP9lpnXMGkbsUxejRMU9hnaAB50pXR",
487        "IDAzavkVFCguFlJ8nKkv/Y1Avlw7tc2aZOd3lOZBEr8gJ8mRDGqqsNU+Z12I6slEstz",
488        "GMpsq6AewCVw4lMjdWWgugzUrxQTRAsG87on6gOiQH2cMODN3L7Fq4KOLQIjb3/luQh",
489        "AQhpdKmEGFLin3c+f5or3thCDuwwDtOU1lZf+8t9S8pZPLrZrIs6H2xjXqCRuUY7iRN",
490        "bO18Ukc6rlDYhBj9LT+cpmBbHECAwEAAaOCAmYwggJiMA4GA1UdDwEB/wQEAwIFoDAd",
491        "BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4E",
492        "FgQUJj2pvRtl3GloH3He6FX1ds3X0VEwHwYDVR0jBBgwFoAUqEpqYwR93brm0Tm3pkVl",
493        "7/Oo7KEwbwYIKwYBBQUHAQEEYzBhMC4GCCsGAQUFBzABhiJodHRwOi8vb2NzcC5pbnQt",
494        "eDMubGV0c2VuY3J5cHQub3JnMC8GCCsGAQUFBzAChiNodHRwOi8vY2VydC5pbnQteDMu",
495        "bGV0c2VuY3J5cHQub3JnLzAdBgNVHREEFjAUghJsaXN0cy5mb3Itb3VyLmluZm8wTAYD",
496        "VR0gBEUwQzAIBgZngQwBAgEwNwYLKwYBBAGC3xMBAQEwKDAmBggrBgEFBQcCARYaaHR0",
497        "cDovL2Nwcy5sZXRzZW5jcnlwdC5vcmcwggEDBgorBgEEAdZ5AgQCBIH0BIHxAO8AdgAp",
498        "PFGWVMg5ZbqqUPxYB9S3b79Yeily3KTDDPTlRUf0eAAAAWvmGV7yAAAEAwBHMEUCICQL",
499        "2Sm14aCMLxX9a9RbySgyBfichMRdbu6QA2Mbrl4eAiEA1vgJ7snqUWCgoqEE3SEfK3io",
500        "MopzWBsPvG6LdCuCMRAAdQBvU3asMfAxGdiZAKRRFf93FRwR2QLBACkGjbIImjfZEwAA",
501        "AWvmGV9oAAAEAwBGMEQCIExGqw3Lo0nSCyUuTRf92FgGASwWYji5UGnXuYnpJrAvAiBw",
502        "8AWVag8fzZ4ogAhY9EFRNdLrUcBjStipL888vyuxKzANBgkqhkiG9w0BAQsFAAOCAQEA",
503        "F8BBLDvSWZg57B6aDtzfUTSGetCYs3k0vJqCJlL+Pz7/UruCSsojQzp5R6jvvgYQ83Ma",
504        "Idwe2mgt+OCQB5v7ylctyBzBmYIw9nPnxEC7HlcJL2K/k5ZjJFRnv4kV1Si8+TIpEAV0",
505        "ksf39KGKemG8kGi4GXV1v03zSv0p8aCarpuoSKBJ4qlB0CvmS2MqV4KnzO0O2h0c/ZQ4",
506        "jg7l53eiN7VPdRMMO1DRw+MaW6I/hEZp+oZQ7hhKXgKUBvF4IGwyrfyIZ8AeWKG4IP98",
507        "COgyRbz7qtrAVevRKCM0ZC2t04A2Fcix40FKEeiE093Aj3cweMYxNLPgwgQP8Xu3kA5QEw=="
508    );
509
510    fn der_length(length: usize) -> Vec<u8> {
511        if length < 128 {
512            return vec![length as u8];
513        }
514        let bytes = length.to_be_bytes();
515        let first = bytes.iter().position(|byte| *byte != 0).unwrap();
516        let significant = &bytes[first..];
517        let mut encoded = vec![0x80 | significant.len() as u8];
518        encoded.extend_from_slice(significant);
519        encoded
520    }
521
522    fn san_sequence(entries: &[(u8, Vec<u8>)]) -> Vec<u8> {
523        let mut content = Vec::new();
524        for (tag, value) in entries {
525            content.push(*tag);
526            content.extend(der_length(value.len()));
527            content.extend(value);
528        }
529        let mut encoded = vec![0x30];
530        encoded.extend(der_length(content.len()));
531        encoded.extend(content);
532        encoded
533    }
534
535    #[test]
536    fn reduces_and_deduplicates_leaf_without_names() {
537        let der = STANDARD.decode(EXPIRED_LEAF).unwrap();
538        let collector = TlsObservationCollector::new("https");
539        collector.observe_https_response_at(Some(&der), 1_700_000_000);
540        collector.observe_https_response_at(Some(&der), 1_700_000_001);
541        let audit = collector.audit();
542        assert_eq!(audit.successful_https_response_count(), 2);
543        assert_eq!(audit.leaf_observations().len(), 1);
544        let leaf = &audit.leaf_observations()[0];
545        assert_eq!(leaf.byte_length(), 1_375);
546        assert_eq!(leaf.dns_san_count(), 1);
547        assert_eq!(leaf.response_occurrence_count(), 2);
548        assert_eq!(
549            leaf.certificate_time_status(),
550            TlsCertificateTimeStatus::ExpiredAtObservation
551        );
552        assert!(!format!("{audit:?}").contains("lists.for-our.info"));
553        assert_eq!(audit.connection_reuse(), TLS_OBSERVATION_BACKEND_LIMIT);
554        assert_eq!(audit.session_resumption(), TLS_OBSERVATION_BACKEND_LIMIT);
555        assert_eq!(audit.handshake_kind(), TLS_OBSERVATION_BACKEND_LIMIT);
556    }
557
558    #[test]
559    fn unavailable_malformed_oversized_and_plaintext_are_distinct() {
560        let collector = TlsObservationCollector::new("https");
561        collector.observe_plaintext_response();
562        collector.observe_https_response_at(None, 0);
563        collector.observe_https_response_at(Some(b"not a certificate"), 0);
564        collector.observe_https_response_at(Some(&vec![0; MAX_TLS_LEAF_CERTIFICATE_BYTES + 1]), 0);
565        let audit = collector.audit();
566        assert_eq!(audit.plaintext_response_count(), 1);
567        assert_eq!(audit.successful_https_response_count(), 3);
568        assert_eq!(audit.tls_info_unavailable_count(), 1);
569        assert_eq!(audit.malformed_certificate_count(), 1);
570        assert_eq!(audit.certificate_limit_rejection_count(), 1);
571        assert_eq!(audit.alpn_protocol(), TLS_OBSERVATION_BACKEND_LIMIT);
572        assert_eq!(audit.session_resumption(), TLS_OBSERVATION_BACKEND_LIMIT);
573    }
574
575    #[test]
576    fn clones_share_one_bounded_state() {
577        let first = TlsObservationCollector::new("http");
578        let second = first.clone();
579        assert!(first.shares_state_with(&second));
580        first.observe_plaintext_response();
581        second.observe_plaintext_response();
582        assert_eq!(first.audit().plaintext_response_count(), 2);
583        assert_eq!(second.audit().target_scheme().as_str(), "http");
584    }
585
586    #[test]
587    fn shallow_extension_and_san_work_bounds_reject_mutations() {
588        let san = san_sequence(&[(0x82, b"example.test".to_vec())]);
589        assert!(select_unique_san_value(
590            MAX_TLS_CERTIFICATE_EXTENSIONS,
591            std::iter::once(san.as_slice())
592        )
593        .is_ok());
594        assert_eq!(
595            select_unique_san_value(MAX_TLS_CERTIFICATE_EXTENSIONS + 1, std::iter::empty()),
596            Err(LeafParseError::LimitRejected)
597        );
598        assert_eq!(
599            select_unique_san_value(2, [san.as_slice(), san.as_slice()].into_iter()),
600            Err(LeafParseError::Malformed)
601        );
602        assert_eq!(
603            classify_san(Some(&vec![0; MAX_TLS_SUBJECT_ALTERNATIVE_NAME_BYTES + 1])),
604            Err(LeafParseError::LimitRejected)
605        );
606
607        let entries = (0..=MAX_TLS_SAN_ENTRIES_PER_CERTIFICATE)
608            .map(|_| (0x82, b"x".to_vec()))
609            .collect::<Vec<_>>();
610        assert_eq!(
611            classify_san(Some(&san_sequence(&entries))),
612            Ok((MAX_TLS_SAN_ENTRIES_PER_CERTIFICATE as u16, 0, 0, true))
613        );
614
615        let invalid_ip = san_sequence(&[(0x87, vec![127, 0, 1])]);
616        assert_eq!(classify_san(Some(&invalid_ip)), Ok((0, 0, 1, false)));
617        let mut trailing = san;
618        trailing.push(0);
619        assert_eq!(
620            classify_san(Some(&trailing)),
621            Err(LeafParseError::Malformed)
622        );
623    }
624}