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}