Files
DECNET/decnet/templates/sniffer/server.py
anti 6708f26e6b fix(packaging): move templates/ into decnet/ package so they ship with pip install
The docker build contexts and syslog_bridge.py lived at repo root, which
meant setuptools (include = ["decnet*"]) never shipped them. Agents
installed via `pip install $RELEASE_DIR` got site-packages/decnet/** but no
templates/, so every deploy blew up in deployer._sync_logging_helper with
FileNotFoundError on templates/syslog_bridge.py.

Move templates/ -> decnet/templates/ and declare it as setuptools
package-data. Path resolutions in services/*.py and engine/deployer.py drop
one .parent since templates now lives beside the code. Test fixtures,
bandit exclude path, and coverage omit glob updated to match.
2026-04-19 19:30:04 -04:00

1051 lines
38 KiB
Python

#!/usr/bin/env python3
"""
syslog-relay passive TLS sniffer.
Captures TLS handshakes on the MACVLAN interface (shared network namespace
with the decky base container). Extracts fingerprints and connection
metadata, then emits structured RFC 5424 log lines to stdout for the
host-side collector to ingest.
Requires: NET_RAW + NET_ADMIN capabilities (set in compose fragment).
Supported fingerprints:
JA3 — MD5(SSLVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats)
JA3S — MD5(SSLVersion,Cipher,Extensions)
JA4 — {proto}{ver}{sni}{#cs}{#ext}{alpn}_{sha256_12(sorted_cs)}_{sha256_12(sorted_ext,sigalgs)}
JA4S — {proto}{ver}{#ext}{alpn}_{sha256_12(cipher,sorted_ext)}
JA4L — TCP RTT latency measurement (client_ttl, server_rtt_ms)
TLS session resumption detection (session tickets, PSK, 0-RTT)
Certificate extraction (TLS ≤1.2 only — 1.3 encrypts certs)
GREASE values (RFC 8701) are excluded from all lists before hashing.
"""
from __future__ import annotations
import hashlib
import os
import struct
import time
from typing import Any
from scapy.layers.inet import IP, TCP
from scapy.sendrecv import sniff
from syslog_bridge import SEVERITY_INFO, SEVERITY_WARNING, syslog_line, write_syslog_file
# ─── Configuration ────────────────────────────────────────────────────────────
NODE_NAME: str = os.environ.get("NODE_NAME", "decky-sniffer")
SERVICE_NAME: str = "sniffer"
# Session TTL in seconds — drop half-open sessions after this
_SESSION_TTL: float = 60.0
# Dedup TTL — suppress identical fingerprint events from the same source IP
# within this window (seconds). Set to 0 to disable dedup.
_DEDUP_TTL: float = float(os.environ.get("DEDUP_TTL", "300"))
# GREASE values per RFC 8701 — 0x0A0A, 0x1A1A, 0x2A2A, ..., 0xFAFA
_GREASE: frozenset[int] = frozenset(0x0A0A + i * 0x1010 for i in range(16))
# TLS record / handshake type constants
_TLS_RECORD_HANDSHAKE: int = 0x16
_TLS_HT_CLIENT_HELLO: int = 0x01
_TLS_HT_SERVER_HELLO: int = 0x02
_TLS_HT_CERTIFICATE: int = 0x0B
# TLS extension types we extract for metadata
_EXT_SNI: int = 0x0000
_EXT_SUPPORTED_GROUPS: int = 0x000A
_EXT_EC_POINT_FORMATS: int = 0x000B
_EXT_SIGNATURE_ALGORITHMS: int = 0x000D
_EXT_ALPN: int = 0x0010
_EXT_SESSION_TICKET: int = 0x0023
_EXT_SUPPORTED_VERSIONS: int = 0x002B
_EXT_PRE_SHARED_KEY: int = 0x0029
_EXT_EARLY_DATA: int = 0x002A
# TCP flags
_TCP_SYN: int = 0x02
_TCP_ACK: int = 0x10
# ─── Session tracking ─────────────────────────────────────────────────────────
# Key: (src_ip, src_port, dst_ip, dst_port) — forward 4-tuple from ClientHello
# Value: parsed ClientHello metadata dict
_sessions: dict[tuple[str, int, str, int], dict[str, Any]] = {}
_session_ts: dict[tuple[str, int, str, int], float] = {}
# TCP RTT tracking for JA4L: key = (client_ip, client_port, server_ip, server_port)
# Value: {"syn_time": float, "ttl": int}
_tcp_syn: dict[tuple[str, int, str, int], dict[str, Any]] = {}
# Completed RTT measurements: key = same 4-tuple, value = {"rtt_ms": float, "client_ttl": int}
_tcp_rtt: dict[tuple[str, int, str, int], dict[str, Any]] = {}
# ─── GREASE helpers ───────────────────────────────────────────────────────────
def _is_grease(value: int) -> bool:
return value in _GREASE
def _filter_grease(values: list[int]) -> list[int]:
return [v for v in values if not _is_grease(v)]
# ─── Pure-Python TLS record parser ────────────────────────────────────────────
def _parse_client_hello(data: bytes) -> dict[str, Any] | None:
"""
Parse a TLS ClientHello from raw bytes (starting at TLS record header).
Returns a dict of parsed fields, or None if not a valid ClientHello.
"""
try:
if len(data) < 6:
return None
# TLS record header: content_type(1) version(2) length(2)
if data[0] != _TLS_RECORD_HANDSHAKE:
return None
record_len = struct.unpack_from("!H", data, 3)[0]
if len(data) < 5 + record_len:
return None
# Handshake header: type(1) length(3)
hs = data[5:]
if hs[0] != _TLS_HT_CLIENT_HELLO:
return None
hs_len = struct.unpack_from("!I", b"\x00" + hs[1:4])[0]
body = hs[4: 4 + hs_len]
if len(body) < 34:
return None
pos = 0
# ClientHello version (2 bytes) — used for JA3
tls_version = struct.unpack_from("!H", body, pos)[0]
pos += 2
# Random (32 bytes)
pos += 32
# Session ID
session_id_len = body[pos]
session_id = body[pos + 1: pos + 1 + session_id_len]
pos += 1 + session_id_len
# Cipher Suites
cs_len = struct.unpack_from("!H", body, pos)[0]
pos += 2
cipher_suites = [
struct.unpack_from("!H", body, pos + i * 2)[0]
for i in range(cs_len // 2)
]
pos += cs_len
# Compression Methods
comp_len = body[pos]
pos += 1 + comp_len
# Extensions
extensions: list[int] = []
supported_groups: list[int] = []
ec_point_formats: list[int] = []
signature_algorithms: list[int] = []
supported_versions: list[int] = []
sni: str = ""
alpn: list[str] = []
has_session_ticket_data: bool = False
has_pre_shared_key: bool = False
has_early_data: bool = False
if pos + 2 <= len(body):
ext_total = struct.unpack_from("!H", body, pos)[0]
pos += 2
ext_end = pos + ext_total
while pos + 4 <= ext_end:
ext_type = struct.unpack_from("!H", body, pos)[0]
ext_len = struct.unpack_from("!H", body, pos + 2)[0]
ext_data = body[pos + 4: pos + 4 + ext_len]
pos += 4 + ext_len
if not _is_grease(ext_type):
extensions.append(ext_type)
if ext_type == _EXT_SNI and len(ext_data) > 5:
# server_name_list_length(2) type(1) name_length(2) name
sni = ext_data[5:].decode("ascii", errors="replace")
elif ext_type == _EXT_SUPPORTED_GROUPS and len(ext_data) >= 2:
grp_len = struct.unpack_from("!H", ext_data, 0)[0]
supported_groups = [
struct.unpack_from("!H", ext_data, 2 + i * 2)[0]
for i in range(grp_len // 2)
]
elif ext_type == _EXT_EC_POINT_FORMATS and len(ext_data) >= 1:
pf_len = ext_data[0]
ec_point_formats = list(ext_data[1: 1 + pf_len])
elif ext_type == _EXT_ALPN and len(ext_data) >= 2:
proto_list_len = struct.unpack_from("!H", ext_data, 0)[0]
ap = 2
while ap < 2 + proto_list_len:
plen = ext_data[ap]
alpn.append(ext_data[ap + 1: ap + 1 + plen].decode("ascii", errors="replace"))
ap += 1 + plen
elif ext_type == _EXT_SIGNATURE_ALGORITHMS and len(ext_data) >= 2:
sa_len = struct.unpack_from("!H", ext_data, 0)[0]
signature_algorithms = [
struct.unpack_from("!H", ext_data, 2 + i * 2)[0]
for i in range(sa_len // 2)
]
elif ext_type == _EXT_SUPPORTED_VERSIONS and len(ext_data) >= 1:
sv_len = ext_data[0]
supported_versions = [
struct.unpack_from("!H", ext_data, 1 + i * 2)[0]
for i in range(sv_len // 2)
]
elif ext_type == _EXT_SESSION_TICKET:
has_session_ticket_data = len(ext_data) > 0
elif ext_type == _EXT_PRE_SHARED_KEY:
has_pre_shared_key = True
elif ext_type == _EXT_EARLY_DATA:
has_early_data = True
filtered_ciphers = _filter_grease(cipher_suites)
filtered_groups = _filter_grease(supported_groups)
filtered_sig_algs = _filter_grease(signature_algorithms)
filtered_versions = _filter_grease(supported_versions)
return {
"tls_version": tls_version,
"cipher_suites": filtered_ciphers,
"extensions": extensions,
"supported_groups": filtered_groups,
"ec_point_formats": ec_point_formats,
"signature_algorithms": filtered_sig_algs,
"supported_versions": filtered_versions,
"sni": sni,
"alpn": alpn,
"session_id": session_id,
"has_session_ticket_data": has_session_ticket_data,
"has_pre_shared_key": has_pre_shared_key,
"has_early_data": has_early_data,
}
except Exception:
return None
def _parse_server_hello(data: bytes) -> dict[str, Any] | None:
"""
Parse a TLS ServerHello from raw bytes.
Returns dict with tls_version, cipher_suite, extensions, or None.
"""
try:
if len(data) < 6 or data[0] != _TLS_RECORD_HANDSHAKE:
return None
hs = data[5:]
if hs[0] != _TLS_HT_SERVER_HELLO:
return None
hs_len = struct.unpack_from("!I", b"\x00" + hs[1:4])[0]
body = hs[4: 4 + hs_len]
if len(body) < 35:
return None
pos = 0
tls_version = struct.unpack_from("!H", body, pos)[0]
pos += 2
# Random (32 bytes)
pos += 32
# Session ID
session_id_len = body[pos]
pos += 1 + session_id_len
if pos + 2 > len(body):
return None
cipher_suite = struct.unpack_from("!H", body, pos)[0]
pos += 2
# Compression method (1 byte)
pos += 1
extensions: list[int] = []
selected_version: int | None = None
alpn: str = ""
if pos + 2 <= len(body):
ext_total = struct.unpack_from("!H", body, pos)[0]
pos += 2
ext_end = pos + ext_total
while pos + 4 <= ext_end:
ext_type = struct.unpack_from("!H", body, pos)[0]
ext_len = struct.unpack_from("!H", body, pos + 2)[0]
ext_data = body[pos + 4: pos + 4 + ext_len]
pos += 4 + ext_len
if not _is_grease(ext_type):
extensions.append(ext_type)
if ext_type == _EXT_SUPPORTED_VERSIONS and len(ext_data) >= 2:
selected_version = struct.unpack_from("!H", ext_data, 0)[0]
elif ext_type == _EXT_ALPN and len(ext_data) >= 2:
proto_list_len = struct.unpack_from("!H", ext_data, 0)[0]
if proto_list_len > 0 and len(ext_data) >= 4:
plen = ext_data[2]
alpn = ext_data[3: 3 + plen].decode("ascii", errors="replace")
return {
"tls_version": tls_version,
"cipher_suite": cipher_suite,
"extensions": extensions,
"selected_version": selected_version,
"alpn": alpn,
}
except Exception:
return None
def _parse_certificate(data: bytes) -> dict[str, Any] | None:
"""
Parse a TLS Certificate handshake message from raw bytes.
Only works for TLS 1.2 and below — TLS 1.3 encrypts the Certificate
message. Extracts basic details from the first (leaf) certificate
using minimal DER/ASN.1 parsing.
"""
try:
if len(data) < 6 or data[0] != _TLS_RECORD_HANDSHAKE:
return None
hs = data[5:]
if hs[0] != _TLS_HT_CERTIFICATE:
return None
hs_len = struct.unpack_from("!I", b"\x00" + hs[1:4])[0]
body = hs[4: 4 + hs_len]
if len(body) < 3:
return None
# Certificate list total length (3 bytes)
certs_len = struct.unpack_from("!I", b"\x00" + body[0:3])[0]
if certs_len == 0:
return None
pos = 3
# First certificate length (3 bytes)
if pos + 3 > len(body):
return None
cert_len = struct.unpack_from("!I", b"\x00" + body[pos:pos + 3])[0]
pos += 3
if pos + cert_len > len(body):
return None
cert_der = body[pos: pos + cert_len]
return _parse_x509_der(cert_der)
except Exception:
return None
# ─── Minimal DER/ASN.1 X.509 parser ─────────────────────────────────────────
def _der_read_tag_len(data: bytes, pos: int) -> tuple[int, int, int]:
"""Read a DER tag and length. Returns (tag, content_start, content_length)."""
tag = data[pos]
pos += 1
length_byte = data[pos]
pos += 1
if length_byte & 0x80:
num_bytes = length_byte & 0x7F
length = int.from_bytes(data[pos: pos + num_bytes], "big")
pos += num_bytes
else:
length = length_byte
return tag, pos, length
def _der_read_sequence(data: bytes, pos: int) -> tuple[int, int]:
"""Read a SEQUENCE tag, return (content_start, content_length)."""
tag, content_start, length = _der_read_tag_len(data, pos)
return content_start, length
def _der_read_oid(data: bytes, pos: int, length: int) -> str:
"""Decode a DER OID to dotted string."""
if length < 1:
return ""
first = data[pos]
oid_parts = [str(first // 40), str(first % 40)]
val = 0
for i in range(1, length):
b = data[pos + i]
val = (val << 7) | (b & 0x7F)
if not (b & 0x80):
oid_parts.append(str(val))
val = 0
return ".".join(oid_parts)
def _der_extract_cn(data: bytes, start: int, length: int) -> str:
"""Walk an X.501 Name (SEQUENCE of SETs of SEQUENCE of OID+value) to find CN."""
pos = start
end = start + length
while pos < end:
# Each RDN is a SET
set_tag, set_start, set_len = _der_read_tag_len(data, pos)
if set_tag != 0x31: # SET
break
set_end = set_start + set_len
# Inside the SET, each attribute is a SEQUENCE
attr_pos = set_start
while attr_pos < set_end:
seq_tag, seq_start, seq_len = _der_read_tag_len(data, attr_pos)
if seq_tag != 0x30: # SEQUENCE
break
# OID
oid_tag, oid_start, oid_len = _der_read_tag_len(data, seq_start)
if oid_tag == 0x06:
oid = _der_read_oid(data, oid_start, oid_len)
# CN OID = 2.5.4.3
if oid == "2.5.4.3":
val_tag, val_start, val_len = _der_read_tag_len(data, oid_start + oid_len)
return data[val_start: val_start + val_len].decode("utf-8", errors="replace")
attr_pos = seq_start + seq_len
pos = set_end
return ""
def _der_extract_name_str(data: bytes, start: int, length: int) -> str:
"""Extract a human-readable summary of an X.501 Name (all RDN values joined)."""
parts: list[str] = []
pos = start
end = start + length
oid_names = {
"2.5.4.3": "CN",
"2.5.4.6": "C",
"2.5.4.7": "L",
"2.5.4.8": "ST",
"2.5.4.10": "O",
"2.5.4.11": "OU",
}
while pos < end:
set_tag, set_start, set_len = _der_read_tag_len(data, pos)
if set_tag != 0x31:
break
set_end = set_start + set_len
attr_pos = set_start
while attr_pos < set_end:
seq_tag, seq_start, seq_len = _der_read_tag_len(data, attr_pos)
if seq_tag != 0x30:
break
oid_tag, oid_start, oid_len = _der_read_tag_len(data, seq_start)
if oid_tag == 0x06:
oid = _der_read_oid(data, oid_start, oid_len)
val_tag, val_start, val_len = _der_read_tag_len(data, oid_start + oid_len)
val = data[val_start: val_start + val_len].decode("utf-8", errors="replace")
name = oid_names.get(oid, oid)
parts.append(f"{name}={val}")
attr_pos = seq_start + seq_len
pos = set_end
return ", ".join(parts)
def _parse_x509_der(cert_der: bytes) -> dict[str, Any] | None:
"""
Minimal X.509 DER parser. Extracts subject CN, issuer string,
validity period, and self-signed flag.
Structure: SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue }
tbsCertificate: SEQUENCE {
version [0] EXPLICIT, serialNumber, signature,
issuer, validity { notBefore, notAfter },
subject, subjectPublicKeyInfo, ...extensions
}
"""
try:
# Outer SEQUENCE
outer_start, outer_len = _der_read_sequence(cert_der, 0)
# tbsCertificate SEQUENCE
tbs_tag, tbs_start, tbs_len = _der_read_tag_len(cert_der, outer_start)
tbs_end = tbs_start + tbs_len
pos = tbs_start
# version [0] EXPLICIT — optional, skip if present
if cert_der[pos] == 0xA0:
_, v_start, v_len = _der_read_tag_len(cert_der, pos)
pos = v_start + v_len
# serialNumber (INTEGER)
_, sn_start, sn_len = _der_read_tag_len(cert_der, pos)
pos = sn_start + sn_len
# signature algorithm (SEQUENCE)
_, sa_start, sa_len = _der_read_tag_len(cert_der, pos)
pos = sa_start + sa_len
# issuer (SEQUENCE)
issuer_tag, issuer_start, issuer_len = _der_read_tag_len(cert_der, pos)
issuer_str = _der_extract_name_str(cert_der, issuer_start, issuer_len)
issuer_cn = _der_extract_cn(cert_der, issuer_start, issuer_len)
pos = issuer_start + issuer_len
# validity (SEQUENCE of two times)
val_tag, val_start, val_len = _der_read_tag_len(cert_der, pos)
# notBefore
nb_tag, nb_start, nb_len = _der_read_tag_len(cert_der, val_start)
not_before = cert_der[nb_start: nb_start + nb_len].decode("ascii", errors="replace")
# notAfter
na_tag, na_start, na_len = _der_read_tag_len(cert_der, nb_start + nb_len)
not_after = cert_der[na_start: na_start + na_len].decode("ascii", errors="replace")
pos = val_start + val_len
# subject (SEQUENCE)
subj_tag, subj_start, subj_len = _der_read_tag_len(cert_der, pos)
subject_cn = _der_extract_cn(cert_der, subj_start, subj_len)
subject_str = _der_extract_name_str(cert_der, subj_start, subj_len)
# Self-signed: issuer CN matches subject CN (basic check)
self_signed = (issuer_cn == subject_cn) and subject_cn != ""
# SANs are in extensions — attempt to find them
pos = subj_start + subj_len
sans: list[str] = _extract_sans(cert_der, pos, tbs_end)
return {
"subject_cn": subject_cn,
"subject": subject_str,
"issuer": issuer_str,
"issuer_cn": issuer_cn,
"not_before": not_before,
"not_after": not_after,
"self_signed": self_signed,
"sans": sans,
}
except Exception:
return None
def _extract_sans(cert_der: bytes, pos: int, end: int) -> list[str]:
"""
Attempt to extract Subject Alternative Names from X.509v3 extensions.
SAN OID = 2.5.29.17
"""
sans: list[str] = []
try:
# Skip subjectPublicKeyInfo SEQUENCE
if pos >= end:
return sans
spki_tag, spki_start, spki_len = _der_read_tag_len(cert_der, pos)
pos = spki_start + spki_len
# Extensions are wrapped in [3] EXPLICIT
while pos < end:
tag = cert_der[pos]
if tag == 0xA3: # [3] EXPLICIT — extensions wrapper
_, ext_wrap_start, ext_wrap_len = _der_read_tag_len(cert_der, pos)
# Inner SEQUENCE of extensions
_, exts_start, exts_len = _der_read_tag_len(cert_der, ext_wrap_start)
epos = exts_start
eend = exts_start + exts_len
while epos < eend:
# Each extension is a SEQUENCE { OID, [critical], value }
ext_tag, ext_start, ext_len = _der_read_tag_len(cert_der, epos)
ext_end = ext_start + ext_len
oid_tag, oid_start, oid_len = _der_read_tag_len(cert_der, ext_start)
if oid_tag == 0x06:
oid = _der_read_oid(cert_der, oid_start, oid_len)
if oid == "2.5.29.17": # SAN
# Find the OCTET STRING containing the SAN value
vpos = oid_start + oid_len
# Skip optional BOOLEAN (critical)
if vpos < ext_end and cert_der[vpos] == 0x01:
_, bs, bl = _der_read_tag_len(cert_der, vpos)
vpos = bs + bl
# OCTET STRING wrapping the SAN SEQUENCE
if vpos < ext_end:
os_tag, os_start, os_len = _der_read_tag_len(cert_der, vpos)
if os_tag == 0x04:
sans = _parse_san_sequence(cert_der, os_start, os_len)
epos = ext_end
break
else:
_, skip_start, skip_len = _der_read_tag_len(cert_der, pos)
pos = skip_start + skip_len
except Exception:
pass
return sans
def _parse_san_sequence(data: bytes, start: int, length: int) -> list[str]:
"""Parse a GeneralNames SEQUENCE to extract DNS names and IPs."""
names: list[str] = []
try:
# The SAN value is itself a SEQUENCE of GeneralName
seq_tag, seq_start, seq_len = _der_read_tag_len(data, start)
pos = seq_start
end = seq_start + seq_len
while pos < end:
tag = data[pos]
_, val_start, val_len = _der_read_tag_len(data, pos)
context_tag = tag & 0x1F
if context_tag == 2: # dNSName
names.append(data[val_start: val_start + val_len].decode("ascii", errors="replace"))
elif context_tag == 7 and val_len == 4: # iPAddress (IPv4)
names.append(".".join(str(b) for b in data[val_start: val_start + val_len]))
pos = val_start + val_len
except Exception:
pass
return names
# ─── JA3 / JA3S computation ───────────────────────────────────────────────────
def _tls_version_str(version: int) -> str:
return {
0x0301: "TLS 1.0",
0x0302: "TLS 1.1",
0x0303: "TLS 1.2",
0x0304: "TLS 1.3",
0x0200: "SSL 2.0",
0x0300: "SSL 3.0",
}.get(version, f"0x{version:04x}")
def _ja3(ch: dict[str, Any]) -> tuple[str, str]:
"""Return (ja3_string, ja3_hash) for a parsed ClientHello."""
parts = [
str(ch["tls_version"]),
"-".join(str(c) for c in ch["cipher_suites"]),
"-".join(str(e) for e in ch["extensions"]),
"-".join(str(g) for g in ch["supported_groups"]),
"-".join(str(p) for p in ch["ec_point_formats"]),
]
ja3_str = ",".join(parts)
return ja3_str, hashlib.md5(ja3_str.encode()).hexdigest()
def _ja3s(sh: dict[str, Any]) -> tuple[str, str]:
"""Return (ja3s_string, ja3s_hash) for a parsed ServerHello."""
parts = [
str(sh["tls_version"]),
str(sh["cipher_suite"]),
"-".join(str(e) for e in sh["extensions"]),
]
ja3s_str = ",".join(parts)
return ja3s_str, hashlib.md5(ja3s_str.encode()).hexdigest()
# ─── JA4 / JA4S computation ──────────────────────────────────────────────────
def _ja4_version(ch: dict[str, Any]) -> str:
"""
Determine JA4 TLS version string (2 chars).
Uses supported_versions extension if present (TLS 1.3 advertises 0x0303 in
ClientHello.version but 0x0304 in supported_versions).
"""
versions = ch.get("supported_versions", [])
if versions:
best = max(versions)
else:
best = ch["tls_version"]
return {
0x0304: "13",
0x0303: "12",
0x0302: "11",
0x0301: "10",
0x0300: "s3",
0x0200: "s2",
}.get(best, "00")
def _ja4_alpn_tag(alpn_list: list[str] | str) -> str:
"""
JA4 ALPN tag: first and last character of the first ALPN protocol.
No ALPN → "00".
"""
if isinstance(alpn_list, str):
proto = alpn_list
elif alpn_list:
proto = alpn_list[0]
else:
return "00"
if not proto:
return "00"
if len(proto) == 1:
return proto[0] + proto[0]
return proto[0] + proto[-1]
def _sha256_12(text: str) -> str:
"""First 12 hex chars of SHA-256."""
return hashlib.sha256(text.encode()).hexdigest()[:12]
def _ja4(ch: dict[str, Any]) -> str:
"""
Compute JA4 fingerprint from a parsed ClientHello.
Format: a_b_c where
a = {t|q}{version:2}{d|i}{cipher_count:02d}{ext_count:02d}{alpn_tag:2}
b = sha256_12(sorted_cipher_suites, comma-separated)
c = sha256_12(sorted_extensions,sorted_signature_algorithms)
Protocol is always 't' (TCP) since we capture on a TCP socket.
SNI present → 'd' (domain), absent → 'i' (IP).
"""
proto = "t"
ver = _ja4_version(ch)
sni_flag = "d" if ch.get("sni") else "i"
# Counts — GREASE already filtered, but also exclude SNI (0x0000) and ALPN (0x0010)
# from extension count per JA4 spec? No — JA4 counts all non-GREASE extensions.
cs_count = min(len(ch["cipher_suites"]), 99)
ext_count = min(len(ch["extensions"]), 99)
alpn_tag = _ja4_alpn_tag(ch.get("alpn", []))
section_a = f"{proto}{ver}{sni_flag}{cs_count:02d}{ext_count:02d}{alpn_tag}"
# Section b: sorted cipher suites as decimal, comma-separated
sorted_cs = sorted(ch["cipher_suites"])
section_b = _sha256_12(",".join(str(c) for c in sorted_cs))
# Section c: sorted extensions + sorted signature algorithms
sorted_ext = sorted(ch["extensions"])
sorted_sa = sorted(ch.get("signature_algorithms", []))
ext_str = ",".join(str(e) for e in sorted_ext)
sa_str = ",".join(str(s) for s in sorted_sa)
combined = f"{ext_str}_{sa_str}" if sa_str else ext_str
section_c = _sha256_12(combined)
return f"{section_a}_{section_b}_{section_c}"
def _ja4s(sh: dict[str, Any]) -> str:
"""
Compute JA4S fingerprint from a parsed ServerHello.
Format: a_b where
a = {t|q}{version:2}{ext_count:02d}{alpn_tag:2}
b = sha256_12({cipher_suite},{sorted_extensions comma-separated})
"""
proto = "t"
# Use selected_version from supported_versions ext if available
selected = sh.get("selected_version")
if selected:
ver = {0x0304: "13", 0x0303: "12", 0x0302: "11", 0x0301: "10",
0x0300: "s3", 0x0200: "s2"}.get(selected, "00")
else:
ver = {0x0304: "13", 0x0303: "12", 0x0302: "11", 0x0301: "10",
0x0300: "s3", 0x0200: "s2"}.get(sh["tls_version"], "00")
ext_count = min(len(sh["extensions"]), 99)
alpn_tag = _ja4_alpn_tag(sh.get("alpn", ""))
section_a = f"{proto}{ver}{ext_count:02d}{alpn_tag}"
sorted_ext = sorted(sh["extensions"])
inner = f"{sh['cipher_suite']},{','.join(str(e) for e in sorted_ext)}"
section_b = _sha256_12(inner)
return f"{section_a}_{section_b}"
# ─── JA4L (latency) ──────────────────────────────────────────────────────────
def _ja4l(key: tuple[str, int, str, int]) -> dict[str, Any] | None:
"""
Retrieve JA4L data for a connection.
JA4L measures the TCP handshake RTT: time from SYN to SYN-ACK.
Returns {"rtt_ms": float, "client_ttl": int} or None.
"""
return _tcp_rtt.get(key)
# ─── Session resumption ──────────────────────────────────────────────────────
def _session_resumption_info(ch: dict[str, Any]) -> dict[str, Any]:
"""
Analyze ClientHello for TLS session resumption behavior.
Returns a dict describing what resumption mechanisms the client uses.
"""
mechanisms: list[str] = []
if ch.get("has_session_ticket_data"):
mechanisms.append("session_ticket")
if ch.get("has_pre_shared_key"):
mechanisms.append("psk")
if ch.get("has_early_data"):
mechanisms.append("early_data_0rtt")
if ch.get("session_id") and len(ch["session_id"]) > 0:
mechanisms.append("session_id")
return {
"resumption_attempted": len(mechanisms) > 0,
"mechanisms": mechanisms,
}
# ─── Session cleanup ─────────────────────────────────────────────────────────
def _cleanup_sessions() -> None:
now = time.monotonic()
stale = [k for k, ts in _session_ts.items() if now - ts > _SESSION_TTL]
for k in stale:
_sessions.pop(k, None)
_session_ts.pop(k, None)
# Also clean up TCP RTT tracking
stale_syn = [k for k, v in _tcp_syn.items()
if now - v.get("time", 0) > _SESSION_TTL]
for k in stale_syn:
_tcp_syn.pop(k, None)
stale_rtt = [k for k, _ in _tcp_rtt.items()
if k not in _sessions and k not in _session_ts]
for k in stale_rtt:
_tcp_rtt.pop(k, None)
# ─── Dedup cache ─────────────────────────────────────────────────────────────
# Key: (src_ip, event_type, fingerprint_key) → timestamp of last emit
_dedup_cache: dict[tuple[str, str, str], float] = {}
_DEDUP_CLEANUP_INTERVAL: float = 60.0
_dedup_last_cleanup: float = 0.0
def _dedup_key_for(event_type: str, fields: dict[str, Any]) -> str:
"""Build a dedup fingerprint from the most significant fields."""
if event_type == "tls_client_hello":
return fields.get("ja3", "") + "|" + fields.get("ja4", "")
if event_type == "tls_session":
return (fields.get("ja3", "") + "|" + fields.get("ja3s", "") +
"|" + fields.get("ja4", "") + "|" + fields.get("ja4s", ""))
if event_type == "tls_certificate":
return fields.get("subject_cn", "") + "|" + fields.get("issuer", "")
# tls_resumption or unknown — dedup on mechanisms
return fields.get("mechanisms", fields.get("resumption", ""))
def _is_duplicate(event_type: str, fields: dict[str, Any]) -> bool:
"""Return True if this event was already emitted within the dedup window."""
if _DEDUP_TTL <= 0:
return False
global _dedup_last_cleanup
now = time.monotonic()
# Periodic cleanup
if now - _dedup_last_cleanup > _DEDUP_CLEANUP_INTERVAL:
stale = [k for k, ts in _dedup_cache.items() if now - ts > _DEDUP_TTL]
for k in stale:
del _dedup_cache[k]
_dedup_last_cleanup = now
src_ip = fields.get("src_ip", "")
fp = _dedup_key_for(event_type, fields)
cache_key = (src_ip, event_type, fp)
last_seen = _dedup_cache.get(cache_key)
if last_seen is not None and now - last_seen < _DEDUP_TTL:
return True
_dedup_cache[cache_key] = now
return False
# ─── Logging helpers ─────────────────────────────────────────────────────────
def _log(event_type: str, severity: int = SEVERITY_INFO, **fields: Any) -> None:
if _is_duplicate(event_type, fields):
return
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity=severity, **fields)
write_syslog_file(line)
# ─── Packet callback ─────────────────────────────────────────────────────────
def _on_packet(pkt: Any) -> None:
if not (pkt.haslayer(IP) and pkt.haslayer(TCP)):
return
ip = pkt[IP]
tcp = pkt[TCP]
src_ip: str = ip.src
dst_ip: str = ip.dst
src_port: int = tcp.sport
dst_port: int = tcp.dport
flags: int = tcp.flags.value if hasattr(tcp.flags, 'value') else int(tcp.flags)
# ── TCP SYN tracking for JA4L ──
if flags & _TCP_SYN and not (flags & _TCP_ACK):
# Pure SYN — record timestamp and TTL
key = (src_ip, src_port, dst_ip, dst_port)
_tcp_syn[key] = {"time": time.monotonic(), "ttl": ip.ttl}
elif flags & _TCP_SYN and flags & _TCP_ACK:
# SYN-ACK — calculate RTT for the original SYN sender
rev_key = (dst_ip, dst_port, src_ip, src_port)
syn_data = _tcp_syn.pop(rev_key, None)
if syn_data:
rtt_ms = round((time.monotonic() - syn_data["time"]) * 1000, 2)
_tcp_rtt[rev_key] = {
"rtt_ms": rtt_ms,
"client_ttl": syn_data["ttl"],
}
payload = bytes(tcp.payload)
if not payload:
return
# TLS record check
if payload[0] != _TLS_RECORD_HANDSHAKE:
return
# Attempt ClientHello parse
ch = _parse_client_hello(payload)
if ch is not None:
_cleanup_sessions()
key = (src_ip, src_port, dst_ip, dst_port)
ja3_str, ja3_hash = _ja3(ch)
ja4_hash = _ja4(ch)
resumption = _session_resumption_info(ch)
rtt_data = _ja4l(key)
_sessions[key] = {
"ja3": ja3_hash,
"ja3_str": ja3_str,
"ja4": ja4_hash,
"tls_version": ch["tls_version"],
"cipher_suites": ch["cipher_suites"],
"extensions": ch["extensions"],
"signature_algorithms": ch.get("signature_algorithms", []),
"supported_versions": ch.get("supported_versions", []),
"sni": ch["sni"],
"alpn": ch["alpn"],
"resumption": resumption,
}
_session_ts[key] = time.monotonic()
log_fields: dict[str, Any] = {
"src_ip": src_ip,
"src_port": str(src_port),
"dst_ip": dst_ip,
"dst_port": str(dst_port),
"ja3": ja3_hash,
"ja4": ja4_hash,
"tls_version": _tls_version_str(ch["tls_version"]),
"sni": ch["sni"] or "",
"alpn": ",".join(ch["alpn"]),
"raw_ciphers": "-".join(str(c) for c in ch["cipher_suites"]),
"raw_extensions": "-".join(str(e) for e in ch["extensions"]),
}
if resumption["resumption_attempted"]:
log_fields["resumption"] = ",".join(resumption["mechanisms"])
if rtt_data:
log_fields["ja4l_rtt_ms"] = str(rtt_data["rtt_ms"])
log_fields["ja4l_client_ttl"] = str(rtt_data["client_ttl"])
_log("tls_client_hello", **log_fields)
return
# Attempt ServerHello parse
sh = _parse_server_hello(payload)
if sh is not None:
# Reverse 4-tuple to find the matching ClientHello
rev_key = (dst_ip, dst_port, src_ip, src_port)
ch_data = _sessions.pop(rev_key, None)
_session_ts.pop(rev_key, None)
ja3s_str, ja3s_hash = _ja3s(sh)
ja4s_hash = _ja4s(sh)
fields: dict[str, Any] = {
"src_ip": dst_ip, # original attacker is now the destination
"src_port": str(dst_port),
"dst_ip": src_ip,
"dst_port": str(src_port),
"ja3s": ja3s_hash,
"ja4s": ja4s_hash,
"tls_version": _tls_version_str(sh["tls_version"]),
}
if ch_data:
fields["ja3"] = ch_data["ja3"]
fields["ja4"] = ch_data.get("ja4", "")
fields["sni"] = ch_data["sni"] or ""
fields["alpn"] = ",".join(ch_data["alpn"])
fields["raw_ciphers"] = "-".join(str(c) for c in ch_data["cipher_suites"])
fields["raw_extensions"] = "-".join(str(e) for e in ch_data["extensions"])
if ch_data.get("resumption", {}).get("resumption_attempted"):
fields["resumption"] = ",".join(ch_data["resumption"]["mechanisms"])
rtt_data = _tcp_rtt.pop(rev_key, None)
if rtt_data:
fields["ja4l_rtt_ms"] = str(rtt_data["rtt_ms"])
fields["ja4l_client_ttl"] = str(rtt_data["client_ttl"])
_log("tls_session", severity=SEVERITY_WARNING, **fields)
return
# Attempt Certificate parse (TLS 1.2 only — 1.3 encrypts it)
cert = _parse_certificate(payload)
if cert is not None:
# Match to a session — the cert comes from the server side
rev_key = (dst_ip, dst_port, src_ip, src_port)
ch_data = _sessions.get(rev_key)
cert_fields: dict[str, Any] = {
"src_ip": dst_ip,
"src_port": str(dst_port),
"dst_ip": src_ip,
"dst_port": str(src_port),
"subject_cn": cert["subject_cn"],
"issuer": cert["issuer"],
"self_signed": str(cert["self_signed"]).lower(),
"not_before": cert["not_before"],
"not_after": cert["not_after"],
}
if cert["sans"]:
cert_fields["sans"] = ",".join(cert["sans"])
if ch_data:
cert_fields["sni"] = ch_data.get("sni", "")
_log("tls_certificate", **cert_fields)
# ─── Entry point ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
_log("startup", msg=f"sniffer started node={NODE_NAME}")
sniff(
filter="tcp",
prn=_on_packet,
store=False,
)