use crate::protocol::FlowKey;
#[cfg(feature = "tls")]
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TlsFingerprint {
pub sni: Option<String>,
pub alpn: Option<String>,
pub ja3: Option<String>,
pub ja4: Option<String>,
#[cfg(feature = "ja4plus")]
pub ja4s: Option<String>,
#[cfg(feature = "ja4plus")]
pub ja4x: Option<String>,
pub pq_key_share: bool,
pub app_protocol: flowscope::app_proto::AppProtocol,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub key: Option<FlowKey>,
}
#[cfg(feature = "tls")]
impl TlsFingerprint {
pub(crate) fn from_handshake(hs: &flowscope::tls::TlsHandshake, key: Option<FlowKey>) -> Self {
let port = key.map(|k| k.b.port()).unwrap_or(443);
Self {
sni: hs.sni.clone(),
alpn: hs
.server_alpn
.clone()
.or_else(|| hs.client_alpn.first().cloned()),
ja3: hs.ja3.clone(),
ja4: hs.ja4.clone(),
#[cfg(feature = "ja4plus")]
ja4s: hs.ja4s.clone(),
#[cfg(feature = "ja4plus")]
ja4x: hs.ja4x.clone(),
pq_key_share: hs.pq_key_share,
app_protocol: flowscope::app_proto::AppProtocol::from_tls_handshake(hs, port),
key,
}
}
pub fn has_fingerprint(&self) -> bool {
let any = self.ja3.is_some() || self.ja4.is_some();
#[cfg(feature = "ja4plus")]
let any = any || self.ja4s.is_some() || self.ja4x.is_some();
any
}
}
#[cfg(all(feature = "http", feature = "ja4plus"))]
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct HttpFingerprint {
pub ja4h: String,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub method: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub host: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub user_agent: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub key: Option<FlowKey>,
}
#[cfg(all(feature = "http", feature = "ja4plus"))]
impl HttpFingerprint {
pub(crate) fn from_request(req: &flowscope::http::HttpRequest, key: Option<FlowKey>) -> Self {
Self {
ja4h: flowscope::http::ja4h_fingerprint(req),
method: req.method_str().map(str::to_owned),
host: req.host().map(str::to_owned),
user_agent: req.user_agent().map(str::to_owned),
key,
}
}
}
#[cfg(feature = "tls")]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct EncryptedDns {
pub app_protocol: flowscope::app_proto::AppProtocol,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub sni: Option<String>,
pub via_known_resolver: bool,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub key: Option<FlowKey>,
pub ts: flowscope::Timestamp,
}
#[cfg(feature = "tls")]
const KNOWN_DOH_RESOLVERS: &[&str] = &[
"cloudflare-dns.com",
"mozilla.cloudflare-dns.com",
"one.one.one.one",
"dns.google",
"dns.google.com",
"dns.quad9.net",
"doh.opendns.com",
"dns.nextdns.io",
"doh.cleanbrowsing.org",
"dns.adguard.com",
"dns.adguard-dns.com",
"doh.mullvad.net",
];
#[cfg(feature = "tls")]
pub(crate) fn is_known_doh_resolver(sni: &str) -> bool {
let s = sni.trim_end_matches('.').to_ascii_lowercase();
KNOWN_DOH_RESOLVERS.contains(&s.as_str())
}
#[cfg(feature = "quic")]
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct QuicFingerprint {
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub sni: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub alpn: Option<String>,
pub version: String,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub ja4: Option<String>,
pub pq_key_share: bool,
pub app_protocol: flowscope::app_proto::AppProtocol,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub key: Option<FlowKey>,
}
#[cfg(feature = "quic")]
impl QuicFingerprint {
pub(crate) fn from_initial(initial: &flowscope::QuicInitial, key: Option<FlowKey>) -> Self {
let port = key.map(|k| k.b.port()).unwrap_or(443);
let version = if initial.version.is_v1() {
"v1".to_string()
} else if initial.version.is_v2() {
"v2".to_string()
} else {
format!("0x{:08x}", initial.version.as_u32())
};
#[cfg(feature = "tls")]
let ja4 = flowscope::quic::ja4(initial);
#[cfg(not(feature = "tls"))]
let ja4 = None;
#[cfg(feature = "tls")]
let pq_key_share = initial
.client_hello
.as_ref()
.is_some_and(|ch| ch.pq_key_share);
#[cfg(not(feature = "tls"))]
let pq_key_share = false;
Self {
sni: initial.sni.clone(),
alpn: initial.alpn.first().cloned(),
version,
ja4,
pq_key_share,
app_protocol: flowscope::app_proto::AppProtocol::from_quic_initial(initial, port),
key,
}
}
}
#[cfg(feature = "ssh")]
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct SshFingerprint {
pub banners: Vec<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub hassh: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub hassh_server: Option<String>,
pub kex_algorithms: Vec<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub key: Option<FlowKey>,
}
#[cfg(feature = "ssh")]
#[derive(Debug, Default)]
pub(crate) struct SshFpState {
pub(crate) banners: Vec<String>,
pub(crate) hassh: Option<String>,
pub(crate) hassh_server: Option<String>,
pub(crate) kex_algorithms: Vec<String>,
pub(crate) fired: bool,
}
#[cfg(feature = "ssh")]
impl SshFpState {
pub(crate) fn observe(
&mut self,
msg: &flowscope::ssh::SshMessage,
key: Option<FlowKey>,
) -> Option<SshFingerprint> {
match msg {
flowscope::ssh::SshMessage::Banner { banner } => {
self.banners.push(banner.clone());
}
flowscope::ssh::SshMessage::KexInit(k) => {
if k.from_client {
self.hassh.get_or_insert_with(|| k.hassh.clone());
if self.kex_algorithms.is_empty() {
self.kex_algorithms = k.kex_algorithms.clone();
}
} else {
self.hassh_server.get_or_insert_with(|| k.hassh.clone());
}
}
_ => {}
}
if !self.fired && self.hassh.is_some() && self.hassh_server.is_some() {
self.fired = true;
Some(SshFingerprint {
banners: self.banners.clone(),
hassh: self.hassh.clone(),
hassh_server: self.hassh_server.clone(),
kex_algorithms: self.kex_algorithms.clone(),
key,
})
} else {
None
}
}
}
#[cfg(all(test, feature = "tls"))]
mod tests {
use super::*;
use flowscope::tls::TlsHandshake;
#[test]
fn bundles_handshake_fields_and_prefers_server_alpn() {
let mut hs = TlsHandshake::default();
hs.sni = Some("example.com".to_string());
hs.client_alpn = vec!["h2".to_string(), "http/1.1".to_string()];
hs.server_alpn = Some("h2".to_string());
hs.ja3 = Some("abc".to_string());
hs.ja4 = Some("t13d…".to_string());
hs.pq_key_share = true;
#[cfg(feature = "ja4plus")]
{
hs.ja4s = Some("t130200_1301_…".to_string());
hs.ja4x = Some("a564fbbd9b48_5e2c5a8f4f17_8c0e391b6d8b".to_string());
}
let fp = TlsFingerprint::from_handshake(&hs, None);
assert_eq!(fp.sni.as_deref(), Some("example.com"));
assert_eq!(fp.alpn.as_deref(), Some("h2")); assert!(fp.pq_key_share, "pq_key_share should carry through (#128)");
assert_eq!(
fp.app_protocol,
flowscope::app_proto::AppProtocol::Http2,
"h2 ALPN should classify as Http2"
);
#[cfg(feature = "ja4plus")]
{
assert_eq!(fp.ja4s.as_deref(), Some("t130200_1301_…"));
assert_eq!(
fp.ja4x.as_deref(),
Some("a564fbbd9b48_5e2c5a8f4f17_8c0e391b6d8b")
);
}
assert!(fp.has_fingerprint());
}
#[test]
fn falls_back_to_first_client_alpn_when_server_did_not_choose() {
let mut hs = TlsHandshake::default();
hs.client_alpn = vec!["h2".to_string()];
hs.server_alpn = None;
let fp = TlsFingerprint::from_handshake(&hs, None);
assert_eq!(fp.alpn.as_deref(), Some("h2"));
assert!(!fp.has_fingerprint());
}
#[test]
fn known_doh_resolver_matching_is_case_and_dot_insensitive() {
assert!(is_known_doh_resolver("cloudflare-dns.com"));
assert!(is_known_doh_resolver("Cloudflare-DNS.com."));
assert!(is_known_doh_resolver("dns.google"));
assert!(!is_known_doh_resolver("example.com"));
}
#[test]
fn doh_sni_classifies_as_dns_over_https() {
let mut hs = TlsHandshake::default();
hs.server_alpn = Some("h2".to_string());
hs.sni = Some("cloudflare-dns.com".to_string());
let fp = TlsFingerprint::from_handshake(&hs, None);
assert_eq!(
fp.app_protocol,
flowscope::app_proto::AppProtocol::DnsOverHttps
);
assert!(fp.app_protocol.is_encrypted_dns());
}
#[cfg(feature = "tokio")]
#[test]
fn on_fingerprint_builds_a_valid_monitor_and_doesnt_double_register() {
let m = crate::monitor::Monitor::builder()
.interface("lo")
.on_fingerprint(|_fp, _ctx| Ok(()))
.build();
assert!(m.is_ok(), "auto-register build failed: {:?}", m.err());
let m = crate::monitor::Monitor::builder()
.interface("lo")
.protocol::<crate::protocol::builtin::TlsHandshake>()
.on_fingerprint(|_fp, _ctx| Ok(()))
.build();
assert!(m.is_ok(), "explicit-protocol build failed: {:?}", m.err());
}
#[cfg(all(feature = "http", feature = "ja4plus", feature = "tokio"))]
#[test]
fn on_http_fingerprint_builds_and_doesnt_double_register() {
let m = crate::monitor::Monitor::builder()
.interface("lo")
.on_http_fingerprint(|_fp, _ctx| Ok(()))
.build();
assert!(m.is_ok(), "auto-register build failed: {:?}", m.err());
let m = crate::monitor::Monitor::builder()
.interface("lo")
.protocol::<crate::protocol::builtin::Http>()
.on_http_fingerprint(|_fp, _ctx| Ok(()))
.build();
assert!(m.is_ok(), "explicit-protocol build failed: {:?}", m.err());
}
}