feat(pr2): HTTP/2+HTTP/3 fingerprint extractors — JA4H, H2 SETTINGS, JA4-QUIC

This commit is contained in:
2026-05-10 00:47:19 -04:00
parent 0653e500b5
commit 92632d7afd
25 changed files with 1885 additions and 48 deletions

153
tests/sniffer/test_ja4h.py Normal file
View File

@@ -0,0 +1,153 @@
"""Tests for _ja4h computation and QUIC helpers in decnet.sniffer.fingerprint."""
from __future__ import annotations
import pytest
from decnet.sniffer.fingerprint import _ja4h, _quic_varint, _extract_crypto_frames
class TestJA4H:
def test_basic_get_h11(self):
result = _ja4h(
method="GET",
version="HTTP/1.1",
headers_ordered=["Host", "User-Agent", "Accept"],
)
parts = result.split("_")
assert len(parts) == 4
assert parts[0].startswith("GE11") # method + version
assert parts[0][4] == "n" # no cookie
assert parts[0][5] == "n" # no referer
assert parts[0][6:10] == "0000" # no Accept-Language
def test_cookie_flag(self):
result = _ja4h(
method="POST",
version="HTTP/1.1",
headers_ordered=["Host", "Cookie", "Content-Type"],
cookie_val="session=abc",
)
parts = result.split("_")
assert parts[0][4] == "c" # has cookie
assert parts[0][5] == "n" # no referer
def test_referer_flag(self):
result = _ja4h(
method="GET",
version="HTTP/1.1",
headers_ordered=["Host", "Referer"],
)
parts = result.split("_")
assert parts[0][5] == "r" # has referer
def test_h2_version_tag(self):
result = _ja4h(
method="GET",
version="HTTP/2.0",
headers_ordered=["Host", "User-Agent"],
)
assert result.startswith("GE20")
def test_h3_version_tag(self):
result = _ja4h(
method="GET",
version="HTTP/3.0",
headers_ordered=["Host", "User-Agent"],
)
assert result.startswith("GE30")
def test_cookie_and_referer_excluded_from_header_hash(self):
result_with = _ja4h(
method="GET",
version="HTTP/1.1",
headers_ordered=["Host", "User-Agent", "Cookie", "Referer"],
cookie_val="x=1",
)
result_without = _ja4h(
method="GET",
version="HTTP/1.1",
headers_ordered=["Host", "User-Agent"],
)
# Header hash (parts[2]) must be identical — cookie/referer excluded from it
assert result_with.split("_")[2] == result_without.split("_")[2]
def test_header_count_excludes_cookie_and_referer(self):
result = _ja4h(
method="GET",
version="HTTP/1.1",
headers_ordered=["Host", "Cookie", "Accept", "Referer"],
)
parts = result.split("_")
# 2 headers after dropping Cookie and Referer (Host + Accept)
assert parts[1] == "02"
def test_cookie_hash_alphabetical_sort(self):
r1 = _ja4h("GET", "HTTP/1.1", [], cookie_val="z=3; a=1; m=2")
r2 = _ja4h("GET", "HTTP/1.1", [], cookie_val="a=1; m=2; z=3")
# Both should produce the same cookie hash regardless of original order
assert r1.split("_")[3] == r2.split("_")[3]
def test_no_cookie_produces_12_zeros(self):
result = _ja4h("GET", "HTTP/1.1", ["Host"])
assert result.split("_")[3] == "000000000000"
def test_accept_lang_truncated_to_4_chars(self):
result = _ja4h("GET", "HTTP/1.1", [], accept_lang="en-US,en;q=0.9")
parts = result.split("_")
lang_tag = parts[0][6:10]
assert lang_tag == "en-U"
def test_deterministic(self):
kwargs = dict(
method="POST",
version="HTTP/1.1",
headers_ordered=["Host", "Content-Type", "Accept"],
)
assert _ja4h(**kwargs) == _ja4h(**kwargs)
class TestQuicVarint:
def test_1_byte(self):
assert _quic_varint(b"\x3f", 0) == (63, 1)
def test_2_byte(self):
# 0x4000 → big 2-byte form: 01 + 14 bits = 0x4000 = 16384
data = bytes([0x40, 0x00])
assert _quic_varint(data, 0) == (0, 2)
def test_4_byte(self):
# 0x80000000 → 2 MSB = 10, value = 0
data = bytes([0x80, 0x00, 0x00, 0x00])
assert _quic_varint(data, 0) == (0, 4)
def test_small_values(self):
assert _quic_varint(b"\x00", 0) == (0, 1)
assert _quic_varint(b"\x01", 0) == (1, 1)
assert _quic_varint(b"\x25", 0) == (37, 1)
class TestExtractCryptoFrames:
def test_single_crypto_frame(self):
# CRYPTO frame: type=0x06, offset=0x00 (varint), length=5 (varint), data
data_bytes = b"hello"
frame = bytes([0x06, 0x00, 0x05]) + data_bytes
result = _extract_crypto_frames(frame)
assert result == b"hello"
def test_empty_payload(self):
result = _extract_crypto_frames(b"")
assert result == b""
def test_padding_skipped(self):
# PADDING (0x00) + CRYPTO frame
data_bytes = b"world"
frame = bytes([0x00, 0x00, 0x06, 0x00, 0x05]) + data_bytes
result = _extract_crypto_frames(frame)
assert result == b"world"
def test_non_crypto_frame_stops_parsing(self):
# Unknown frame type (0x10) after CRYPTO — should stop and return what we have
data = b"hello"
frame = bytes([0x06, 0x00, 0x05]) + data + bytes([0x10, 0x00])
result = _extract_crypto_frames(frame)
assert result == b"hello"

