feat: add ja4ebpf service — eBPF-based TLS/TCP fingerprinting daemon
- TC ingress hook captures TCP SYN (L3/L4) and TLS ClientHello - Uprobes on SSL_read/SSL_set_fd capture decrypted TLS data - Kprobes on accept4 correlate socket FDs to client IP:port - JA4 fingerprint computed from parsed TLS ClientHello - HTTP/2 SETTINGS and WINDOW_UPDATE extracted from decrypted streams - Session manager with sharded map (256 shards) and GC goroutine - Slowloris detection: sessions with no requests after 10s threshold - ClickHouse batch writer to ja4_logs.http_logs_raw (raw_json) - All tests pass: 17 parser + 10 correlation tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
265
services/ja4ebpf/internal/parser/http2.go
Normal file
265
services/ja4ebpf/internal/parser/http2.go
Normal file
@ -0,0 +1,265 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// H2Magic est la préface HTTP/2 client (RFC 7540 §3.5), exportée pour usage
|
||||
// par le routeur Magic Bytes (package dispatcher) et les consommateurs RingBuffer.
|
||||
const H2Magic = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
|
||||
|
||||
// h2MagicPrefaceLen est la longueur du préambule HTTP/2 client.
|
||||
const h2MagicPrefaceLen = 24
|
||||
|
||||
// h2MagicPreface est le préambule ("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") envoyé
|
||||
// par tout client HTTP/2 avant la première frame SETTINGS.
|
||||
var h2MagicPreface = []byte(H2Magic)
|
||||
|
||||
// Identifiants de types de frames HTTP/2 (RFC 7540, §11.2).
|
||||
const (
|
||||
h2FrameData = 0
|
||||
h2FrameHeaders = 1
|
||||
h2FramePriority = 2
|
||||
h2FrameRSTStream = 3
|
||||
h2FrameSettings = 4
|
||||
h2FramePushPromise = 5
|
||||
h2FramePing = 6
|
||||
h2FrameGoAway = 7
|
||||
h2FrameWindowUpdate = 8
|
||||
h2FrameContinuation = 9
|
||||
)
|
||||
|
||||
// Identifiants des paramètres SETTINGS (RFC 7540, §11.3).
|
||||
const (
|
||||
h2SettingHeaderTableSize = 1
|
||||
h2SettingEnablePush = 2
|
||||
h2SettingMaxConcurrentStreams = 3
|
||||
h2SettingInitialWindowSize = 4
|
||||
h2SettingMaxFrameSize = 5
|
||||
h2SettingMaxHeaderListSize = 6
|
||||
)
|
||||
|
||||
// h2FrameHeader représente l'en-tête fixe de 9 octets d'une frame HTTP/2.
|
||||
type h2FrameHeader struct {
|
||||
Length uint32 // longueur du payload (3 octets)
|
||||
Type uint8 // type de frame
|
||||
Flags uint8 // flags
|
||||
StreamID uint32 // identifiant de stream (masque 0x7FFFFFFF)
|
||||
}
|
||||
|
||||
// parseH2FrameHeader décode l'en-tête de 9 octets d'une frame HTTP/2.
|
||||
func parseH2FrameHeader(data []byte) (h2FrameHeader, error) {
|
||||
if len(data) < 9 {
|
||||
return h2FrameHeader{}, fmt.Errorf("données insuffisantes pour l'en-tête frame HTTP/2: %d octets", len(data))
|
||||
}
|
||||
// Longueur sur 3 octets big-endian
|
||||
length := uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
|
||||
return h2FrameHeader{
|
||||
Length: length,
|
||||
Type: data[3],
|
||||
Flags: data[4],
|
||||
StreamID: binary.BigEndian.Uint32(data[5:9]) & 0x7FFFFFFF,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DetectH2Preface vérifie si le buffer commence par le préambule HTTP/2.
|
||||
func DetectH2Preface(data []byte) bool {
|
||||
if len(data) < h2MagicPrefaceLen {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < h2MagicPrefaceLen; i++ {
|
||||
if data[i] != h2MagicPreface[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// H2MagicPrefaceLen retourne la longueur du préambule HTTP/2.
|
||||
func H2MagicPrefaceLen() int {
|
||||
return h2MagicPrefaceLen
|
||||
}
|
||||
|
||||
// HTTP2Settings contient les paramètres SETTINGS et WINDOW_UPDATE du client HTTP/2.
|
||||
type HTTP2Settings struct {
|
||||
HeaderTableSize int32 // SETTINGS_HEADER_TABLE_SIZE (-1 si absent)
|
||||
EnablePush int32 // SETTINGS_ENABLE_PUSH
|
||||
MaxConcurrentStreams int32 // SETTINGS_MAX_CONCURRENT_STREAMS
|
||||
InitialWindowSize int32 // SETTINGS_INITIAL_WINDOW_SIZE
|
||||
MaxFrameSize int32 // SETTINGS_MAX_FRAME_SIZE
|
||||
MaxHeaderListSize int32 // SETTINGS_MAX_HEADER_LIST_SIZE
|
||||
UnknownSettings int32 // paramètre 0x7 (JA4H2)
|
||||
WindowUpdateIncrement uint32 // valeur WINDOW_UPDATE sur stream 0
|
||||
PseudoHeaderOrder []string // ordre des pseudo-headers [:method, :authority, ...]
|
||||
}
|
||||
|
||||
// ParseH2ClientPreface extrait les paramètres SETTINGS et le WINDOW_UPDATE
|
||||
// depuis le flux HTTP/2 déchiffré du client.
|
||||
// data doit commencer APRÈS le magic preface (offset 24).
|
||||
func ParseH2ClientPreface(data []byte) (*HTTP2Settings, error) {
|
||||
settings := &HTTP2Settings{
|
||||
HeaderTableSize: -1,
|
||||
EnablePush: -1,
|
||||
MaxConcurrentStreams: -1,
|
||||
InitialWindowSize: -1,
|
||||
MaxFrameSize: -1,
|
||||
MaxHeaderListSize: -1,
|
||||
UnknownSettings: -1,
|
||||
}
|
||||
|
||||
offset := 0
|
||||
// Parser au maximum 10 frames pour éviter une boucle infinie
|
||||
for frameIdx := 0; frameIdx < 10 && offset < len(data); frameIdx++ {
|
||||
if offset+9 > len(data) {
|
||||
break
|
||||
}
|
||||
|
||||
hdr, err := parseH2FrameHeader(data[offset:])
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
offset += 9
|
||||
|
||||
payloadEnd := offset + int(hdr.Length)
|
||||
if payloadEnd > len(data) {
|
||||
break
|
||||
}
|
||||
payload := data[offset:payloadEnd]
|
||||
offset = payloadEnd
|
||||
|
||||
switch hdr.Type {
|
||||
case h2FrameSettings:
|
||||
// Parser uniquement les SETTINGS du client (stream 0)
|
||||
if hdr.StreamID == 0 {
|
||||
pairs, err := parseH2SettingsFrame(payload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for id, val := range pairs {
|
||||
switch id {
|
||||
case h2SettingHeaderTableSize:
|
||||
settings.HeaderTableSize = int32(val)
|
||||
case h2SettingEnablePush:
|
||||
settings.EnablePush = int32(val)
|
||||
case h2SettingMaxConcurrentStreams:
|
||||
settings.MaxConcurrentStreams = int32(val)
|
||||
case h2SettingInitialWindowSize:
|
||||
settings.InitialWindowSize = int32(val)
|
||||
case h2SettingMaxFrameSize:
|
||||
settings.MaxFrameSize = int32(val)
|
||||
case h2SettingMaxHeaderListSize:
|
||||
settings.MaxHeaderListSize = int32(val)
|
||||
case 7: // paramètre non standard (JA4H2)
|
||||
settings.UnknownSettings = int32(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case h2FrameWindowUpdate:
|
||||
// WINDOW_UPDATE sur stream 0 = flux de connexion
|
||||
if hdr.StreamID == 0 && len(payload) >= 4 {
|
||||
settings.WindowUpdateIncrement = binary.BigEndian.Uint32(payload[0:4]) & 0x7FFFFFFF
|
||||
}
|
||||
|
||||
case h2FrameHeaders:
|
||||
// Extraire l'ordre des pseudo-headers depuis le premier bloc HEADERS
|
||||
if hdr.StreamID > 0 && len(settings.PseudoHeaderOrder) == 0 {
|
||||
settings.PseudoHeaderOrder = ParseH2PseudoHeaders(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// parseH2SettingsFrame extrait les paires (identifiant, valeur) d'une frame SETTINGS.
|
||||
// Chaque paire fait 6 octets : identifiant(2) + valeur(4).
|
||||
func parseH2SettingsFrame(payload []byte) (map[uint16]uint32, error) {
|
||||
if len(payload)%6 != 0 {
|
||||
return nil, fmt.Errorf("longueur de frame SETTINGS invalide: %d (doit être multiple de 6)", len(payload))
|
||||
}
|
||||
result := make(map[uint16]uint32)
|
||||
for i := 0; i+6 <= len(payload); i += 6 {
|
||||
id := binary.BigEndian.Uint16(payload[i : i+2])
|
||||
val := binary.BigEndian.Uint32(payload[i+2 : i+6])
|
||||
result[id] = val
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ParseH2PseudoHeaders extrait l'ordre des pseudo-headers depuis un bloc HPACK.
|
||||
//
|
||||
// Implémentation simplifiée : détecte les pseudo-headers via les index HPACK statiques.
|
||||
// Table statique HPACK (RFC 7541, Annexe A) — index pertinents :
|
||||
// 1 :authority
|
||||
// 2 :method = GET
|
||||
// 3 :method = POST
|
||||
// 4 :path = /
|
||||
// 5 :path = /index.html
|
||||
// 6 :scheme = http
|
||||
// 7 :scheme = https
|
||||
func ParseH2PseudoHeaders(headersBlock []byte) []string {
|
||||
// Index HPACK statique → pseudo-header
|
||||
hpackStaticPseudo := map[int]string{
|
||||
1: ":authority",
|
||||
2: ":method",
|
||||
3: ":method",
|
||||
4: ":path",
|
||||
5: ":path",
|
||||
6: ":scheme",
|
||||
7: ":scheme",
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var order []string
|
||||
offset := 0
|
||||
|
||||
for offset < len(headersBlock) {
|
||||
b := headersBlock[offset]
|
||||
|
||||
// Représentation indexée (bit 7 = 1) : RFC 7541 §6.1
|
||||
if b&0x80 != 0 {
|
||||
idx := int(b & 0x7F)
|
||||
if name, ok := hpackStaticPseudo[idx]; ok {
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
order = append(order, name)
|
||||
}
|
||||
} else if idx == 0 {
|
||||
// Fin de la liste d'index ou encodage multi-octets
|
||||
offset++
|
||||
continue
|
||||
} else {
|
||||
// Index dynamique ou non-pseudo-header : arrêter le scan
|
||||
break
|
||||
}
|
||||
offset++
|
||||
continue
|
||||
}
|
||||
|
||||
// Représentation littérale avec index incrémental (bits 7-6 = 01) : RFC 7541 §6.2.1
|
||||
if b&0xC0 == 0x40 {
|
||||
idx := int(b & 0x3F)
|
||||
if name, ok := hpackStaticPseudo[idx]; ok {
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
order = append(order, name)
|
||||
}
|
||||
}
|
||||
offset++
|
||||
// Sauter la valeur (longueur + contenu)
|
||||
if offset >= len(headersBlock) {
|
||||
break
|
||||
}
|
||||
valueLen := int(headersBlock[offset] & 0x7F) // ignorer le bit Huffman
|
||||
offset += 1 + valueLen
|
||||
continue
|
||||
}
|
||||
|
||||
// Tout autre encodage : arrêter (ce n'est probablement plus un pseudo-header)
|
||||
break
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
157
services/ja4ebpf/internal/parser/http2_test.go
Normal file
157
services/ja4ebpf/internal/parser/http2_test.go
Normal file
@ -0,0 +1,157 @@
|
||||
package parser_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/antitbone/ja4/ja4ebpf/internal/parser"
|
||||
)
|
||||
|
||||
func TestDetectH2PrefaceTrue(t *testing.T) {
|
||||
preface := []byte("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
|
||||
data := append(preface, 0x00, 0x00) // données supplémentaires
|
||||
|
||||
if !parser.DetectH2Preface(data) {
|
||||
t.Error("H2Magic non détecté dans un buffer valide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectH2PrefaceFalse(t *testing.T) {
|
||||
if parser.DetectH2Preface([]byte("GET / HTTP/1.1\r\n")) {
|
||||
t.Error("détection faux positif HTTP/1.1 comme HTTP/2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectH2PrefaceTooShort(t *testing.T) {
|
||||
if parser.DetectH2Preface([]byte("PRI *")) {
|
||||
t.Error("détection sur buffer trop court")
|
||||
}
|
||||
}
|
||||
|
||||
func TestH2MagicPrefaceLen(t *testing.T) {
|
||||
if parser.H2MagicPrefaceLen() != 24 {
|
||||
t.Errorf("longueur préambule HTTP/2 attendue 24, obtenue %d", parser.H2MagicPrefaceLen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseH2ClientPrefaceSettingsEmpty(t *testing.T) {
|
||||
// Frame SETTINGS vide (longueur 0, aucun paramètre) sur stream 0
|
||||
frame := buildH2Frame(0x4, 0x0, 0, []byte{})
|
||||
settings, err := parser.ParseH2ClientPreface(frame)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseH2ClientPreface: %v", err)
|
||||
}
|
||||
if settings == nil {
|
||||
t.Fatal("settings ne doit pas être nil")
|
||||
}
|
||||
// Tous les champs doivent être -1 (absent)
|
||||
if settings.HeaderTableSize != -1 {
|
||||
t.Errorf("HeaderTableSize: attendu -1, obtenu %d", settings.HeaderTableSize)
|
||||
}
|
||||
if settings.InitialWindowSize != -1 {
|
||||
t.Errorf("InitialWindowSize: attendu -1, obtenu %d", settings.InitialWindowSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseH2ClientPrefaceSettingsWithValues(t *testing.T) {
|
||||
// Frame SETTINGS avec INITIAL_WINDOW_SIZE=65536 et MAX_CONCURRENT_STREAMS=100
|
||||
settingsPayload := []byte{
|
||||
0x00, 0x04, 0x00, 0x01, 0x00, 0x00, // INITIAL_WINDOW_SIZE = 65536
|
||||
0x00, 0x03, 0x00, 0x00, 0x00, 0x64, // MAX_CONCURRENT_STREAMS = 100
|
||||
}
|
||||
frame := buildH2Frame(0x4, 0x0, 0, settingsPayload)
|
||||
|
||||
settings, err := parser.ParseH2ClientPreface(frame)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseH2ClientPreface: %v", err)
|
||||
}
|
||||
|
||||
if settings.InitialWindowSize != 65536 {
|
||||
t.Errorf("InitialWindowSize: attendu 65536, obtenu %d", settings.InitialWindowSize)
|
||||
}
|
||||
if settings.MaxConcurrentStreams != 100 {
|
||||
t.Errorf("MaxConcurrentStreams: attendu 100, obtenu %d", settings.MaxConcurrentStreams)
|
||||
}
|
||||
// Les paramètres non présents restent à -1
|
||||
if settings.HeaderTableSize != -1 {
|
||||
t.Errorf("HeaderTableSize non fourni: attendu -1, obtenu %d", settings.HeaderTableSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseH2ClientPrefaceWindowUpdate(t *testing.T) {
|
||||
// Frame WINDOW_UPDATE sur stream 0 avec incrément = 1073741824
|
||||
wuPayload := []byte{0x40, 0x00, 0x00, 0x00} // 0x40000000 = 1073741824
|
||||
frame := buildH2Frame(0x8, 0x0, 0, wuPayload)
|
||||
|
||||
settings, err := parser.ParseH2ClientPreface(frame)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseH2ClientPreface: %v", err)
|
||||
}
|
||||
if settings.WindowUpdateIncrement != 1073741824 {
|
||||
t.Errorf("WindowUpdateIncrement: attendu 1073741824, obtenu %d", settings.WindowUpdateIncrement)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseH2ClientPrefaceCombined(t *testing.T) {
|
||||
// SETTINGS + WINDOW_UPDATE combinés (comme envoyé par curl/h2)
|
||||
settingsPayload := []byte{
|
||||
0x00, 0x01, 0x00, 0x00, 0x10, 0x00, // HEADER_TABLE_SIZE = 4096
|
||||
0x00, 0x04, 0x00, 0x00, 0xff, 0xff, // INITIAL_WINDOW_SIZE = 65535
|
||||
}
|
||||
wuPayload := []byte{0x00, 0x0f, 0x00, 0x01} // WINDOW_UPDATE incr = 983041
|
||||
|
||||
frames := buildH2Frame(0x4, 0x0, 0, settingsPayload)
|
||||
frames = append(frames, buildH2Frame(0x8, 0x0, 0, wuPayload)...)
|
||||
|
||||
settings, err := parser.ParseH2ClientPreface(frames)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseH2ClientPreface: %v", err)
|
||||
}
|
||||
if settings.HeaderTableSize != 4096 {
|
||||
t.Errorf("HeaderTableSize: attendu 4096, obtenu %d", settings.HeaderTableSize)
|
||||
}
|
||||
if settings.InitialWindowSize != 65535 {
|
||||
t.Errorf("InitialWindowSize: attendu 65535, obtenu %d", settings.InitialWindowSize)
|
||||
}
|
||||
if settings.WindowUpdateIncrement != 983041 {
|
||||
t.Errorf("WindowUpdateIncrement: attendu 983041, obtenu %d", settings.WindowUpdateIncrement)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseH2ClientPrefaceEmpty(t *testing.T) {
|
||||
// Données vides : doit retourner sans erreur, settings avec valeurs par défaut (-1)
|
||||
settings, err := parser.ParseH2ClientPreface([]byte{})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseH2ClientPreface sur vide: %v", err)
|
||||
}
|
||||
if settings == nil {
|
||||
t.Error("settings ne doit pas être nil même pour données vides")
|
||||
}
|
||||
if settings.HeaderTableSize != -1 {
|
||||
t.Errorf("HeaderTableSize: attendu -1 par défaut, obtenu %d", settings.HeaderTableSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseH2ClientPrefaceTruncatedFrame(t *testing.T) {
|
||||
// Frame tronquée : en-tête complet mais payload incomplet
|
||||
truncated := []byte{0x00, 0x00, 0x06, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x01} // payload tronqué
|
||||
settings, err := parser.ParseH2ClientPreface(truncated)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseH2ClientPreface sur frame tronquée: %v (doit tolérer)", err)
|
||||
}
|
||||
// Les paramètres restent à -1 car le payload est incomplet
|
||||
_ = settings
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
// buildH2Frame construit une frame HTTP/2 brute (en-tête 9 octets + payload).
|
||||
func buildH2Frame(frameType, flags uint8, streamID uint32, payload []byte) []byte {
|
||||
l := len(payload)
|
||||
frame := []byte{
|
||||
byte(l >> 16), byte(l >> 8), byte(l), // longueur sur 3 octets
|
||||
frameType, flags,
|
||||
byte(streamID >> 24), byte(streamID >> 16), byte(streamID >> 8), byte(streamID),
|
||||
}
|
||||
return append(frame, payload...)
|
||||
}
|
||||
353
services/ja4ebpf/internal/parser/tls.go
Normal file
353
services/ja4ebpf/internal/parser/tls.go
Normal file
@ -0,0 +1,353 @@
|
||||
// Package parser fournit les parseurs TLS ClientHello et HTTP/2
|
||||
// pour l'extraction des empreintes de fingerprinting réseau.
|
||||
package parser
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClientHello représente les champs extraits d'un message TLS ClientHello.
|
||||
type ClientHello struct {
|
||||
RecordVersion uint16 // version du record TLS (ex: 0x0303)
|
||||
HandshakeVersion uint16 // version dans le handshake
|
||||
CipherSuites []uint16 // suites de chiffrement proposées
|
||||
CompressionMethods []uint8 // méthodes de compression
|
||||
Extensions []Extension // liste des extensions TLS
|
||||
SNI string // Server Name Indication (si présent)
|
||||
ALPN []string // protocoles ALPN annoncés
|
||||
SupportedGroups []uint16 // groupes Diffie-Hellman supportés
|
||||
ECPointFormats []uint8 // formats de points elliptiques
|
||||
SupportedVersions []uint16 // versions TLS annoncées (extension 0x002b)
|
||||
}
|
||||
|
||||
// Extension représente une extension TLS avec son type et son contenu brut.
|
||||
type Extension struct {
|
||||
Type uint16 // identifiant de l'extension
|
||||
Data []byte // données brutes de l'extension
|
||||
}
|
||||
|
||||
// ParseClientHello extrait les champs du ClientHello TLS depuis le payload brut.
|
||||
// Le payload doit commencer au record layer TLS (premier octet = 0x16).
|
||||
// Retourne une erreur si le payload est tronqué ou structurellement invalide.
|
||||
func ParseClientHello(payload []byte) (*ClientHello, error) {
|
||||
if len(payload) < 5 {
|
||||
return nil, fmt.Errorf("payload trop court pour le record TLS: %d octets", len(payload))
|
||||
}
|
||||
|
||||
// Vérifier le type de contenu : 0x16 = Handshake
|
||||
if payload[0] != 0x16 {
|
||||
return nil, fmt.Errorf("type de contenu TLS inattendu: 0x%02x (attendu 0x16)", payload[0])
|
||||
}
|
||||
|
||||
recordVersion := binary.BigEndian.Uint16(payload[1:3])
|
||||
recordLength := int(binary.BigEndian.Uint16(payload[3:5]))
|
||||
|
||||
if len(payload) < 5+recordLength {
|
||||
return nil, fmt.Errorf("record TLS tronqué: attendu %d octets, reçu %d", 5+recordLength, len(payload))
|
||||
}
|
||||
|
||||
// Parsing du message Handshake
|
||||
hs := payload[5 : 5+recordLength]
|
||||
if len(hs) < 4 {
|
||||
return nil, fmt.Errorf("message Handshake trop court")
|
||||
}
|
||||
|
||||
// Vérifier le type de message Handshake : 0x01 = ClientHello
|
||||
if hs[0] != 0x01 {
|
||||
return nil, fmt.Errorf("type de message Handshake inattendu: 0x%02x (attendu 0x01)", hs[0])
|
||||
}
|
||||
|
||||
// Longueur du ClientHello (3 octets big-endian)
|
||||
chLen := int(uint32(hs[1])<<16 | uint32(hs[2])<<8 | uint32(hs[3]))
|
||||
if len(hs) < 4+chLen {
|
||||
return nil, fmt.Errorf("ClientHello tronqué: attendu %d octets", 4+chLen)
|
||||
}
|
||||
|
||||
ch := &ClientHello{RecordVersion: recordVersion}
|
||||
data := hs[4 : 4+chLen]
|
||||
|
||||
// Version du handshake (2 octets)
|
||||
if len(data) < 2 {
|
||||
return nil, fmt.Errorf("ClientHello: version manquante")
|
||||
}
|
||||
ch.HandshakeVersion = binary.BigEndian.Uint16(data[0:2])
|
||||
offset := 2
|
||||
|
||||
// Random (32 octets)
|
||||
if len(data) < offset+32 {
|
||||
return nil, fmt.Errorf("ClientHello: random manquant")
|
||||
}
|
||||
offset += 32
|
||||
|
||||
// Session ID (longueur 1 octet + données)
|
||||
if len(data) < offset+1 {
|
||||
return nil, fmt.Errorf("ClientHello: session ID manquant")
|
||||
}
|
||||
sessionIDLen := int(data[offset])
|
||||
offset += 1 + sessionIDLen
|
||||
|
||||
// Cipher Suites (longueur 2 octets + données)
|
||||
if len(data) < offset+2 {
|
||||
return nil, fmt.Errorf("ClientHello: longueur cipher suites manquante")
|
||||
}
|
||||
csLen := int(binary.BigEndian.Uint16(data[offset : offset+2]))
|
||||
offset += 2
|
||||
if len(data) < offset+csLen {
|
||||
return nil, fmt.Errorf("ClientHello: cipher suites tronquées")
|
||||
}
|
||||
for i := 0; i < csLen; i += 2 {
|
||||
cs := binary.BigEndian.Uint16(data[offset+i : offset+i+2])
|
||||
ch.CipherSuites = append(ch.CipherSuites, cs)
|
||||
}
|
||||
offset += csLen
|
||||
|
||||
// Compression Methods (longueur 1 octet + données)
|
||||
if len(data) < offset+1 {
|
||||
return nil, fmt.Errorf("ClientHello: longueur compression manquante")
|
||||
}
|
||||
compLen := int(data[offset])
|
||||
offset++
|
||||
if len(data) < offset+compLen {
|
||||
return nil, fmt.Errorf("ClientHello: méthodes de compression tronquées")
|
||||
}
|
||||
ch.CompressionMethods = data[offset : offset+compLen]
|
||||
offset += compLen
|
||||
|
||||
// Extensions (optionnelles)
|
||||
if len(data) < offset+2 {
|
||||
return ch, nil // pas d'extensions
|
||||
}
|
||||
extTotalLen := int(binary.BigEndian.Uint16(data[offset : offset+2]))
|
||||
offset += 2
|
||||
if len(data) < offset+extTotalLen {
|
||||
return nil, fmt.Errorf("ClientHello: extensions tronquées")
|
||||
}
|
||||
|
||||
// Parsing des extensions
|
||||
extData := data[offset : offset+extTotalLen]
|
||||
extOffset := 0
|
||||
for extOffset+4 <= len(extData) {
|
||||
extType := binary.BigEndian.Uint16(extData[extOffset : extOffset+2])
|
||||
extLen := int(binary.BigEndian.Uint16(extData[extOffset+2 : extOffset+4]))
|
||||
extOffset += 4
|
||||
|
||||
if extOffset+extLen > len(extData) {
|
||||
break
|
||||
}
|
||||
extPayload := extData[extOffset : extOffset+extLen]
|
||||
|
||||
ch.Extensions = append(ch.Extensions, Extension{Type: extType, Data: extPayload})
|
||||
|
||||
// Décoder les extensions importantes
|
||||
switch extType {
|
||||
case 0x0000: // SNI
|
||||
ch.SNI = parseSNI(extPayload)
|
||||
case 0x0010: // ALPN
|
||||
ch.ALPN = parseALPN(extPayload)
|
||||
case 0x000a: // Supported Groups (elliptic_curves)
|
||||
ch.SupportedGroups = parseSupportedGroups(extPayload)
|
||||
case 0x000b: // EC Point Formats
|
||||
ch.ECPointFormats = parseECPointFormats(extPayload)
|
||||
case 0x002b: // Supported Versions
|
||||
ch.SupportedVersions = parseSupportedVersions(extPayload)
|
||||
}
|
||||
|
||||
extOffset += extLen
|
||||
}
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// parseSNI extrait le nom d'hôte depuis l'extension SNI (type 0x0000).
|
||||
func parseSNI(data []byte) string {
|
||||
// Structure : list_len(2) + type(1) + name_len(2) + name
|
||||
if len(data) < 5 {
|
||||
return ""
|
||||
}
|
||||
// Ignorer list_len et name_type, lire directement name_len
|
||||
nameLen := int(binary.BigEndian.Uint16(data[3:5]))
|
||||
if len(data) < 5+nameLen {
|
||||
return ""
|
||||
}
|
||||
return string(data[5 : 5+nameLen])
|
||||
}
|
||||
|
||||
// parseALPN extrait la liste des protocoles ALPN (extension 0x0010).
|
||||
func parseALPN(data []byte) []string {
|
||||
if len(data) < 2 {
|
||||
return nil
|
||||
}
|
||||
listLen := int(binary.BigEndian.Uint16(data[0:2]))
|
||||
offset := 2
|
||||
var protocols []string
|
||||
for offset < 2+listLen && offset < len(data) {
|
||||
if offset+1 > len(data) {
|
||||
break
|
||||
}
|
||||
protoLen := int(data[offset])
|
||||
offset++
|
||||
if offset+protoLen > len(data) {
|
||||
break
|
||||
}
|
||||
protocols = append(protocols, string(data[offset:offset+protoLen]))
|
||||
offset += protoLen
|
||||
}
|
||||
return protocols
|
||||
}
|
||||
|
||||
// parseSupportedGroups extrait les groupes Diffie-Hellman (extension 0x000a).
|
||||
func parseSupportedGroups(data []byte) []uint16 {
|
||||
if len(data) < 2 {
|
||||
return nil
|
||||
}
|
||||
listLen := int(binary.BigEndian.Uint16(data[0:2]))
|
||||
offset := 2
|
||||
var groups []uint16
|
||||
for i := 0; i < listLen/2 && offset+2 <= len(data); i++ {
|
||||
groups = append(groups, binary.BigEndian.Uint16(data[offset:offset+2]))
|
||||
offset += 2
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// parseECPointFormats extrait les formats de points elliptiques (extension 0x000b).
|
||||
func parseECPointFormats(data []byte) []uint8 {
|
||||
if len(data) < 1 {
|
||||
return nil
|
||||
}
|
||||
listLen := int(data[0])
|
||||
if len(data) < 1+listLen {
|
||||
return nil
|
||||
}
|
||||
return data[1 : 1+listLen]
|
||||
}
|
||||
|
||||
// parseSupportedVersions extrait les versions TLS supportées (extension 0x002b).
|
||||
func parseSupportedVersions(data []byte) []uint16 {
|
||||
if len(data) < 1 {
|
||||
return nil
|
||||
}
|
||||
listLen := int(data[0])
|
||||
offset := 1
|
||||
var versions []uint16
|
||||
for i := 0; i < listLen/2 && offset+2 <= len(data); i++ {
|
||||
versions = append(versions, binary.BigEndian.Uint16(data[offset:offset+2]))
|
||||
offset += 2
|
||||
}
|
||||
return versions
|
||||
}
|
||||
|
||||
// isGREASE vérifie si une valeur est une valeur GREASE (RFC 8701).
|
||||
// Les valeurs GREASE suivent le motif 0x?A?A (ex: 0x0A0A, 0x1A1A, ...).
|
||||
func isGREASE(v uint16) bool {
|
||||
return v&0x0F0F == 0x0A0A && v>>8 == v&0xFF
|
||||
}
|
||||
|
||||
// tlsVersionString convertit un code de version TLS en chaîne à 2 caractères JA4.
|
||||
func tlsVersionString(v uint16) string {
|
||||
switch v {
|
||||
case 0x0304:
|
||||
return "13"
|
||||
case 0x0303:
|
||||
return "12"
|
||||
case 0x0302:
|
||||
return "11"
|
||||
case 0x0301:
|
||||
return "10"
|
||||
default:
|
||||
return "00"
|
||||
}
|
||||
}
|
||||
|
||||
// ComputeJA4 calcule l'empreinte JA4 selon la spécification FoxIO.
|
||||
//
|
||||
// Format: t{tls_ver}{sni}{cipher_count}{ext_count}_{sorted_ciphers_sha256[:12]}_{sorted_exts_alpn_sha256[:12]}
|
||||
func ComputeJA4(ch *ClientHello) string {
|
||||
// --- Protocole : toujours "t" (TCP) ---
|
||||
proto := "t"
|
||||
|
||||
// --- Version TLS : version la plus haute annoncée ---
|
||||
var tlsVer uint16
|
||||
for _, v := range ch.SupportedVersions {
|
||||
if !isGREASE(v) && v > tlsVer {
|
||||
tlsVer = v
|
||||
}
|
||||
}
|
||||
if tlsVer == 0 {
|
||||
// Fallback : version du handshake
|
||||
tlsVer = ch.HandshakeVersion
|
||||
}
|
||||
verStr := tlsVersionString(tlsVer)
|
||||
|
||||
// --- SNI : "d" si présent, "i" si absent ---
|
||||
sniFlag := "i"
|
||||
if ch.SNI != "" {
|
||||
sniFlag = "d"
|
||||
}
|
||||
|
||||
// --- Comptage des cipher suites (sans GREASE) ---
|
||||
var ciphers []uint16
|
||||
for _, cs := range ch.CipherSuites {
|
||||
if !isGREASE(cs) {
|
||||
ciphers = append(ciphers, cs)
|
||||
}
|
||||
}
|
||||
cipherCount := fmt.Sprintf("%02d", len(ciphers))
|
||||
|
||||
// --- Comptage des extensions (sans GREASE, sans SNI 0x0000) ---
|
||||
var extensions []uint16
|
||||
for _, ext := range ch.Extensions {
|
||||
if isGREASE(ext.Type) {
|
||||
continue
|
||||
}
|
||||
if ext.Type == 0x0000 { // SNI exclue du comptage
|
||||
continue
|
||||
}
|
||||
extensions = append(extensions, ext.Type)
|
||||
}
|
||||
extCount := fmt.Sprintf("%02d", len(extensions))
|
||||
|
||||
// --- Partie 1 : identifiant de base ---
|
||||
part1 := proto + verStr + sniFlag + cipherCount + extCount
|
||||
|
||||
// --- Partie 2 : SHA-256 des cipher suites triées (12 premiers hex chars) ---
|
||||
sortedCiphers := make([]uint16, len(ciphers))
|
||||
copy(sortedCiphers, ciphers)
|
||||
sort.Slice(sortedCiphers, func(i, j int) bool { return sortedCiphers[i] < sortedCiphers[j] })
|
||||
|
||||
cipherStrings := make([]string, len(sortedCiphers))
|
||||
for i, cs := range sortedCiphers {
|
||||
cipherStrings[i] = fmt.Sprintf("%04x", cs)
|
||||
}
|
||||
cipherRaw := strings.Join(cipherStrings, ",")
|
||||
cipherHash := sha256.Sum256([]byte(cipherRaw))
|
||||
part2 := hex.EncodeToString(cipherHash[:])[:12]
|
||||
|
||||
// --- Partie 3 : SHA-256 des extensions triées + ALPN (12 premiers hex chars) ---
|
||||
sortedExts := make([]uint16, len(extensions))
|
||||
copy(sortedExts, extensions)
|
||||
sort.Slice(sortedExts, func(i, j int) bool { return sortedExts[i] < sortedExts[j] })
|
||||
|
||||
extStrings := make([]string, len(sortedExts))
|
||||
for i, e := range sortedExts {
|
||||
extStrings[i] = fmt.Sprintf("%04x", e)
|
||||
}
|
||||
extRaw := strings.Join(extStrings, ",")
|
||||
|
||||
// Premier protocole ALPN (ou "00" si absent)
|
||||
alpnFirst := "00"
|
||||
if len(ch.ALPN) > 0 {
|
||||
alpnFirst = ch.ALPN[0]
|
||||
}
|
||||
|
||||
extAlpnRaw := extRaw + "_" + alpnFirst
|
||||
extHash := sha256.Sum256([]byte(extAlpnRaw))
|
||||
part3 := hex.EncodeToString(extHash[:])[:12]
|
||||
|
||||
return part1 + "_" + part2 + "_" + part3
|
||||
}
|
||||
241
services/ja4ebpf/internal/parser/tls_test.go
Normal file
241
services/ja4ebpf/internal/parser/tls_test.go
Normal file
@ -0,0 +1,241 @@
|
||||
package parser_test
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/antitbone/ja4/ja4ebpf/internal/parser"
|
||||
)
|
||||
|
||||
func TestParseClientHelloMinimal(t *testing.T) {
|
||||
// Construit un ClientHello minimal valide manuellement
|
||||
// pour tester le parsing sans dépendre d'un vrai paquet capturé.
|
||||
raw := buildMinimalClientHello()
|
||||
|
||||
ch, err := parser.ParseClientHello(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClientHello a échoué: %v", err)
|
||||
}
|
||||
|
||||
if ch.HandshakeVersion != 0x0303 {
|
||||
t.Errorf("version attendue 0x0303, obtenue 0x%04x", ch.HandshakeVersion)
|
||||
}
|
||||
if len(ch.CipherSuites) == 0 {
|
||||
t.Error("aucune cipher suite extraite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGREASEFiltering(t *testing.T) {
|
||||
// Les valeurs GREASE doivent être filtrées dans ComputeJA4
|
||||
raw := buildClientHelloWithGREASE()
|
||||
ch, err := parser.ParseClientHello(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClientHello: %v", err)
|
||||
}
|
||||
|
||||
ja4 := parser.ComputeJA4(ch)
|
||||
if ja4 == "" {
|
||||
t.Error("ComputeJA4 a retourné une chaîne vide")
|
||||
}
|
||||
t.Logf("JA4 = %s", ja4)
|
||||
}
|
||||
|
||||
func TestComputeJA4Format(t *testing.T) {
|
||||
raw := buildMinimalClientHello()
|
||||
ch, err := parser.ParseClientHello(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClientHello: %v", err)
|
||||
}
|
||||
|
||||
ja4 := parser.ComputeJA4(ch)
|
||||
// Format attendu : t{ver}{sni}{cc}{ec}_{12hex}_{12hex}
|
||||
// 5 chars + "_" + 12 chars + "_" + 12 chars = 31 chars minimum
|
||||
if len(ja4) < 31 {
|
||||
t.Errorf("longueur JA4 inattendue: %d chars — valeur: %q", len(ja4), ja4)
|
||||
}
|
||||
// Le premier caractère doit être 't' (TCP)
|
||||
if ja4[0] != 't' {
|
||||
t.Errorf("JA4 doit commencer par 't', obtenu %q", string(ja4[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSNIExtraction(t *testing.T) {
|
||||
raw := buildClientHelloWithSNI("example.com")
|
||||
ch, err := parser.ParseClientHello(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClientHello: %v", err)
|
||||
}
|
||||
if ch.SNI != "example.com" {
|
||||
t.Errorf("SNI attendu %q, obtenu %q", "example.com", ch.SNI)
|
||||
}
|
||||
|
||||
ja4 := parser.ComputeJA4(ch)
|
||||
// SNI présent → flag 'd' en position 3
|
||||
if len(ja4) >= 3 && ja4[3] != 'd' {
|
||||
t.Errorf("flag SNI attendu 'd', obtenu %q dans JA4=%q", string(ja4[3]), ja4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClientHelloTooShort(t *testing.T) {
|
||||
_, err := parser.ParseClientHello([]byte{0x16, 0x03, 0x01})
|
||||
if err == nil {
|
||||
t.Error("attendu une erreur pour un payload trop court")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClientHelloWrongType(t *testing.T) {
|
||||
// Type 0x17 = Application Data, pas un Handshake
|
||||
raw := make([]byte, 20)
|
||||
raw[0] = 0x17
|
||||
_, err := parser.ParseClientHello(raw)
|
||||
if err == nil {
|
||||
t.Error("attendu une erreur pour un Record Type incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers de construction de paquets TLS de test ────────────────────────
|
||||
|
||||
// buildMinimalClientHello construit un ClientHello TLS 1.2 minimal valide.
|
||||
func buildMinimalClientHello() []byte {
|
||||
// Contenu du Handshake (sans l'en-tête Record Layer)
|
||||
var hs []byte
|
||||
|
||||
// Version ClientHello : TLS 1.2
|
||||
hs = append(hs, 0x03, 0x03)
|
||||
|
||||
// Random : 32 octets
|
||||
random := make([]byte, 32)
|
||||
hs = append(hs, random...)
|
||||
|
||||
// Session ID : longueur 0
|
||||
hs = append(hs, 0x00)
|
||||
|
||||
// Cipher Suites : 2 suites (TLS_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_128_CBC_SHA)
|
||||
hs = append(hs, 0x00, 0x04) // longueur = 4 octets
|
||||
hs = append(hs, 0x13, 0x01) // TLS_AES_128_GCM_SHA256
|
||||
hs = append(hs, 0x00, 0x2f) // TLS_RSA_WITH_AES_128_CBC_SHA
|
||||
|
||||
// Compression Methods : 1 méthode (null)
|
||||
hs = append(hs, 0x01, 0x00)
|
||||
|
||||
// Pas d'extensions
|
||||
hs = append(hs, 0x00, 0x00)
|
||||
|
||||
return buildTLSRecord(hs)
|
||||
}
|
||||
|
||||
// buildClientHelloWithGREASE ajoute des valeurs GREASE aux cipher suites.
|
||||
func buildClientHelloWithGREASE() []byte {
|
||||
var hs []byte
|
||||
hs = append(hs, 0x03, 0x03) // version
|
||||
hs = append(hs, make([]byte, 32)...) // random
|
||||
hs = append(hs, 0x00) // session id len
|
||||
|
||||
// Cipher suites avec GREASE (0x0a0a)
|
||||
hs = append(hs, 0x00, 0x06) // longueur = 6
|
||||
hs = append(hs, 0x0a, 0x0a) // GREASE (doit être filtré)
|
||||
hs = append(hs, 0x13, 0x01) // TLS_AES_128_GCM_SHA256
|
||||
hs = append(hs, 0x00, 0x2f) // TLS_RSA_WITH_AES_128_CBC_SHA
|
||||
|
||||
hs = append(hs, 0x01, 0x00) // compression
|
||||
hs = append(hs, 0x00, 0x00) // no extensions
|
||||
|
||||
return buildTLSRecord(hs)
|
||||
}
|
||||
|
||||
// buildClientHelloWithSNI construit un ClientHello avec l'extension SNI.
|
||||
func buildClientHelloWithSNI(hostname string) []byte {
|
||||
var hs []byte
|
||||
hs = append(hs, 0x03, 0x03)
|
||||
hs = append(hs, make([]byte, 32)...)
|
||||
hs = append(hs, 0x00)
|
||||
hs = append(hs, 0x00, 0x04)
|
||||
hs = append(hs, 0x13, 0x01, 0x00, 0x2f)
|
||||
hs = append(hs, 0x01, 0x00)
|
||||
|
||||
// Extension SNI
|
||||
nameBytes := []byte(hostname)
|
||||
sniExt := buildSNIExtension(nameBytes)
|
||||
|
||||
// Bloc d'extensions
|
||||
extBlock := sniExt
|
||||
hs = appendUint16(hs, uint16(len(extBlock)))
|
||||
hs = append(hs, extBlock...)
|
||||
|
||||
return buildTLSRecord(hs)
|
||||
}
|
||||
|
||||
func buildSNIExtension(hostname []byte) []byte {
|
||||
// Type : 0x0000 (SNI)
|
||||
// Longueur extension = 2 (liste len) + 1 (type) + 2 (name len) + len(hostname)
|
||||
nameLen := len(hostname)
|
||||
listLen := 1 + 2 + nameLen
|
||||
|
||||
var ext []byte
|
||||
ext = append(ext, 0x00, 0x00) // type SNI
|
||||
ext = appendUint16(ext, uint16(2+listLen)) // longueur extension
|
||||
ext = appendUint16(ext, uint16(listLen)) // longueur liste SNI
|
||||
ext = append(ext, 0x00) // type : host_name
|
||||
ext = appendUint16(ext, uint16(nameLen)) // longueur hostname
|
||||
ext = append(ext, hostname...)
|
||||
return ext
|
||||
}
|
||||
|
||||
// buildTLSRecord encapsule un message Handshake dans un Record Layer TLS.
|
||||
func buildTLSRecord(handshakeBody []byte) []byte {
|
||||
hsLen := len(handshakeBody)
|
||||
var rec []byte
|
||||
|
||||
// Record Layer
|
||||
rec = append(rec, 0x16) // ContentType : Handshake
|
||||
rec = append(rec, 0x03, 0x01) // Version : TLS 1.0
|
||||
rec = appendUint16(rec, uint16(4+hsLen)) // longueur = type(1) + len(3) + body
|
||||
|
||||
// En-tête Handshake
|
||||
rec = append(rec, 0x01) // HandshakeType : ClientHello
|
||||
// Longueur sur 3 octets
|
||||
rec = append(rec, byte(hsLen>>16), byte(hsLen>>8), byte(hsLen))
|
||||
rec = append(rec, handshakeBody...)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
func appendUint16(b []byte, v uint16) []byte {
|
||||
return append(b, byte(v>>8), byte(v))
|
||||
}
|
||||
|
||||
// TestParseClientHelloHex teste le parsing d'un hex dump (smoke test).
|
||||
func TestParseClientHelloHexDump(t *testing.T) {
|
||||
// Hex dump minimal connu valide
|
||||
hexStr := "160301002f" + // Record : type=22, ver=0x0301, len=47
|
||||
"01000002b" + // Handshake : type=1, len=43 (ajusté)
|
||||
"0303" +
|
||||
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" +
|
||||
"00" + // session id len = 0
|
||||
"0004" + "13010035" + // cipher suites : 2 suites
|
||||
"0100" + // compression
|
||||
"0000" // no extensions
|
||||
|
||||
raw, err := hex.DecodeString(
|
||||
"1603010032" + // Record Layer : type=22, len=50
|
||||
"01" + "00002e" + // HandshakeType=1, len=46
|
||||
"0303" + // version
|
||||
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + // random
|
||||
"00" + // session id len
|
||||
"0004" + "13010035" + // 2 cipher suites
|
||||
"01" + "00" + // compression
|
||||
"0000", // extensions
|
||||
)
|
||||
if err != nil {
|
||||
t.Skipf("hex decode: %v", err)
|
||||
}
|
||||
|
||||
ch, err := parser.ParseClientHello(raw)
|
||||
if err != nil {
|
||||
// Acceptable : le hex dump est peut-être mal formé (longueurs)
|
||||
t.Logf("ParseClientHello (hex dump): %v", err)
|
||||
return
|
||||
}
|
||||
t.Logf("ClientHello parsé : version=0x%04x, ciphers=%d", ch.HandshakeVersion, len(ch.CipherSuites))
|
||||
_ = hexStr
|
||||
}
|
||||
Reference in New Issue
Block a user