Skip to main content

termivar_scanner/web_runtime/
tls_negotiation_review.rs

1//! Bounded active TLS negotiation review for one exact authorized HTTPS peer.
2//!
3//! The runtime sends only TLS handshake and close-notify bytes. It never sends
4//! HTTP, application data, credentials, client certificates, early data, or
5//! ALPN offers. Two network-capable cells (TLS 1.3 then TLS 1.2) share the
6//! parent assessment's request, response-byte, cancellation, and deadline
7//! authority. Legacy rows are explicit zero-network backend limitations.
8
9use std::{
10    collections::BTreeSet,
11    future::Future,
12    io,
13    net::SocketAddr,
14    pin::Pin,
15    sync::{
16        atomic::{AtomicBool, AtomicU64, Ordering},
17        Arc,
18    },
19    task::{Context, Poll},
20    time::Duration,
21};
22
23use thiserror::Error;
24use tokio::{
25    io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf},
26    net::{lookup_host, TcpStream},
27};
28use tokio_rustls::{
29    rustls::{
30        self,
31        pki_types::{CertificateDer, ServerName},
32        ClientConfig, RootCertStore,
33    },
34    TlsConnector,
35};
36use tokio_util::sync::CancellationToken;
37use url::{Host, Url};
38
39use crate::{
40    runtime_budget::{RequestAccountingBroker, RequestAccountingLease},
41    DecisionExecutionStage, RuntimeBudgetDimension, TransportDispatchOutcome,
42    HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS,
43};
44
45/// Stable capability identity for active TLS negotiation review.
46pub const TLS_NEGOTIATION_REVIEW_CAPABILITY_ID: &str = "web.tls-negotiation-review";
47/// Stable audit schema for the active TLS matrix.
48pub const TLS_NEGOTIATION_REVIEW_AUDIT_SCHEMA: &str = "security.tls-negotiation-review-audit/v1";
49/// Stable policy identity for the fixed initial matrix.
50pub const TLS_NEGOTIATION_REVIEW_POLICY_ID: &str = "termivar.active-tls-negotiation-review/v1";
51/// Parent-accounted action identity for the TLS 1.3 cell.
52pub const TLS_NEGOTIATION_REVIEW_ACTION_TLS13: &str = "web.review.tls-negotiation.tls13@1";
53/// Parent-accounted action identity for the TLS 1.2 cell.
54pub const TLS_NEGOTIATION_REVIEW_ACTION_TLS12: &str = "web.review.tls-negotiation.tls12@1";
55
56pub(crate) const TLS_NEGOTIATION_ACTIVE_VERIFICATION_ALLOWANCE: u16 = 2;
57const MAX_CELL_INGRESS_TLS_BYTES: u64 = 128 * 1024;
58const MAX_TOTAL_INGRESS_TLS_BYTES: u64 = 256 * 1024;
59const MAX_CELL_EGRESS_TLS_BYTES: u64 = 64 * 1024;
60const MAX_TOTAL_EGRESS_TLS_BYTES: u64 = 128 * 1024;
61const MAX_RESOLVED_ADDRESSES: usize = 16;
62const CELL_TIMEOUT: Duration = Duration::from_secs(5);
63const TOTAL_TIMEOUT: Duration = Duration::from_secs(10);
64const MAX_SINGLE_RAW_READ: usize = 16 * 1024;
65
66const NETWORK_CELL_COUNT: usize = 2;
67const MATRIX_CELL_COUNT: usize = 5;
68
69/// Returns whether an explicitly selected URL can be represented by the
70/// active TLS matrix's exact DNS host/SNI/port authority.
71///
72/// This is a local preflight predicate. It performs no DNS or network access.
73pub fn tls_negotiation_review_target_is_supported(target: &Url) -> bool {
74    matches!(target.host(), Some(Host::Domain(host))
75        if !host.is_empty() && ServerName::try_from(host.to_owned()).is_ok())
76        && target.scheme() == "https"
77        && target.username().is_empty()
78        && target.password().is_none()
79        && target.port_or_known_default().is_some()
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83enum CellId {
84    Tls13DefaultProvider,
85    Tls12DefaultProvider,
86    Tls11LegacyProtocol,
87    Tls10LegacyProtocol,
88    LegacyCipherSuites,
89}
90
91impl CellId {
92    const fn token(self) -> &'static str {
93        match self {
94            Self::Tls13DefaultProvider => "tls13_default_provider",
95            Self::Tls12DefaultProvider => "tls12_default_provider",
96            Self::Tls11LegacyProtocol => "tls11_legacy_protocol",
97            Self::Tls10LegacyProtocol => "tls10_legacy_protocol",
98            Self::LegacyCipherSuites => "legacy_cipher_suites",
99        }
100    }
101
102    const fn offered_protocol(self) -> &'static str {
103        match self {
104            Self::Tls13DefaultProvider => "tls1.3",
105            Self::Tls12DefaultProvider => "tls1.2",
106            Self::Tls11LegacyProtocol => "tls1.1",
107            Self::Tls10LegacyProtocol => "tls1.0",
108            Self::LegacyCipherSuites => "legacy_cipher_suites",
109        }
110    }
111
112    const fn action_id(self) -> Option<&'static str> {
113        match self {
114            Self::Tls13DefaultProvider => Some(TLS_NEGOTIATION_REVIEW_ACTION_TLS13),
115            Self::Tls12DefaultProvider => Some(TLS_NEGOTIATION_REVIEW_ACTION_TLS12),
116            Self::Tls11LegacyProtocol | Self::Tls10LegacyProtocol | Self::LegacyCipherSuites => {
117                None
118            },
119        }
120    }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124enum DispatchStatus {
125    Dispatched,
126    NotDispatched,
127}
128
129impl DispatchStatus {
130    const fn token(self) -> &'static str {
131        match self {
132            Self::Dispatched => "dispatched",
133            Self::NotDispatched => "not_dispatched",
134        }
135    }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum CellOutcome {
140    Negotiated,
141    PeerAlertReceived,
142    CertificateValidationFailed,
143    HandshakeFailed,
144    TransportFailed,
145    ClientBackendUnsupported,
146    ParentAuthorityUnavailable,
147    BudgetExhausted,
148    Cancelled,
149    DeadlineReached,
150    NotReached,
151}
152
153impl CellOutcome {
154    const fn token(self) -> &'static str {
155        match self {
156            Self::Negotiated => "negotiated",
157            Self::PeerAlertReceived => "peer_alert_received",
158            Self::CertificateValidationFailed => "certificate_validation_failed",
159            Self::HandshakeFailed => "handshake_failed",
160            Self::TransportFailed => "transport_failed",
161            Self::ClientBackendUnsupported => "client_backend_unsupported",
162            Self::ParentAuthorityUnavailable => "parent_authority_unavailable",
163            Self::BudgetExhausted => "budget_exhausted",
164            Self::Cancelled => "cancelled",
165            Self::DeadlineReached => "deadline_reached",
166            Self::NotReached => "not_reached",
167        }
168    }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172enum SupportState {
173    ObservedSupported,
174    NotEstablished,
175    NotTestedClientBackendUnsupported,
176}
177
178impl SupportState {
179    const fn token(self) -> &'static str {
180        match self {
181            Self::ObservedSupported => "observed_supported",
182            Self::NotEstablished => "not_established",
183            Self::NotTestedClientBackendUnsupported => "not_tested_client_backend_unsupported",
184        }
185    }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189enum TransportValidation {
190    Succeeded,
191    NotEstablished,
192    NotApplicable,
193}
194
195impl TransportValidation {
196    const fn token(self) -> &'static str {
197        match self {
198            Self::Succeeded => "succeeded",
199            Self::NotEstablished => "not_established",
200            Self::NotApplicable => "not_applicable",
201        }
202    }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206enum ReviewTerminal {
207    Completed,
208    ParentAuthorityUnavailable,
209    BudgetExhausted,
210    Cancelled,
211    DeadlineReached,
212}
213
214impl ReviewTerminal {
215    const fn token(self) -> &'static str {
216        match self {
217            Self::Completed => "completed",
218            Self::ParentAuthorityUnavailable => "parent_authority_unavailable",
219            Self::BudgetExhausted => "budget_exhausted",
220            Self::Cancelled => "cancelled",
221            Self::DeadlineReached => "deadline_reached",
222        }
223    }
224}
225
226/// One fixed row in the bounded negotiation matrix.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct WebAssessmentTlsNegotiationReviewCell {
229    id: CellId,
230    dispatch_status: DispatchStatus,
231    outcome: CellOutcome,
232    support: SupportState,
233    transport_validation: TransportValidation,
234    negotiated_protocol: Option<&'static str>,
235    negotiated_cipher_suite: Option<&'static str>,
236    accounted_ingress_tls_bytes: u64,
237    observed_egress_tls_bytes: u64,
238}
239
240impl WebAssessmentTlsNegotiationReviewCell {
241    /// Stable matrix-row identity.
242    pub const fn id(&self) -> &'static str {
243        self.id.token()
244    }
245
246    /// Protocol or legacy family deliberately represented by this row.
247    pub const fn offered_protocol(&self) -> &'static str {
248        self.id.offered_protocol()
249    }
250
251    /// Closed client-backend identity; no server property is inferred from it.
252    pub const fn client_backend(&self) -> &'static str {
253        "rustls-ring"
254    }
255
256    /// Whether a parent-accounted connection attempt was dispatched.
257    pub const fn dispatch_status(&self) -> &'static str {
258        self.dispatch_status.token()
259    }
260
261    /// Closed result of this exact client attempt.
262    pub const fn outcome(&self) -> &'static str {
263        self.outcome.token()
264    }
265
266    /// Narrow support interpretation established by this cell.
267    pub const fn support(&self) -> &'static str {
268        self.support.token()
269    }
270
271    /// Whether standard certificate/transport validation completed.
272    pub const fn transport_validation(&self) -> &'static str {
273        self.transport_validation.token()
274    }
275
276    /// Negotiated protocol only after a successful handshake.
277    pub const fn negotiated_protocol(&self) -> Option<&'static str> {
278        self.negotiated_protocol
279    }
280
281    /// Negotiated cipher suite only after a successful handshake.
282    pub const fn negotiated_cipher_suite(&self) -> Option<&'static str> {
283        self.negotiated_cipher_suite
284    }
285
286    /// Raw TLS ingress bytes charged to the parent response-byte authority.
287    pub const fn accounted_ingress_tls_bytes(&self) -> u64 {
288        self.accounted_ingress_tls_bytes
289    }
290
291    /// Raw TLS egress bytes observed for this handshake, audit-only.
292    pub const fn observed_egress_tls_bytes(&self) -> u64 {
293        self.observed_egress_tls_bytes
294    }
295
296    fn pending(id: CellId) -> Self {
297        Self {
298            id,
299            dispatch_status: DispatchStatus::NotDispatched,
300            outcome: CellOutcome::NotReached,
301            support: SupportState::NotEstablished,
302            transport_validation: TransportValidation::NotEstablished,
303            negotiated_protocol: None,
304            negotiated_cipher_suite: None,
305            accounted_ingress_tls_bytes: 0,
306            observed_egress_tls_bytes: 0,
307        }
308    }
309
310    fn backend_unsupported(id: CellId) -> Self {
311        Self {
312            id,
313            dispatch_status: DispatchStatus::NotDispatched,
314            outcome: CellOutcome::ClientBackendUnsupported,
315            support: SupportState::NotTestedClientBackendUnsupported,
316            transport_validation: TransportValidation::NotApplicable,
317            negotiated_protocol: None,
318            negotiated_cipher_suite: None,
319            accounted_ingress_tls_bytes: 0,
320            observed_egress_tls_bytes: 0,
321        }
322    }
323}
324
325/// Value-free bounded active TLS matrix produced by one assessment authority.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct WebAssessmentTlsNegotiationReviewAudit {
328    terminal: ReviewTerminal,
329    attempted_connection_count: u8,
330    completed_handshake_count: u8,
331    accounted_ingress_tls_bytes: u64,
332    observed_egress_tls_bytes: u64,
333    cells: Vec<WebAssessmentTlsNegotiationReviewCell>,
334}
335
336impl WebAssessmentTlsNegotiationReviewAudit {
337    /// Stable audit schema.
338    pub const fn schema(&self) -> &'static str {
339        TLS_NEGOTIATION_REVIEW_AUDIT_SCHEMA
340    }
341
342    /// Stable fixed-matrix policy identity.
343    pub const fn policy(&self) -> &'static str {
344        TLS_NEGOTIATION_REVIEW_POLICY_ID
345    }
346
347    /// The audit exists only when explicitly selected.
348    pub const fn selected(&self) -> bool {
349        true
350    }
351
352    /// Overall matrix-processing terminal state.
353    pub const fn terminal(&self) -> &'static str {
354        self.terminal.token()
355    }
356
357    /// Parent-accounted network cells actually admitted.
358    pub const fn attempted_connection_count(&self) -> u8 {
359        self.attempted_connection_count
360    }
361
362    /// Whether every network-capable row reached the parent-accounted dispatch
363    /// boundary and matrix processing ended normally. Individual handshakes may
364    /// still fail; this is coverage completeness, not server support.
365    pub(crate) const fn network_matrix_complete(&self) -> bool {
366        matches!(self.terminal, ReviewTerminal::Completed)
367            && self.attempted_connection_count == NETWORK_CELL_COUNT as u8
368    }
369
370    /// Successfully completed TLS handshakes.
371    pub const fn completed_handshake_count(&self) -> u8 {
372        self.completed_handshake_count
373    }
374
375    /// Aggregate raw TLS ingress charged to the parent response-byte budget.
376    pub const fn accounted_ingress_tls_bytes(&self) -> u64 {
377        self.accounted_ingress_tls_bytes
378    }
379
380    /// Aggregate raw TLS egress observed by this child runtime.
381    pub const fn observed_egress_tls_bytes(&self) -> u64 {
382        self.observed_egress_tls_bytes
383    }
384
385    /// Fixed five-row matrix in policy order.
386    pub fn cells(&self) -> &[WebAssessmentTlsNegotiationReviewCell] {
387        &self.cells
388    }
389
390    pub(crate) fn parent_authority_unavailable() -> Self {
391        let mut audit = Self::empty();
392        audit.terminal = ReviewTerminal::ParentAuthorityUnavailable;
393        for cell in audit.cells.iter_mut().take(NETWORK_CELL_COUNT) {
394            cell.outcome = CellOutcome::ParentAuthorityUnavailable;
395        }
396        audit
397    }
398
399    fn empty() -> Self {
400        Self {
401            terminal: ReviewTerminal::Completed,
402            attempted_connection_count: 0,
403            completed_handshake_count: 0,
404            accounted_ingress_tls_bytes: 0,
405            observed_egress_tls_bytes: 0,
406            cells: vec![
407                WebAssessmentTlsNegotiationReviewCell::pending(CellId::Tls13DefaultProvider),
408                WebAssessmentTlsNegotiationReviewCell::pending(CellId::Tls12DefaultProvider),
409                WebAssessmentTlsNegotiationReviewCell::backend_unsupported(
410                    CellId::Tls11LegacyProtocol,
411                ),
412                WebAssessmentTlsNegotiationReviewCell::backend_unsupported(
413                    CellId::Tls10LegacyProtocol,
414                ),
415                WebAssessmentTlsNegotiationReviewCell::backend_unsupported(
416                    CellId::LegacyCipherSuites,
417                ),
418            ],
419        }
420    }
421
422    pub(crate) fn is_valid(&self) -> bool {
423        if self.cells.len() != MATRIX_CELL_COUNT
424            || self.attempted_connection_count > NETWORK_CELL_COUNT as u8
425            || self.completed_handshake_count > self.attempted_connection_count
426        {
427            return false;
428        }
429        let expected = [
430            CellId::Tls13DefaultProvider,
431            CellId::Tls12DefaultProvider,
432            CellId::Tls11LegacyProtocol,
433            CellId::Tls10LegacyProtocol,
434            CellId::LegacyCipherSuites,
435        ];
436        if self
437            .cells
438            .iter()
439            .zip(expected)
440            .any(|(cell, id)| cell.id != id)
441        {
442            return false;
443        }
444        let attempted = self
445            .cells
446            .iter()
447            .filter(|cell| cell.dispatch_status == DispatchStatus::Dispatched)
448            .count();
449        let completed = self
450            .cells
451            .iter()
452            .filter(|cell| cell.outcome == CellOutcome::Negotiated)
453            .count();
454        let ingress = self.cells.iter().try_fold(0_u64, |total, cell| {
455            total.checked_add(cell.accounted_ingress_tls_bytes)
456        });
457        let egress = self.cells.iter().try_fold(0_u64, |total, cell| {
458            total.checked_add(cell.observed_egress_tls_bytes)
459        });
460        if attempted != usize::from(self.attempted_connection_count)
461            || completed != usize::from(self.completed_handshake_count)
462            || ingress != Some(self.accounted_ingress_tls_bytes)
463            || egress != Some(self.observed_egress_tls_bytes)
464            || self.accounted_ingress_tls_bytes > MAX_TOTAL_INGRESS_TLS_BYTES
465            || self.observed_egress_tls_bytes > MAX_TOTAL_EGRESS_TLS_BYTES
466        {
467            return false;
468        }
469        if self.terminal == ReviewTerminal::ParentAuthorityUnavailable
470            && self.cells[..NETWORK_CELL_COUNT]
471                .iter()
472                .any(|cell| cell.outcome != CellOutcome::ParentAuthorityUnavailable)
473        {
474            return false;
475        }
476        self.cells.iter().enumerate().all(|(index, cell)| {
477            let negotiated = cell.outcome == CellOutcome::Negotiated;
478            let fields_are_consistent = negotiated
479                == (cell.negotiated_protocol.is_some()
480                    && cell.negotiated_cipher_suite.is_some()
481                    && cell.support == SupportState::ObservedSupported
482                    && cell.transport_validation == TransportValidation::Succeeded);
483            let unsupported = matches!(
484                cell.id,
485                CellId::Tls11LegacyProtocol
486                    | CellId::Tls10LegacyProtocol
487                    | CellId::LegacyCipherSuites
488            );
489            let network_row_is_consistent = index >= NETWORK_CELL_COUNT
490                || match cell.dispatch_status {
491                    DispatchStatus::Dispatched => matches!(
492                        cell.outcome,
493                        CellOutcome::Negotiated
494                            | CellOutcome::PeerAlertReceived
495                            | CellOutcome::CertificateValidationFailed
496                            | CellOutcome::HandshakeFailed
497                            | CellOutcome::TransportFailed
498                            | CellOutcome::BudgetExhausted
499                            | CellOutcome::Cancelled
500                            | CellOutcome::DeadlineReached
501                    ),
502                    DispatchStatus::NotDispatched => {
503                        cell.accounted_ingress_tls_bytes == 0
504                            && cell.observed_egress_tls_bytes == 0
505                            && matches!(
506                                cell.outcome,
507                                CellOutcome::ParentAuthorityUnavailable
508                                    | CellOutcome::BudgetExhausted
509                                    | CellOutcome::Cancelled
510                                    | CellOutcome::DeadlineReached
511                                    | CellOutcome::NotReached
512                            )
513                    },
514                };
515            let negotiated_profile_is_consistent =
516                !negotiated || cell.negotiated_protocol == Some(cell.id.offered_protocol());
517            let non_negotiated_fields_are_consistent = unsupported
518                || negotiated
519                || (cell.negotiated_protocol.is_none()
520                    && cell.negotiated_cipher_suite.is_none()
521                    && cell.support == SupportState::NotEstablished
522                    && cell.transport_validation == TransportValidation::NotEstablished);
523            fields_are_consistent
524                && network_row_is_consistent
525                && negotiated_profile_is_consistent
526                && non_negotiated_fields_are_consistent
527                && cell.accounted_ingress_tls_bytes <= MAX_CELL_INGRESS_TLS_BYTES
528                && cell.observed_egress_tls_bytes <= MAX_CELL_EGRESS_TLS_BYTES
529                && (!unsupported
530                    || (cell.dispatch_status == DispatchStatus::NotDispatched
531                        && cell.outcome == CellOutcome::ClientBackendUnsupported
532                        && cell.support == SupportState::NotTestedClientBackendUnsupported
533                        && cell.transport_validation == TransportValidation::NotApplicable
534                        && cell.accounted_ingress_tls_bytes == 0
535                        && cell.observed_egress_tls_bytes == 0))
536        })
537    }
538}
539
540/// Closed fixture-only connection/trust data minted by the parent authority.
541#[derive(Clone)]
542pub(crate) struct TlsNegotiationOwnedTransportProfile {
543    resolved_address: SocketAddr,
544    root_certificate_der: Vec<u8>,
545}
546
547impl TlsNegotiationOwnedTransportProfile {
548    pub(crate) fn new(resolved_address: SocketAddr, root_certificate_der: Vec<u8>) -> Self {
549        Self {
550            resolved_address,
551            root_certificate_der,
552        }
553    }
554}
555
556#[derive(Debug, Error)]
557pub(crate) enum TlsNegotiationReviewMintError {
558    #[error("TLS negotiation endpoint is outside the selected authority")]
559    EndpointOutsideAuthority,
560    #[error("TLS negotiation requires one credential-free DNS HTTPS target")]
561    UnsupportedTarget,
562    #[error("TLS negotiation authority has already been consumed")]
563    AuthorityAlreadyMinted,
564    #[error("TLS negotiation client configuration is unavailable")]
565    ClientConfiguration,
566    #[error("TLS negotiation authority invariant failed")]
567    InternalInvariant,
568}
569
570/// Single-use child runtime minted by [`super::SharedWebRuntimeAuthority`].
571pub(crate) struct TlsNegotiationReviewRuntime {
572    host: String,
573    port: u16,
574    fixed_address: Option<SocketAddr>,
575    tls13: Arc<ClientConfig>,
576    tls12: Arc<ClientConfig>,
577    accounting: RequestAccountingBroker,
578    cancellation: CancellationToken,
579    parent_deadline: Option<tokio::time::Instant>,
580}
581
582impl TlsNegotiationReviewRuntime {
583    pub(crate) fn new(
584        target: &Url,
585        accounting: RequestAccountingBroker,
586        cancellation: CancellationToken,
587        parent_deadline: Option<tokio::time::Instant>,
588        owned_profile: Option<TlsNegotiationOwnedTransportProfile>,
589    ) -> Result<Self, TlsNegotiationReviewMintError> {
590        let Host::Domain(host) = target
591            .host()
592            .ok_or(TlsNegotiationReviewMintError::UnsupportedTarget)?
593        else {
594            return Err(TlsNegotiationReviewMintError::UnsupportedTarget);
595        };
596        if !tls_negotiation_review_target_is_supported(target) {
597            return Err(TlsNegotiationReviewMintError::UnsupportedTarget);
598        }
599        let port = target
600            .port_or_known_default()
601            .ok_or(TlsNegotiationReviewMintError::UnsupportedTarget)?;
602        ServerName::try_from(host.to_owned())
603            .map_err(|_| TlsNegotiationReviewMintError::UnsupportedTarget)?;
604
605        let (roots, fixed_address) = match owned_profile {
606            Some(profile) => {
607                let mut roots = RootCertStore::empty();
608                roots
609                    .add(CertificateDer::from(profile.root_certificate_der))
610                    .map_err(|_| TlsNegotiationReviewMintError::ClientConfiguration)?;
611                (roots, Some(profile.resolved_address))
612            },
613            None => (
614                RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()),
615                None,
616            ),
617        };
618        let tls13 = client_config(&roots, &[&rustls::version::TLS13])?;
619        let tls12 = client_config(&roots, &[&rustls::version::TLS12])?;
620        Ok(Self {
621            host: host.to_owned(),
622            port,
623            fixed_address,
624            tls13,
625            tls12,
626            accounting,
627            cancellation,
628            parent_deadline,
629        })
630    }
631
632    pub(crate) async fn execute(mut self) -> WebAssessmentTlsNegotiationReviewAudit {
633        let mut audit = WebAssessmentTlsNegotiationReviewAudit::empty();
634        // Every dispatched matrix cell must retain the corresponding parent
635        // receipt so the final report can reconcile request, byte, stage, and
636        // outcome truth. Refuse the child before DNS or transport when earlier
637        // work has consumed that bounded evidence capacity.
638        let transport = self.accounting.dispatch_audit();
639        let receipt_capacity_available = transport.omitted_receipt_count() == 0
640            && transport
641                .receipts()
642                .len()
643                .checked_add(NETWORK_CELL_COUNT)
644                .is_some_and(|required| required <= HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS);
645        if !receipt_capacity_available {
646            audit.terminal = ReviewTerminal::BudgetExhausted;
647            audit.cells[0].outcome = CellOutcome::BudgetExhausted;
648            debug_assert!(audit.is_valid());
649            return audit;
650        }
651        let started = tokio::time::Instant::now();
652        let total_deadline =
653            earlier_deadline(started.checked_add(TOTAL_TIMEOUT), self.parent_deadline)
654                .unwrap_or(started);
655        let totals = Arc::new(TotalByteCounters::default());
656        let mut resolved_address = self.fixed_address;
657        let mut resolution_failed = false;
658
659        for index in 0..NETWORK_CELL_COUNT {
660            if resolution_failed {
661                break;
662            }
663            let id = if index == 0 {
664                CellId::Tls13DefaultProvider
665            } else {
666                CellId::Tls12DefaultProvider
667            };
668            let config = if index == 0 {
669                Arc::clone(&self.tls13)
670            } else {
671                Arc::clone(&self.tls12)
672            };
673            let stop = self
674                .execute_cell(
675                    id,
676                    config,
677                    total_deadline,
678                    &totals,
679                    &mut resolved_address,
680                    &mut resolution_failed,
681                    &mut audit.cells[index],
682                )
683                .await;
684            if let Some(terminal) = stop {
685                audit.terminal = terminal;
686                break;
687            }
688        }
689
690        audit.attempted_connection_count = u8::try_from(
691            audit
692                .cells
693                .iter()
694                .filter(|cell| cell.dispatch_status == DispatchStatus::Dispatched)
695                .count(),
696        )
697        .unwrap_or(u8::MAX);
698        audit.completed_handshake_count = u8::try_from(
699            audit
700                .cells
701                .iter()
702                .filter(|cell| cell.outcome == CellOutcome::Negotiated)
703                .count(),
704        )
705        .unwrap_or(u8::MAX);
706        audit.accounted_ingress_tls_bytes = totals.ingress.load(Ordering::Relaxed);
707        audit.observed_egress_tls_bytes = totals.egress.load(Ordering::Relaxed);
708        debug_assert!(audit.is_valid());
709        audit
710    }
711
712    #[allow(clippy::too_many_arguments)]
713    async fn execute_cell(
714        &mut self,
715        id: CellId,
716        config: Arc<ClientConfig>,
717        total_deadline: tokio::time::Instant,
718        totals: &Arc<TotalByteCounters>,
719        resolved_address: &mut Option<SocketAddr>,
720        resolution_failed: &mut bool,
721        cell: &mut WebAssessmentTlsNegotiationReviewCell,
722    ) -> Option<ReviewTerminal> {
723        if self.cancellation.is_cancelled() {
724            cell.outcome = CellOutcome::Cancelled;
725            return Some(ReviewTerminal::Cancelled);
726        }
727        let now = tokio::time::Instant::now();
728        if now >= total_deadline {
729            cell.outcome = CellOutcome::DeadlineReached;
730            return Some(ReviewTerminal::DeadlineReached);
731        }
732        let action_id = id.action_id().expect("network cell has an action identity");
733        let mut lease = match self.accounting.try_begin_with_request_body_bytes(
734            action_id,
735            DecisionExecutionStage::Active,
736            None,
737            0,
738        ) {
739            Ok(lease) => lease,
740            Err(limit) => {
741                cell.outcome = CellOutcome::BudgetExhausted;
742                return Some(if limit.dimension() == RuntimeBudgetDimension::WallTime {
743                    ReviewTerminal::DeadlineReached
744                } else {
745                    ReviewTerminal::BudgetExhausted
746                });
747            },
748        };
749        cell.dispatch_status = DispatchStatus::Dispatched;
750        let cell_deadline = earlier_deadline(now.checked_add(CELL_TIMEOUT), Some(total_deadline))
751            .unwrap_or(total_deadline);
752        let response_gate = match bounded(
753            &self.cancellation,
754            cell_deadline,
755            lease.acquire_response_read(),
756        )
757        .await
758        {
759            Ok(guard) => guard,
760            Err(StepStop::Cancelled) => {
761                lease.finish(TransportDispatchOutcome::Cancelled);
762                cell.outcome = CellOutcome::Cancelled;
763                return Some(ReviewTerminal::Cancelled);
764            },
765            Err(StepStop::Deadline) => {
766                lease.finish(TransportDispatchOutcome::RequestTimeout);
767                cell.outcome = CellOutcome::DeadlineReached;
768                return Some(ReviewTerminal::DeadlineReached);
769            },
770        };
771
772        if resolved_address.is_none() {
773            let resolution = bounded(
774                &self.cancellation,
775                cell_deadline,
776                resolve_one_address(&self.host, self.port),
777            )
778            .await;
779            match resolution {
780                Ok(Some(address)) => *resolved_address = Some(address),
781                Ok(None) => {
782                    lease.finish(TransportDispatchOutcome::TransportFailure);
783                    cell.outcome = CellOutcome::TransportFailed;
784                    *resolution_failed = true;
785                    drop(response_gate);
786                    return None;
787                },
788                Err(StepStop::Deadline) => {
789                    lease.finish(TransportDispatchOutcome::RequestTimeout);
790                    cell.outcome = CellOutcome::DeadlineReached;
791                    *resolution_failed = true;
792                    drop(response_gate);
793                    return Some(ReviewTerminal::DeadlineReached);
794                },
795                Err(StepStop::Cancelled) => {
796                    lease.finish(TransportDispatchOutcome::Cancelled);
797                    cell.outcome = CellOutcome::Cancelled;
798                    drop(response_gate);
799                    return Some(ReviewTerminal::Cancelled);
800                },
801            }
802        }
803        let address = resolved_address.expect("resolution populated before connection");
804        let stream = match bounded(
805            &self.cancellation,
806            cell_deadline,
807            TcpStream::connect(address),
808        )
809        .await
810        {
811            Ok(Ok(stream)) => stream,
812            Ok(Err(_)) => {
813                lease.finish(TransportDispatchOutcome::TransportFailure);
814                cell.outcome = CellOutcome::TransportFailed;
815                drop(response_gate);
816                return None;
817            },
818            Err(StepStop::Cancelled) => {
819                lease.finish(TransportDispatchOutcome::Cancelled);
820                cell.outcome = CellOutcome::Cancelled;
821                drop(response_gate);
822                return Some(ReviewTerminal::Cancelled);
823            },
824            Err(StepStop::Deadline) => {
825                lease.finish(TransportDispatchOutcome::RequestTimeout);
826                cell.outcome = CellOutcome::DeadlineReached;
827                drop(response_gate);
828                return Some(ReviewTerminal::DeadlineReached);
829            },
830        };
831        let counters = CellByteCounters::new(Arc::clone(totals));
832        let counted = CountedTlsIo::new(stream, &mut lease, counters.clone());
833        let server_name = ServerName::try_from(self.host.clone())
834            .expect("host was validated before authority mint");
835        let handshake = bounded(
836            &self.cancellation,
837            cell_deadline,
838            TlsConnector::from(config)
839                .connect(server_name, counted)
840                .into_fallible(),
841        )
842        .await;
843
844        match handshake {
845            Ok(Ok(mut tls)) => {
846                let connection = tls.get_ref().1;
847                let protocol = connection.protocol_version().and_then(protocol_token);
848                let cipher = connection
849                    .negotiated_cipher_suite()
850                    .and_then(|suite| cipher_suite_token(suite.suite()));
851                if protocol.is_some() && cipher.is_some() && connection.alpn_protocol().is_none() {
852                    let shutdown = bounded(
853                        &self.cancellation,
854                        cell_deadline,
855                        AsyncWriteExt::shutdown(&mut tls),
856                    )
857                    .await;
858                    drop(tls);
859                    match shutdown {
860                        Err(StepStop::Cancelled) => {
861                            cell.outcome = CellOutcome::Cancelled;
862                            lease.finish(TransportDispatchOutcome::Cancelled);
863                        },
864                        Err(StepStop::Deadline) => {
865                            cell.outcome = CellOutcome::DeadlineReached;
866                            lease.finish(TransportDispatchOutcome::RequestTimeout);
867                        },
868                        Ok(Ok(())) => {
869                            if tls_byte_limit_reached(&lease, &counters, totals) {
870                                cell.outcome = CellOutcome::BudgetExhausted;
871                                lease.finish(TransportDispatchOutcome::ResponseBudgetReached);
872                            } else {
873                                cell.outcome = CellOutcome::Negotiated;
874                                cell.support = SupportState::ObservedSupported;
875                                cell.transport_validation = TransportValidation::Succeeded;
876                                cell.negotiated_protocol = protocol;
877                                cell.negotiated_cipher_suite = cipher;
878                                lease.finish(TransportDispatchOutcome::Completed);
879                            }
880                        },
881                        Ok(Err(_)) => {
882                            if tls_byte_limit_reached(&lease, &counters, totals) {
883                                cell.outcome = CellOutcome::BudgetExhausted;
884                                lease.finish(TransportDispatchOutcome::ResponseBudgetReached);
885                            } else {
886                                cell.outcome = CellOutcome::TransportFailed;
887                                lease.finish(TransportDispatchOutcome::TransportFailure);
888                            }
889                        },
890                    }
891                } else {
892                    drop(tls);
893                    cell.outcome = CellOutcome::HandshakeFailed;
894                    lease.finish(TransportDispatchOutcome::TransportFailure);
895                }
896            },
897            Ok(Err((error, counted))) => {
898                drop(counted);
899                if tls_byte_limit_reached(&lease, &counters, totals) {
900                    cell.outcome = CellOutcome::BudgetExhausted;
901                    lease.finish(TransportDispatchOutcome::ResponseBudgetReached);
902                } else {
903                    cell.outcome = classify_handshake_error(&error);
904                    lease.finish(TransportDispatchOutcome::TransportFailure);
905                }
906            },
907            Err(StepStop::Cancelled) => {
908                cell.outcome = CellOutcome::Cancelled;
909                lease.finish(TransportDispatchOutcome::Cancelled);
910            },
911            Err(StepStop::Deadline) => {
912                cell.outcome = CellOutcome::DeadlineReached;
913                lease.finish(TransportDispatchOutcome::RequestTimeout);
914            },
915        }
916        cell.accounted_ingress_tls_bytes = counters.ingress.load(Ordering::Relaxed);
917        cell.observed_egress_tls_bytes = counters.egress.load(Ordering::Relaxed);
918        drop(response_gate);
919
920        match cell.outcome {
921            CellOutcome::BudgetExhausted => Some(ReviewTerminal::BudgetExhausted),
922            CellOutcome::Cancelled => Some(ReviewTerminal::Cancelled),
923            CellOutcome::DeadlineReached => Some(ReviewTerminal::DeadlineReached),
924            _ => None,
925        }
926    }
927}
928
929fn tls_byte_limit_reached(
930    lease: &RequestAccountingLease,
931    counters: &CellByteCounters,
932    totals: &TotalByteCounters,
933) -> bool {
934    lease.response_budget_reached()
935        || counters
936            .parent_ingress_limit_reached
937            .load(Ordering::Relaxed)
938        || counters.local_ingress_limit_reached.load(Ordering::Relaxed)
939        || counters.local_egress_limit_reached.load(Ordering::Relaxed)
940        || counters.ingress.load(Ordering::Relaxed) >= MAX_CELL_INGRESS_TLS_BYTES
941        || counters.egress.load(Ordering::Relaxed) >= MAX_CELL_EGRESS_TLS_BYTES
942        || totals.ingress.load(Ordering::Relaxed) >= MAX_TOTAL_INGRESS_TLS_BYTES
943        || totals.egress.load(Ordering::Relaxed) >= MAX_TOTAL_EGRESS_TLS_BYTES
944}
945
946fn client_config(
947    roots: &RootCertStore,
948    versions: &[&'static rustls::SupportedProtocolVersion],
949) -> Result<Arc<ClientConfig>, TlsNegotiationReviewMintError> {
950    let provider = Arc::new(rustls::crypto::ring::default_provider());
951    let mut config = ClientConfig::builder_with_provider(provider)
952        .with_protocol_versions(versions)
953        .map_err(|_| TlsNegotiationReviewMintError::ClientConfiguration)?
954        .with_root_certificates(roots.clone())
955        .with_no_client_auth();
956    config.resumption = rustls::client::Resumption::disabled();
957    config.alpn_protocols.clear();
958    config.enable_early_data = false;
959    Ok(Arc::new(config))
960}
961
962async fn resolve_one_address(host: &str, port: u16) -> Option<SocketAddr> {
963    let addresses = lookup_host((host, port)).await.ok()?;
964    select_resolved_address(addresses)
965}
966
967fn select_resolved_address(addresses: impl Iterator<Item = SocketAddr>) -> Option<SocketAddr> {
968    addresses
969        .take(MAX_RESOLVED_ADDRESSES)
970        .collect::<BTreeSet<_>>()
971        .into_iter()
972        .next()
973}
974
975fn classify_handshake_error(error: &io::Error) -> CellOutcome {
976    match error
977        .get_ref()
978        .and_then(|source| source.downcast_ref::<rustls::Error>())
979    {
980        Some(rustls::Error::AlertReceived(_)) => CellOutcome::PeerAlertReceived,
981        Some(rustls::Error::InvalidCertificate(_)) => CellOutcome::CertificateValidationFailed,
982        Some(_) => CellOutcome::HandshakeFailed,
983        None => CellOutcome::TransportFailed,
984    }
985}
986
987fn protocol_token(version: rustls::ProtocolVersion) -> Option<&'static str> {
988    match version {
989        rustls::ProtocolVersion::TLSv1_3 => Some("tls1.3"),
990        rustls::ProtocolVersion::TLSv1_2 => Some("tls1.2"),
991        _ => None,
992    }
993}
994
995fn cipher_suite_token(suite: rustls::CipherSuite) -> Option<&'static str> {
996    Some(match suite {
997        rustls::CipherSuite::TLS13_AES_256_GCM_SHA384 => "tls13_aes_256_gcm_sha384",
998        rustls::CipherSuite::TLS13_AES_128_GCM_SHA256 => "tls13_aes_128_gcm_sha256",
999        rustls::CipherSuite::TLS13_CHACHA20_POLY1305_SHA256 => "tls13_chacha20_poly1305_sha256",
1000        rustls::CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 => {
1001            "tls12_ecdhe_ecdsa_aes_256_gcm_sha384"
1002        },
1003        rustls::CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 => {
1004            "tls12_ecdhe_ecdsa_aes_128_gcm_sha256"
1005        },
1006        rustls::CipherSuite::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 => {
1007            "tls12_ecdhe_ecdsa_chacha20_poly1305_sha256"
1008        },
1009        rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 => {
1010            "tls12_ecdhe_rsa_aes_256_gcm_sha384"
1011        },
1012        rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 => {
1013            "tls12_ecdhe_rsa_aes_128_gcm_sha256"
1014        },
1015        rustls::CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 => {
1016            "tls12_ecdhe_rsa_chacha20_poly1305_sha256"
1017        },
1018        _ => return None,
1019    })
1020}
1021
1022fn earlier_deadline(
1023    first: Option<tokio::time::Instant>,
1024    second: Option<tokio::time::Instant>,
1025) -> Option<tokio::time::Instant> {
1026    match (first, second) {
1027        (Some(first), Some(second)) => Some(first.min(second)),
1028        (Some(value), None) | (None, Some(value)) => Some(value),
1029        (None, None) => None,
1030    }
1031}
1032
1033#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1034enum StepStop {
1035    Cancelled,
1036    Deadline,
1037}
1038
1039async fn bounded<T, F>(
1040    cancellation: &CancellationToken,
1041    deadline: tokio::time::Instant,
1042    future: F,
1043) -> Result<T, StepStop>
1044where
1045    F: Future<Output = T>,
1046{
1047    tokio::select! {
1048        biased;
1049        () = cancellation.cancelled() => Err(StepStop::Cancelled),
1050        result = tokio::time::timeout_at(deadline, future) => {
1051            result.map_err(|_| StepStop::Deadline)
1052        },
1053    }
1054}
1055
1056#[derive(Default)]
1057struct TotalByteCounters {
1058    ingress: AtomicU64,
1059    egress: AtomicU64,
1060}
1061
1062#[derive(Clone)]
1063struct CellByteCounters {
1064    ingress: Arc<AtomicU64>,
1065    egress: Arc<AtomicU64>,
1066    totals: Arc<TotalByteCounters>,
1067    local_ingress_limit_reached: Arc<AtomicBool>,
1068    local_egress_limit_reached: Arc<AtomicBool>,
1069    parent_ingress_limit_reached: Arc<AtomicBool>,
1070}
1071
1072impl CellByteCounters {
1073    fn new(totals: Arc<TotalByteCounters>) -> Self {
1074        Self {
1075            ingress: Arc::new(AtomicU64::new(0)),
1076            egress: Arc::new(AtomicU64::new(0)),
1077            totals,
1078            local_ingress_limit_reached: Arc::new(AtomicBool::new(false)),
1079            local_egress_limit_reached: Arc::new(AtomicBool::new(false)),
1080            parent_ingress_limit_reached: Arc::new(AtomicBool::new(false)),
1081        }
1082    }
1083}
1084
1085struct CountedTlsIo<'a> {
1086    inner: TcpStream,
1087    lease: &'a mut RequestAccountingLease,
1088    counters: CellByteCounters,
1089}
1090
1091impl<'a> CountedTlsIo<'a> {
1092    fn new(
1093        inner: TcpStream,
1094        lease: &'a mut RequestAccountingLease,
1095        counters: CellByteCounters,
1096    ) -> Self {
1097        Self {
1098            inner,
1099            lease,
1100            counters,
1101        }
1102    }
1103}
1104
1105impl AsyncRead for CountedTlsIo<'_> {
1106    fn poll_read(
1107        self: Pin<&mut Self>,
1108        context: &mut Context<'_>,
1109        buffer: &mut ReadBuf<'_>,
1110    ) -> Poll<io::Result<()>> {
1111        let this = self.get_mut();
1112        if buffer.remaining() == 0 {
1113            return Poll::Ready(Ok(()));
1114        }
1115        let cell_remaining = MAX_CELL_INGRESS_TLS_BYTES
1116            .saturating_sub(this.counters.ingress.load(Ordering::Relaxed));
1117        let total_remaining = MAX_TOTAL_INGRESS_TLS_BYTES
1118            .saturating_sub(this.counters.totals.ingress.load(Ordering::Relaxed));
1119        let parent_remaining = this.lease.remaining_response_bytes();
1120        let allowed = u64::try_from(buffer.remaining())
1121            .unwrap_or(u64::MAX)
1122            .min(cell_remaining)
1123            .min(total_remaining)
1124            .min(parent_remaining)
1125            .min(MAX_SINGLE_RAW_READ as u64);
1126        if allowed == 0 {
1127            if parent_remaining == 0 {
1128                this.counters
1129                    .parent_ingress_limit_reached
1130                    .store(true, Ordering::Relaxed);
1131            } else {
1132                this.counters
1133                    .local_ingress_limit_reached
1134                    .store(true, Ordering::Relaxed);
1135            }
1136            return Poll::Ready(Err(io::Error::other("TLS ingress limit reached")));
1137        }
1138        let mut temporary = vec![0_u8; usize::try_from(allowed).unwrap_or(MAX_SINGLE_RAW_READ)];
1139        let mut temporary_buffer = ReadBuf::new(&mut temporary);
1140        match Pin::new(&mut this.inner).poll_read(context, &mut temporary_buffer) {
1141            Poll::Ready(Ok(())) => {
1142                let read = temporary_buffer.filled().len();
1143                if read > 0 {
1144                    buffer.put_slice(&temporary_buffer.filled()[..read]);
1145                    let read_u64 = u64::try_from(read).unwrap_or(u64::MAX);
1146                    this.counters.ingress.fetch_add(read_u64, Ordering::Relaxed);
1147                    this.counters
1148                        .totals
1149                        .ingress
1150                        .fetch_add(read_u64, Ordering::Relaxed);
1151                    let _ = this.lease.observe_response_bytes(read_u64);
1152                }
1153                Poll::Ready(Ok(()))
1154            },
1155            other => other,
1156        }
1157    }
1158}
1159
1160impl AsyncWrite for CountedTlsIo<'_> {
1161    fn poll_write(
1162        self: Pin<&mut Self>,
1163        context: &mut Context<'_>,
1164        buffer: &[u8],
1165    ) -> Poll<Result<usize, io::Error>> {
1166        let this = self.get_mut();
1167        let cell_remaining =
1168            MAX_CELL_EGRESS_TLS_BYTES.saturating_sub(this.counters.egress.load(Ordering::Relaxed));
1169        let total_remaining = MAX_TOTAL_EGRESS_TLS_BYTES
1170            .saturating_sub(this.counters.totals.egress.load(Ordering::Relaxed));
1171        let allowed = usize::try_from(cell_remaining.min(total_remaining))
1172            .unwrap_or(usize::MAX)
1173            .min(buffer.len());
1174        if !buffer.is_empty() && allowed == 0 {
1175            this.counters
1176                .local_egress_limit_reached
1177                .store(true, Ordering::Relaxed);
1178            return Poll::Ready(Err(io::Error::other("TLS egress limit reached")));
1179        }
1180        match Pin::new(&mut this.inner).poll_write(context, &buffer[..allowed]) {
1181            Poll::Ready(Ok(written)) => {
1182                let written = u64::try_from(written).unwrap_or(u64::MAX);
1183                this.counters.egress.fetch_add(written, Ordering::Relaxed);
1184                this.counters
1185                    .totals
1186                    .egress
1187                    .fetch_add(written, Ordering::Relaxed);
1188                Poll::Ready(Ok(usize::try_from(written).unwrap_or(usize::MAX)))
1189            },
1190            other => other,
1191        }
1192    }
1193
1194    fn poll_flush(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
1195        Pin::new(&mut self.get_mut().inner).poll_flush(context)
1196    }
1197
1198    fn poll_shutdown(
1199        self: Pin<&mut Self>,
1200        context: &mut Context<'_>,
1201    ) -> Poll<Result<(), io::Error>> {
1202        Pin::new(&mut self.get_mut().inner).poll_shutdown(context)
1203    }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209
1210    struct OwnedTlsMaterial {
1211        root_der: Vec<u8>,
1212        leaf_der: Vec<u8>,
1213        key_der: Vec<u8>,
1214    }
1215
1216    struct OwnedTlsAccept {
1217        protocol: rustls::ProtocolVersion,
1218        alpn_selected: bool,
1219        application_bytes: usize,
1220    }
1221
1222    fn owned_tls_material(dns_name: &str) -> OwnedTlsMaterial {
1223        use rcgen::{
1224            date_time_ymd, BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose,
1225            IsCa, KeyPair, KeyUsagePurpose,
1226        };
1227
1228        let mut root_params = CertificateParams::new(Vec::<String>::new()).unwrap();
1229        root_params
1230            .distinguished_name
1231            .push(DnType::CommonName, "Termivar active TLS test root");
1232        root_params.not_before = date_time_ymd(2020, 1, 1);
1233        root_params.not_after = date_time_ymd(2100, 1, 1);
1234        root_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
1235        root_params.key_usages = vec![
1236            KeyUsagePurpose::KeyCertSign,
1237            KeyUsagePurpose::DigitalSignature,
1238        ];
1239        let root_key = KeyPair::generate().unwrap();
1240        let root = root_params.self_signed(&root_key).unwrap();
1241
1242        let mut leaf_params = CertificateParams::new(vec![dns_name.to_owned()]).unwrap();
1243        leaf_params
1244            .distinguished_name
1245            .push(DnType::CommonName, dns_name);
1246        leaf_params.not_before = date_time_ymd(2020, 1, 1);
1247        leaf_params.not_after = date_time_ymd(2100, 1, 1);
1248        leaf_params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
1249        leaf_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
1250        let leaf_key = KeyPair::generate().unwrap();
1251        let leaf = leaf_params.signed_by(&leaf_key, &root, &root_key).unwrap();
1252
1253        OwnedTlsMaterial {
1254            root_der: root.der().to_vec(),
1255            leaf_der: leaf.der().to_vec(),
1256            key_der: leaf_key.serialize_der(),
1257        }
1258    }
1259
1260    async fn owned_tls_server(
1261        material: &OwnedTlsMaterial,
1262        connection_count: usize,
1263    ) -> (SocketAddr, tokio::task::JoinHandle<Vec<OwnedTlsAccept>>) {
1264        use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
1265        use tokio::io::AsyncReadExt;
1266        use tokio_rustls::{rustls::ServerConfig, TlsAcceptor};
1267
1268        let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
1269            .await
1270            .unwrap();
1271        let address = listener.local_addr().unwrap();
1272        let certificates = vec![CertificateDer::from(material.leaf_der.clone())];
1273        let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(material.key_der.clone()));
1274        let server = ServerConfig::builder()
1275            .with_no_client_auth()
1276            .with_single_cert(certificates, key)
1277            .unwrap();
1278        let acceptor = TlsAcceptor::from(Arc::new(server));
1279        let task = tokio::spawn(async move {
1280            let mut accepted = Vec::with_capacity(connection_count);
1281            for _ in 0..connection_count {
1282                let (stream, peer) = listener.accept().await.unwrap();
1283                assert!(peer.ip().is_loopback());
1284                let mut stream = acceptor.accept(stream).await.unwrap();
1285                let connection = stream.get_ref().1;
1286                let protocol = connection.protocol_version().unwrap();
1287                let alpn_selected = connection.alpn_protocol().is_some();
1288                let mut application = [0_u8; 1];
1289                let application_bytes = stream.read(&mut application).await.unwrap();
1290                accepted.push(OwnedTlsAccept {
1291                    protocol,
1292                    alpn_selected,
1293                    application_bytes,
1294                });
1295            }
1296            accepted
1297        });
1298        (address, task)
1299    }
1300
1301    #[test]
1302    fn selected_empty_matrix_has_fixed_order_and_zero_network_legacy_rows() {
1303        let audit = WebAssessmentTlsNegotiationReviewAudit::empty();
1304        assert!(audit.is_valid());
1305        assert!(!audit.network_matrix_complete());
1306        assert_eq!(
1307            audit
1308                .cells()
1309                .iter()
1310                .map(|cell| cell.id())
1311                .collect::<Vec<_>>(),
1312            vec![
1313                "tls13_default_provider",
1314                "tls12_default_provider",
1315                "tls11_legacy_protocol",
1316                "tls10_legacy_protocol",
1317                "legacy_cipher_suites",
1318            ]
1319        );
1320        for cell in &audit.cells()[2..] {
1321            assert_eq!(cell.dispatch_status(), "not_dispatched");
1322            assert_eq!(cell.outcome(), "client_backend_unsupported");
1323            assert_eq!(cell.support(), "not_tested_client_backend_unsupported");
1324            assert_eq!(cell.transport_validation(), "not_applicable");
1325            assert_eq!(cell.accounted_ingress_tls_bytes(), 0);
1326            assert_eq!(cell.observed_egress_tls_bytes(), 0);
1327        }
1328    }
1329
1330    #[test]
1331    fn parent_unavailable_is_selected_but_never_dispatched() {
1332        let audit = WebAssessmentTlsNegotiationReviewAudit::parent_authority_unavailable();
1333        assert!(audit.is_valid());
1334        assert!(!audit.network_matrix_complete());
1335        assert_eq!(audit.terminal(), "parent_authority_unavailable");
1336        assert_eq!(audit.attempted_connection_count(), 0);
1337        assert_eq!(audit.cells()[0].outcome(), "parent_authority_unavailable");
1338        assert_eq!(audit.cells()[1].outcome(), "parent_authority_unavailable");
1339    }
1340
1341    #[test]
1342    fn cipher_and_protocol_tokens_are_closed() {
1343        assert_eq!(
1344            protocol_token(rustls::ProtocolVersion::TLSv1_3),
1345            Some("tls1.3")
1346        );
1347        assert_eq!(
1348            cipher_suite_token(rustls::CipherSuite::TLS13_AES_256_GCM_SHA384),
1349            Some("tls13_aes_256_gcm_sha384")
1350        );
1351        assert_eq!(
1352            cipher_suite_token(rustls::CipherSuite::TLS_NULL_WITH_NULL_NULL),
1353            None
1354        );
1355    }
1356
1357    #[test]
1358    fn target_preflight_matches_dns_sni_authority_without_network_access() {
1359        for target in [
1360            "https://example.test/",
1361            "https://xml-target.termivar.test:8443/review",
1362        ] {
1363            assert!(tls_negotiation_review_target_is_supported(
1364                &Url::parse(target).unwrap()
1365            ));
1366        }
1367        for target in [
1368            "http://example.test/",
1369            "https://127.0.0.1/",
1370            "https://user@example.test/",
1371            "https://-bad.example/",
1372            "https://bad-.example/",
1373        ] {
1374            assert!(!tls_negotiation_review_target_is_supported(
1375                &Url::parse(target).unwrap()
1376            ));
1377        }
1378    }
1379
1380    #[test]
1381    fn address_selection_consumes_at_most_sixteen_answers_in_stable_order() {
1382        use std::cell::Cell;
1383
1384        let consumed = Cell::new(0_usize);
1385        let answers = (1_u8..=17).rev().map(|last_octet| {
1386            consumed.set(consumed.get() + 1);
1387            SocketAddr::from(([192, 0, 2, last_octet], 443))
1388        });
1389        assert_eq!(
1390            select_resolved_address(answers),
1391            Some(SocketAddr::from(([192, 0, 2, 2], 443)))
1392        );
1393        assert_eq!(consumed.get(), MAX_RESOLVED_ADDRESSES);
1394
1395        let duplicate = SocketAddr::from(([192, 0, 2, 20], 443));
1396        let distinct_after_limit = SocketAddr::from(([192, 0, 2, 1], 443));
1397        assert_eq!(
1398            select_resolved_address(
1399                std::iter::repeat_n(duplicate, MAX_RESOLVED_ADDRESSES)
1400                    .chain(std::iter::once(distinct_after_limit))
1401            ),
1402            Some(duplicate)
1403        );
1404    }
1405
1406    #[test]
1407    fn tls_byte_limit_classification_includes_close_notify_egress_failure() {
1408        let accounting = RequestAccountingBroker::new(crate::RuntimeBudget::default());
1409        let mut lease = accounting
1410            .try_begin(
1411                TLS_NEGOTIATION_REVIEW_ACTION_TLS13,
1412                DecisionExecutionStage::Active,
1413                None,
1414            )
1415            .unwrap();
1416        let totals = Arc::new(TotalByteCounters::default());
1417        let counters = CellByteCounters::new(Arc::clone(&totals));
1418
1419        assert!(!tls_byte_limit_reached(&lease, &counters, &totals));
1420        counters
1421            .local_egress_limit_reached
1422            .store(true, Ordering::Relaxed);
1423        assert!(tls_byte_limit_reached(&lease, &counters, &totals));
1424        lease.finish(TransportDispatchOutcome::ResponseBudgetReached);
1425    }
1426
1427    #[test]
1428    fn complete_matrix_requires_both_parent_accounted_network_dispatches() {
1429        let mut audit = WebAssessmentTlsNegotiationReviewAudit::empty();
1430        audit.attempted_connection_count = 2;
1431        for cell in audit.cells.iter_mut().take(NETWORK_CELL_COUNT) {
1432            cell.dispatch_status = DispatchStatus::Dispatched;
1433            cell.outcome = CellOutcome::TransportFailed;
1434        }
1435
1436        assert!(audit.is_valid());
1437        assert!(audit.network_matrix_complete());
1438
1439        audit.attempted_connection_count = 1;
1440        audit.cells[1] =
1441            WebAssessmentTlsNegotiationReviewCell::pending(CellId::Tls12DefaultProvider);
1442        assert!(audit.is_valid());
1443        assert!(!audit.network_matrix_complete());
1444    }
1445
1446    #[tokio::test]
1447    async fn owned_tls_matrix_negotiates_two_versions_without_application_data() {
1448        let dns_name = "active-tls.termivar.test";
1449        let material = owned_tls_material(dns_name);
1450        let (address, server) = owned_tls_server(&material, NETWORK_CELL_COUNT).await;
1451        let accounting = RequestAccountingBroker::new(
1452            crate::RuntimeBudget::default()
1453                .with_max_total_requests(NETWORK_CELL_COUNT as u32)
1454                .with_max_active_verifications(NETWORK_CELL_COUNT as u16),
1455        );
1456        let target = Url::parse(&format!("https://{dns_name}:{}/review", address.port())).unwrap();
1457        let runtime = TlsNegotiationReviewRuntime::new(
1458            &target,
1459            accounting.clone(),
1460            CancellationToken::new(),
1461            None,
1462            Some(TlsNegotiationOwnedTransportProfile::new(
1463                address,
1464                material.root_der,
1465            )),
1466        )
1467        .unwrap();
1468
1469        let audit = runtime.execute().await;
1470        let accepted = tokio::time::timeout(Duration::from_secs(5), server)
1471            .await
1472            .expect("owned TLS server completed")
1473            .unwrap();
1474
1475        assert!(audit.is_valid());
1476        assert!(audit.network_matrix_complete());
1477        assert_eq!(audit.terminal(), "completed");
1478        assert_eq!(audit.attempted_connection_count(), 2);
1479        assert_eq!(audit.completed_handshake_count(), 2);
1480        assert!(audit.accounted_ingress_tls_bytes() > 0);
1481        assert!(audit.observed_egress_tls_bytes() > 0);
1482        assert_eq!(
1483            audit
1484                .cells()
1485                .iter()
1486                .take(NETWORK_CELL_COUNT)
1487                .map(|cell| (cell.outcome(), cell.negotiated_protocol()))
1488                .collect::<Vec<_>>(),
1489            vec![
1490                ("negotiated", Some("tls1.3")),
1491                ("negotiated", Some("tls1.2"))
1492            ]
1493        );
1494        assert_eq!(
1495            accepted
1496                .iter()
1497                .map(|fact| fact.protocol)
1498                .collect::<Vec<_>>(),
1499            vec![
1500                rustls::ProtocolVersion::TLSv1_3,
1501                rustls::ProtocolVersion::TLSv1_2
1502            ]
1503        );
1504        assert!(accepted
1505            .iter()
1506            .all(|fact| !fact.alpn_selected && fact.application_bytes == 0));
1507
1508        let transport = accounting.dispatch_audit();
1509        assert_eq!(transport.receipts().len(), NETWORK_CELL_COUNT);
1510        assert_eq!(transport.omitted_receipt_count(), 0);
1511        assert!(transport.receipts().iter().all(|receipt| {
1512            receipt.stage() == DecisionExecutionStage::Active
1513                && receipt.request_body_bytes() == 0
1514                && receipt.response_bytes() > 0
1515                && receipt.outcome() == TransportDispatchOutcome::Completed
1516        }));
1517    }
1518
1519    #[tokio::test]
1520    async fn cancellation_and_parent_budget_stop_before_any_connection() {
1521        let dns_name = "active-tls.termivar.test";
1522        let material = owned_tls_material(dns_name);
1523        let fixed_address = SocketAddr::from(([127, 0, 0, 1], 9));
1524
1525        let cancelled_accounting = RequestAccountingBroker::new(crate::RuntimeBudget::default());
1526        let cancellation = CancellationToken::new();
1527        cancellation.cancel();
1528        let cancelled = TlsNegotiationReviewRuntime::new(
1529            &Url::parse(&format!("https://{dns_name}/")).unwrap(),
1530            cancelled_accounting.clone(),
1531            cancellation,
1532            None,
1533            Some(TlsNegotiationOwnedTransportProfile::new(
1534                fixed_address,
1535                material.root_der.clone(),
1536            )),
1537        )
1538        .unwrap()
1539        .execute()
1540        .await;
1541        assert!(cancelled.is_valid());
1542        assert_eq!(cancelled.terminal(), "cancelled");
1543        assert_eq!(cancelled.attempted_connection_count(), 0);
1544        assert!(cancelled_accounting.dispatch_audit().receipts().is_empty());
1545
1546        let exhausted_accounting = RequestAccountingBroker::new(
1547            crate::RuntimeBudget::default()
1548                .with_max_total_requests(0)
1549                .with_max_active_verifications(0),
1550        );
1551        let exhausted = TlsNegotiationReviewRuntime::new(
1552            &Url::parse(&format!("https://{dns_name}/")).unwrap(),
1553            exhausted_accounting.clone(),
1554            CancellationToken::new(),
1555            None,
1556            Some(TlsNegotiationOwnedTransportProfile::new(
1557                fixed_address,
1558                material.root_der,
1559            )),
1560        )
1561        .unwrap()
1562        .execute()
1563        .await;
1564        assert!(exhausted.is_valid());
1565        assert_eq!(exhausted.terminal(), "budget_exhausted");
1566        assert_eq!(exhausted.attempted_connection_count(), 0);
1567        assert_eq!(exhausted.cells()[0].outcome(), "budget_exhausted");
1568        assert!(exhausted_accounting.dispatch_audit().receipts().is_empty());
1569    }
1570
1571    #[tokio::test]
1572    async fn failed_first_handshake_cannot_reset_the_shared_active_limit() {
1573        let dns_name = "active-tls.termivar.test";
1574        let material = owned_tls_material(dns_name);
1575        let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
1576            .await
1577            .unwrap();
1578        let address = listener.local_addr().unwrap();
1579        let server = tokio::spawn(async move {
1580            let (stream, peer) = listener.accept().await.unwrap();
1581            assert!(peer.ip().is_loopback());
1582            drop(stream);
1583        });
1584        let accounting = RequestAccountingBroker::new(
1585            crate::RuntimeBudget::default()
1586                .with_max_total_requests(2)
1587                .with_max_active_verifications(1),
1588        );
1589        let runtime = TlsNegotiationReviewRuntime::new(
1590            &Url::parse(&format!("https://{dns_name}:{}/", address.port())).unwrap(),
1591            accounting.clone(),
1592            CancellationToken::new(),
1593            None,
1594            Some(TlsNegotiationOwnedTransportProfile::new(
1595                address,
1596                material.root_der,
1597            )),
1598        )
1599        .unwrap();
1600
1601        let audit = runtime.execute().await;
1602        tokio::time::timeout(Duration::from_secs(5), server)
1603            .await
1604            .expect("rejecting server completed")
1605            .unwrap();
1606
1607        assert!(audit.is_valid());
1608        assert_eq!(audit.terminal(), "budget_exhausted");
1609        assert_eq!(audit.attempted_connection_count(), 1);
1610        assert_eq!(audit.cells()[0].dispatch_status(), "dispatched");
1611        assert_eq!(audit.cells()[0].outcome(), "transport_failed");
1612        assert_eq!(audit.cells()[1].dispatch_status(), "not_dispatched");
1613        assert_eq!(audit.cells()[1].outcome(), "budget_exhausted");
1614        let transport = accounting.dispatch_audit();
1615        assert_eq!(transport.receipts().len(), 1);
1616        assert_eq!(
1617            transport.receipts()[0].outcome(),
1618            TransportDispatchOutcome::TransportFailure
1619        );
1620    }
1621
1622    #[tokio::test]
1623    async fn receipt_capacity_exhaustion_refuses_before_dns_or_transport() {
1624        let budget = crate::RuntimeBudget::default().with_max_total_requests(
1625            u32::try_from(HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS + 2).unwrap(),
1626        );
1627        let accounting = RequestAccountingBroker::new(budget);
1628        for _ in 0..HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS - 1 {
1629            let mut lease = accounting
1630                .try_begin("fixture.passive", DecisionExecutionStage::Passive, None)
1631                .unwrap();
1632            lease.finish(TransportDispatchOutcome::Completed);
1633        }
1634        let target = Url::parse("https://receipt-capacity.invalid/").unwrap();
1635        let runtime = TlsNegotiationReviewRuntime::new(
1636            &target,
1637            accounting.clone(),
1638            CancellationToken::new(),
1639            None,
1640            None,
1641        )
1642        .unwrap();
1643
1644        let audit = runtime.execute().await;
1645
1646        assert!(audit.is_valid());
1647        assert!(!audit.network_matrix_complete());
1648        assert_eq!(audit.terminal(), "budget_exhausted");
1649        assert_eq!(audit.attempted_connection_count(), 0);
1650        assert_eq!(audit.cells()[0].dispatch_status(), "not_dispatched");
1651        assert_eq!(audit.cells()[0].outcome(), "budget_exhausted");
1652        assert_eq!(audit.cells()[1].outcome(), "not_reached");
1653        let transport = accounting.dispatch_audit();
1654        assert_eq!(
1655            transport.receipts().len(),
1656            HARD_MAX_TRANSPORT_DISPATCH_RECEIPTS - 1
1657        );
1658        assert_eq!(transport.omitted_receipt_count(), 0);
1659        assert!(transport.receipts().iter().all(|receipt| {
1660            receipt.action_id() != TLS_NEGOTIATION_REVIEW_ACTION_TLS13
1661                && receipt.action_id() != TLS_NEGOTIATION_REVIEW_ACTION_TLS12
1662        }));
1663    }
1664}