View File

@@ -0,0 +1,123 @@
"""Tests for QUIC v1 Initial packet key derivation (RFC 9001 Appendix A vectors)."""
from __future__ import annotations
import pytest
from decnet.sniffer.fingerprint import (
_hkdf_extract,
_hkdf_expand_label,
_quic_initial_keys,
_QUIC_V1_INITIAL_SALT,
_ja4_quic,
_parse_quic_initial,
)
# RFC 9001 Appendix A.1 key derivation test vectors
_RFC9001_DCID = bytes.fromhex("8394c8f03e515708")
_RFC9001_CLIENT_KEY = bytes.fromhex("1f369613dd76d5467730efcbe3b1a22d")
_RFC9001_CLIENT_IV = bytes.fromhex("fa044b2f42a3fd3b46fb255c")
_RFC9001_CLIENT_HP = bytes.fromhex("9f50449e04a0e810283a1e9933adedd2")
class TestHKDF:
def test_extract_sha256(self):
# HKDF-Extract is HMAC-SHA256(salt, IKM). Cross-check with a known value.
result = _hkdf_extract(b"salt", b"ikm")
import hmac, hashlib
expected = hmac.new(b"salt", b"ikm", hashlib.sha256).digest()
assert result == expected
def test_expand_label_length(self):
secret = _hkdf_extract(_QUIC_V1_INITIAL_SALT, _RFC9001_DCID)
# "client in" expand should be 32 bytes
client_secret = _hkdf_expand_label(secret, "client in", b"", 32)
assert len(client_secret) == 32
def test_expand_label_key_length(self):
secret = _hkdf_extract(_QUIC_V1_INITIAL_SALT, _RFC9001_DCID)
client_secret = _hkdf_expand_label(secret, "client in", b"", 32)
key = _hkdf_expand_label(client_secret, "quic key", b"", 16)
assert len(key) == 16
def test_expand_label_iv_length(self):
secret = _hkdf_extract(_QUIC_V1_INITIAL_SALT, _RFC9001_DCID)
client_secret = _hkdf_expand_label(secret, "client in", b"", 32)
iv = _hkdf_expand_label(client_secret, "quic iv", b"", 12)
assert len(iv) == 12
class TestQuicInitialKeys:
def test_rfc9001_appendix_a_vectors(self):
"""Key derivation must match RFC 9001 Appendix A.1 test vectors exactly."""
key, iv, hp = _quic_initial_keys(_RFC9001_DCID)
assert key == _RFC9001_CLIENT_KEY, f"key mismatch: {key.hex()}"
assert iv == _RFC9001_CLIENT_IV, f"iv mismatch: {iv.hex()}"
assert hp == _RFC9001_CLIENT_HP, f"hp mismatch: {hp.hex()}"
class TestJA4Quic:
def test_proto_prefix_is_q(self):
ch = {
"cipher_suites": [0x1301, 0x1302],
"extensions": [0x000a, 0x000d, 0x002b],
"signature_algorithms": [0x0403, 0x0804],
"supported_versions": [0x0304],
"sni": "example.com",
"alpn": ["h3"],
"tls_version": 0x0303,
}
result = _ja4_quic(ch)
assert result.startswith("q"), f"expected 'q' prefix: {result}"
def test_structure(self):
ch = {
"cipher_suites": [0x1301],
"extensions": [0x000a],
"signature_algorithms": [],
"supported_versions": [0x0304],
"sni": "",
"alpn": [],
"tls_version": 0x0303,
}
result = _ja4_quic(ch)
parts = result.split("_")
assert len(parts) == 3
def test_deterministic(self):
ch = {
"cipher_suites": [0x1301, 0x1302, 0x1303],
"extensions": [0x000a, 0x000d],
"signature_algorithms": [0x0403],
"supported_versions": [0x0304],
"sni": "host.example",
"alpn": ["h3"],
"tls_version": 0x0303,
}
assert _ja4_quic(ch) == _ja4_quic(ch)
class TestParseQuicInitial:
def test_short_header_rejected(self):
# Short header: bit 7 clear
assert _parse_quic_initial(b"\x40" + b"\x00" * 20) is None
def test_wrong_version_rejected(self):
# Long header, Initial type, version = 0x00000002
pkt = bytearray(30)
pkt[0] = 0xC0 # long header + Initial
pkt[1:5] = b"\x00\x00\x00\x02" # version 2
assert _parse_quic_initial(bytes(pkt)) is None
def test_non_initial_type_rejected(self):
# Long header, Handshake type (0x20 set)
pkt = bytearray(30)
pkt[0] = 0xE0 # long header + Handshake
pkt[1:5] = b"\x00\x00\x00\x01"
assert _parse_quic_initial(bytes(pkt)) is None
def test_garbage_returns_none(self):
assert _parse_quic_initial(b"garbage bytes that are not QUIC") is None
def test_too_short_returns_none(self):
assert _parse_quic_initial(b"\xc0\x00") is None