Skip to main content

flowscope/tls/
handshake.rs

1//! [`TlsHandshakeParser`] — aggregates ClientHello + ServerHello +
2//! Alert into a single [`TlsHandshake`] message per flow.
3//!
4//! The existing [`super::TlsParser`] emits per-message events;
5//! consumers tracking "what handshake happened on this flow"
6//! hand-rolled correlation across those events. This parser
7//! does that stitching internally and emits one rich event per
8//! handshake outcome.
9
10use bytes::Bytes;
11
12use super::{
13    TlsParser,
14    session::TlsMessage,
15    types::{TlsConfig, TlsVersion},
16};
17use crate::{Timestamp, session::SessionParser};
18
19/// Stable identifier for the handshake aggregator — the
20/// [`ParserKind::TlsHandshake`](crate::ParserKind::TlsHandshake)
21/// slug, so consumers don't have to write the magic string.
22pub const PARSER_KIND: &str = "tls-handshake";
23
24/// Outcome of an observed handshake.
25#[derive(Debug, Clone, PartialEq)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27#[non_exhaustive]
28pub enum HandshakeOutcome {
29    /// ServerHello arrived; no fatal alert observed during the
30    /// handshake window.
31    Completed,
32    /// Server sent a fatal alert.
33    AlertedByServer { description: u8 },
34    /// Client sent a fatal alert.
35    AlertedByClient { description: u8 },
36    /// Flow ended (FIN/RST/timeout) before a ServerHello arrived.
37    Truncated,
38}
39
40/// Aggregated TLS handshake event.
41///
42/// One emitted per observed handshake. Carries enough fields for
43/// the common "log TLS handshake" use case (SNI, ALPN, version,
44/// cipher, JA3/JA4 if features on, outcome) without consumers
45/// hand-rolling correlation across the per-message stream.
46#[derive(Debug, Clone, PartialEq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48#[non_exhaustive]
49pub struct TlsHandshake {
50    pub sni: Option<String>,
51    pub client_alpn: Vec<String>,
52    pub server_alpn: Option<String>,
53    /// JA3 fingerprint (MD5 hex). Set when `ja3` feature on.
54    pub ja3: Option<String>,
55    /// JA4 fingerprint (FoxIO format). Set when `ja4` feature on.
56    pub ja4: Option<String>,
57    /// JA4S server fingerprint (FoxIO format), from the ServerHello.
58    /// Set when the `ja4` config + opt-in `ja4plus` feature are on.
59    /// New in 0.15.0; moved behind `ja4plus` in 0.16.0 (FoxIO License 1.1 —
60    /// see NOTICE).
61    #[cfg(feature = "ja4plus")]
62    pub ja4s: Option<String>,
63    /// Negotiated TLS version (from ServerHello supported_versions
64    /// if present, else legacy_version).
65    pub version: Option<TlsVersion>,
66    /// Server's selected cipher suite.
67    pub cipher_suite: Option<u16>,
68    /// True iff the client sent PSK / session-ticket extensions
69    /// (indicates resumption was attempted).
70    pub resumption_attempted: bool,
71    /// Final outcome.
72    pub outcome: HandshakeOutcome,
73
74    // ── ECH — plan 144, 0.12.0 ───────────────────────────
75    /// Aggregated ECH outcome derived from the client's
76    /// `ech_present` flag plus the server's
77    /// `ech_retry_configs` plaintext-fallback signal.
78    pub ech_outcome: EchOutcome,
79    /// HPKE `config_id` from the client's outer ECHClientHello,
80    /// when ECH was offered.
81    pub ech_config_id: Option<u8>,
82    /// `true` if the client offered a post-quantum hybrid
83    /// key-exchange group (X25519MLKEM768 family). Mirrors
84    /// [`TlsClientHello::pq_key_share`](super::TlsClientHello::pq_key_share);
85    /// the dominant reason a modern ClientHello spans multiple
86    /// segments. Issue #135 (0.22.0).
87    pub pq_key_share: bool,
88    /// Server's certificate chain from a TLS 1.2 `Certificate`
89    /// handshake record (leaf first per RFC 5246 §7.4.2). Each
90    /// entry is an X.509 DER blob. Empty for TLS 1.3
91    /// (cert chain is encrypted) and for sessions where no cert
92    /// record was observed before the parser stopped. New in
93    /// 0.18.0 (issue #24 prereq).
94    pub certificate_chain: Vec<Bytes>,
95    /// JA4X server-certificate fingerprint of the **leaf**
96    /// (first) cert in [`Self::certificate_chain`]. Behind
97    /// the FoxIO-licensed `ja4plus` feature. `None` when no
98    /// cert chain was observed (TLS 1.3 / truncated /
99    /// `ja4plus` off) or the DER failed to parse.
100    /// Issue #24 (0.18.0).
101    #[cfg(feature = "ja4plus")]
102    pub ja4x: Option<String>,
103}
104
105/// Aggregate ECH outcome on a [`TlsHandshake`]. Plan 144, 0.12.0.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
108#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
109#[non_exhaustive]
110pub enum EchOutcome {
111    /// Client did not offer ECH.
112    NotOffered,
113    /// Client offered ECH and the server did not signal
114    /// rejection in observable plaintext. Most ECH-accepted
115    /// handshakes land here (EncryptedExtensions carries
116    /// retry_configs encrypted; we can't see them).
117    Accepted,
118    /// Client offered ECH; server's plaintext EE carries
119    /// retry_configs — explicit rejection on early handshake
120    /// errors.
121    Rejected,
122    /// Client offered ECH; outcome indeterminate (handshake
123    /// did not reach ServerHello / EncryptedExtensions).
124    Unknown,
125}
126
127impl TlsHandshake {
128    /// Classify the ECH (Encrypted ClientHello) state from the
129    /// handshake-level evidence — the client's ECH offer and the
130    /// server's plaintext-observable rejection signal.
131    ///
132    /// Refines [`crate::tls::TlsClientHello::ech_state`] with
133    /// the server side:
134    ///
135    /// - `ech_outcome == NotOffered` → [`super::EchState::NotPresent`]
136    /// - `ech_outcome == Rejected` → [`super::EchState::Rejected`]
137    /// - else: classify by outer SNI via
138    ///   [`super::ech::classify_client_hello`] (Accepted /
139    ///   Unknown without `retry_configs` can't be distinguished
140    ///   from GREASE without further evidence).
141    ///
142    /// Issue #8 (0.18).
143    pub fn ech_state(&self) -> super::EchState {
144        match self.ech_outcome {
145            EchOutcome::NotOffered => super::EchState::NotPresent,
146            EchOutcome::Rejected => super::EchState::Rejected,
147            EchOutcome::Accepted | EchOutcome::Unknown => {
148                super::ech::classify_client_hello(true, self.sni.as_deref())
149            }
150        }
151    }
152
153    /// The ALPN protocol to route on: the server's selection if the
154    /// handshake got that far, otherwise the client's first offer.
155    ///
156    /// The server's choice is the negotiated outcome; the client's
157    /// list is only a wish. A terminating proxy uses this to decide
158    /// whether to speak HTTP/1 or HTTP/2 on the connection.
159    ///
160    /// ```
161    /// use flowscope::tls::TlsHandshake;
162    ///
163    /// let mut hs = TlsHandshake::default();
164    /// hs.client_alpn = vec!["h2".into(), "http/1.1".into()];
165    /// assert_eq!(hs.routing_alpn(), Some("h2"));
166    ///
167    /// // Once the server picks, that wins over the offer order.
168    /// hs.server_alpn = Some("http/1.1".into());
169    /// assert_eq!(hs.routing_alpn(), Some("http/1.1"));
170    /// ```
171    pub fn routing_alpn(&self) -> Option<&str> {
172        self.server_alpn
173            .as_deref()
174            .or_else(|| self.client_alpn.first().map(String::as_str))
175    }
176
177    /// The name to route on, and whether it can be trusted as the
178    /// client's real target.
179    ///
180    /// Returns `(name, is_real_target)`. With ECH in play the SNI on
181    /// the wire is the outer cover domain, so `is_real_target` is
182    /// `false` and the caller should expect to fall further down the
183    /// ladder in `docs/tls-routing.md`. `None` means no SNI at all —
184    /// route by JA4, first-byte class, or pass through.
185    ///
186    /// This never *fails* on ECH: degrading is the designed
187    /// behaviour, and refusing connections that offer ECH would
188    /// refuse a growing share of ordinary traffic.
189    ///
190    /// ```
191    /// use flowscope::tls::{EchOutcome, TlsHandshake};
192    ///
193    /// let mut hs = TlsHandshake::default();
194    /// hs.sni = Some("api.example.com".into());
195    /// assert_eq!(hs.routing_sni(), Some(("api.example.com", true)));
196    ///
197    /// // With ECH offered, the same field is only a cover name.
198    /// hs.ech_outcome = EchOutcome::Accepted;
199    /// assert_eq!(hs.routing_sni(), Some(("api.example.com", false)));
200    /// ```
201    pub fn routing_sni(&self) -> Option<(&str, bool)> {
202        let name = self.sni.as_deref()?;
203        let is_real_target = matches!(self.ech_outcome, EchOutcome::NotOffered);
204        Some((name, is_real_target))
205    }
206}
207
208impl Default for TlsHandshake {
209    fn default() -> Self {
210        Self {
211            sni: None,
212            client_alpn: Vec::new(),
213            server_alpn: None,
214            ja3: None,
215            ja4: None,
216            #[cfg(feature = "ja4plus")]
217            ja4s: None,
218            version: None,
219            cipher_suite: None,
220            resumption_attempted: false,
221            outcome: HandshakeOutcome::Truncated,
222            ech_outcome: EchOutcome::NotOffered,
223            ech_config_id: None,
224            pq_key_share: false,
225            certificate_chain: Vec::new(),
226            #[cfg(feature = "ja4plus")]
227            ja4x: None,
228        }
229    }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233enum State {
234    AwaitingClientHello,
235    AwaitingServerHello,
236    /// After ServerHello until the next ClientHello (resumption /
237    /// renegotiation produces a fresh handshake event).
238    Completed,
239}
240
241/// `SessionParser` that emits one `TlsHandshake` per observed
242/// handshake on the underlying TCP flow.
243///
244/// Reuses [`TlsParser`] internally for the per-message decode
245/// (no duplication of the tls-parser bridge). The handshake
246/// aggregator accumulates fields from successive messages and
247/// emits on terminal events (ServerHello completion or alert).
248#[derive(Debug, Clone)]
249pub struct TlsHandshakeParser {
250    inner: TlsParser,
251    state: State,
252    accumulator: TlsHandshake,
253}
254
255impl Default for TlsHandshakeParser {
256    fn default() -> Self {
257        Self::with_config(TlsConfig {
258            ja3: cfg!(feature = "tls-fingerprints"),
259            ja4: cfg!(feature = "tls-fingerprints"),
260            ..Default::default()
261        })
262    }
263}
264
265impl TlsHandshakeParser {
266    /// Construct with explicit config. Defaults turn on JA3/JA4
267    /// when their features are enabled.
268    pub fn with_config(config: TlsConfig) -> Self {
269        Self {
270            inner: TlsParser::with_config(config),
271            state: State::AwaitingClientHello,
272            accumulator: TlsHandshake::default(),
273        }
274    }
275
276    fn process(&mut self, msgs: Vec<TlsMessage>, out: &mut Vec<TlsHandshake>) {
277        for msg in msgs {
278            match msg {
279                TlsMessage::ClientHello(ch) => {
280                    // A new ClientHello while we were in Completed
281                    // state — emit the previous (if any) as a fresh
282                    // event for the next handshake.
283                    self.accumulator.sni = ch.sni.clone();
284                    self.accumulator.client_alpn = ch.alpn.clone();
285                    self.accumulator.pq_key_share = ch.pq_key_share;
286                    // 41 = pre_shared_key, 35 = session_ticket
287                    self.accumulator.resumption_attempted =
288                        ch.extension_types.iter().any(|&e| e == 41 || e == 35);
289                    // Plan 144: capture ECH offer state from the
290                    // outer ClientHello.
291                    if ch.ech_present {
292                        self.accumulator.ech_outcome = EchOutcome::Unknown;
293                        self.accumulator.ech_config_id = ch.ech_config_id;
294                    }
295                    self.state = State::AwaitingServerHello;
296                }
297                TlsMessage::ServerHello(sh) => {
298                    self.accumulator.server_alpn = sh.alpn.clone();
299                    self.accumulator.cipher_suite = Some(sh.cipher_suite);
300                    self.accumulator.version =
301                        Some(sh.supported_version.unwrap_or(sh.legacy_version));
302                    self.accumulator.outcome = HandshakeOutcome::Completed;
303                    // Plan 144: derive ECH outcome from the
304                    // server's plaintext-observable signal.
305                    if matches!(self.accumulator.ech_outcome, EchOutcome::Unknown) {
306                        self.accumulator.ech_outcome = if sh.ech_retry_configs {
307                            EchOutcome::Rejected
308                        } else {
309                            EchOutcome::Accepted
310                        };
311                    }
312                    let done = std::mem::take(&mut self.accumulator);
313                    out.push(done);
314                    self.state = State::Completed;
315                }
316                TlsMessage::Alert(a) => {
317                    if a.level == super::types::TlsAlertLevel::Fatal {
318                        // Caller-side fatal alert. We can't tell
319                        // initiator from responder at this layer
320                        // (alerts can come from either); attribute
321                        // to whichever side was "expected" given
322                        // current state.
323                        let outcome = match self.state {
324                            State::AwaitingServerHello => HandshakeOutcome::AlertedByServer {
325                                description: a.description,
326                            },
327                            _ => HandshakeOutcome::AlertedByClient {
328                                description: a.description,
329                            },
330                        };
331                        self.accumulator.outcome = outcome;
332                        let done = std::mem::take(&mut self.accumulator);
333                        out.push(done);
334                        self.state = State::AwaitingClientHello;
335                    }
336                }
337                #[cfg(feature = "tls-fingerprints")]
338                TlsMessage::Ja3 { hash, .. } => {
339                    self.accumulator.ja3 = Some(hash);
340                }
341                #[cfg(feature = "tls-fingerprints")]
342                TlsMessage::Ja4 { fingerprint } => {
343                    self.accumulator.ja4 = Some(fingerprint);
344                }
345                // Ja4s is emitted right before its ServerHello, so it
346                // lands in the accumulator before the ServerHello arm
347                // takes it. FoxIO-licensed → opt-in `ja4plus`.
348                #[cfg(feature = "ja4plus")]
349                TlsMessage::Ja4s { fingerprint } => {
350                    self.accumulator.ja4s = Some(fingerprint);
351                }
352                // TLS 1.2 cert chain — accumulate; the ServerHello
353                // arm will take it via std::mem::take.
354                TlsMessage::Certificate { chain } => {
355                    // JA4X over the leaf cert (FoxIO ja4plus).
356                    #[cfg(feature = "ja4plus")]
357                    if let Some(leaf) = chain.first() {
358                        self.accumulator.ja4x = super::ja4x::ja4x_for_der(leaf);
359                    }
360                    self.accumulator.certificate_chain = chain;
361                }
362            }
363        }
364    }
365}
366
367impl SessionParser for TlsHandshakeParser {
368    type Message = TlsHandshake;
369
370    fn parser_kind(&self) -> crate::ParserKind {
371        crate::ParserKind::TlsHandshake
372    }
373
374    fn feed_initiator(&mut self, bytes: &[u8], ts: Timestamp, out: &mut Vec<Self::Message>) {
375        let mut inner_out = Vec::new();
376        self.inner.feed_initiator(bytes, ts, &mut inner_out);
377        self.process(inner_out, out);
378    }
379
380    fn feed_responder(&mut self, bytes: &[u8], ts: Timestamp, out: &mut Vec<Self::Message>) {
381        let mut inner_out = Vec::new();
382        self.inner.feed_responder(bytes, ts, &mut inner_out);
383        self.process(inner_out, out);
384    }
385
386    fn fin_initiator(&mut self, out: &mut Vec<Self::Message>) {
387        let mut inner_out = Vec::new();
388        self.inner.fin_initiator(&mut inner_out);
389        self.process(inner_out, out);
390        if matches!(self.state, State::AwaitingServerHello) {
391            self.accumulator.outcome = HandshakeOutcome::Truncated;
392            let done = std::mem::take(&mut self.accumulator);
393            out.push(done);
394            self.state = State::AwaitingClientHello;
395        }
396    }
397
398    fn fin_responder(&mut self, out: &mut Vec<Self::Message>) {
399        let mut inner_out = Vec::new();
400        self.inner.fin_responder(&mut inner_out);
401        self.process(inner_out, out);
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn default_has_session_parser_shape() {
411        // Compile-only: the parser implements SessionParser.
412        fn assert_impls<P: SessionParser>() {}
413        assert_impls::<TlsHandshakeParser>();
414    }
415
416    #[test]
417    fn parser_kind_label() {
418        let p = TlsHandshakeParser::default();
419        assert_eq!(p.parser_kind().as_str(), "tls-handshake");
420    }
421
422    #[cfg(feature = "ja4plus")]
423    #[test]
424    fn aggregator_captures_ja4s_onto_the_handshake() {
425        use super::super::{TlsMessage, types::TlsServerHello};
426
427        let mut p = TlsHandshakeParser::default();
428        let mut out = Vec::new();
429        // Ja4s is emitted right before its ServerHello (the session
430        // parser's ordering), so it lands in the accumulator that the
431        // ServerHello arm then takes.
432        let sh = TlsServerHello {
433            cipher_suite: 0x1301,
434            ..Default::default()
435        };
436        p.process(
437            vec![
438                TlsMessage::Ja4s {
439                    fingerprint: "t130100_1301_deadbeefcafe".to_string(),
440                },
441                TlsMessage::ServerHello(Box::new(sh)),
442            ],
443            &mut out,
444        );
445        assert_eq!(out.len(), 1, "expected one completed handshake");
446        assert_eq!(out[0].ja4s.as_deref(), Some("t130100_1301_deadbeefcafe"));
447    }
448}