Files
Jacquin Antoine f0c8fe81c6 feat(ja4ebpf): add multi-interface TC, LPM_TRIE ignore_src, unit tests, and fix bugs
- Add multi-interface TC attachment (default "any" = all UP interfaces)
- Add BPF LPM_TRIE map ignored_src for kernel-side CIDR filtering
- Add userspace ignore_src filtering for SSL/accept4 path via net.IPNet.Contains()
- Add AcceptCache for fd→SessionKey correlation with TTL and Close()
- Add 5 test files covering writer, procutil, dispatcher, accept_cache, and cmd
- Fix formatTCPOptions infinite loop on EOL (case 0 break→return)
- Fix pseudoOrderToShort panic on empty slice (negative cap)
- Fix AcceptCache goroutine leak (add done channel + Close())
- Update config.yml.example with interfaces, listen_ports, ignore_src
- Rewrite docs/services/ja4ebpf.md (was massively stale: XDP, RingBuffer, etc.)
- Fix stale XDP/RingBuffer references in docs/architecture.md, thesis, tls.go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 01:49:26 +02:00

438 lines
12 KiB
Go

// Package parser fournit les parseurs TLS ClientHello et HTTP/2
// pour l'extraction des empreintes de fingerprinting réseau.
package parser
import (
"crypto/md5"
"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]))
// Le programme TC capture au maximum MAX_TLS_PAYLOAD (2048) octets.
// Si la taille du record TLS dépasse les données disponibles, on travaille
// avec ce qu'on a (le ClientHello est toujours en début de record).
available := len(payload) - 5
if recordLength > available {
recordLength = available
}
// 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]))
// Tolérance à la troncature TC : on travaille avec ce qu'on a
if chLen > len(hs)-4 {
chLen = len(hs) - 4
}
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 {
csLen = len(data) - offset // troncature tolérée
}
for i := 0; i+2 <= 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 ch, nil // troncature : retourner ce qu'on a
}
compLen := int(data[offset])
offset++
if len(data) < offset+compLen {
compLen = len(data) - offset
}
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 {
extTotalLen = len(data) - offset // troncature tolérée
}
// 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
}
// ComputeJA3 calcule l'empreinte JA3 selon la spécification Salesforce.
//
// Format : TLSVersion,CipherSuites,Extensions,EllipticCurves,EllipticPointFormats
// Chaque segment est une liste de valeurs décimales séparées par des tirets.
// Le JA3 hash est le MD5 hex de cette chaîne.
func ComputeJA3(ch *ClientHello) (ja3Raw string, ja3Hash string) {
// --- Version TLS ---
var tlsVer uint16
for _, v := range ch.SupportedVersions {
if !IsGREASE(v) && v > tlsVer {
tlsVer = v
}
}
if tlsVer == 0 {
tlsVer = ch.HandshakeVersion
}
// --- Cipher suites (sans GREASE) ---
var ciphers []string
for _, cs := range ch.CipherSuites {
if !IsGREASE(cs) {
ciphers = append(ciphers, fmt.Sprintf("%d", cs))
}
}
// --- Extensions (sans GREASE, sans SNI 0x0000) ---
var exts []string
for _, e := range ch.Extensions {
if IsGREASE(e.Type) {
continue
}
// SNI (0x0000) est inclus dans JA3
exts = append(exts, fmt.Sprintf("%d", e.Type))
}
// --- Groupes elliptiques (extension supported_groups 0x000a) ---
var groups []string
for _, e := range ch.Extensions {
if e.Type == 0x000a && len(e.Data) >= 4 {
// Format : longueur (2 octets) + liste de groupes (2 octets chacun)
groupLen := int(binary.BigEndian.Uint16(e.Data[:2]))
for i := 2; i+1 < len(e.Data) && i < groupLen+2; i += 2 {
g := binary.BigEndian.Uint16(e.Data[i : i+2])
if !IsGREASE(g) {
groups = append(groups, fmt.Sprintf("%d", g))
}
}
}
}
// --- Formats de points elliptiques (extension ec_point_formats 0x000b) ---
var ecPointFormats []string
for _, e := range ch.Extensions {
if e.Type == 0x000b && len(e.Data) >= 2 {
fmtLen := int(e.Data[0])
for i := 1; i < len(e.Data) && i <= fmtLen; i++ {
ecPointFormats = append(ecPointFormats, fmt.Sprintf("%d", e.Data[i]))
}
}
}
// --- Assemblage JA3 raw ---
parts := []string{
fmt.Sprintf("%d", tlsVer),
strings.Join(ciphers, "-"),
strings.Join(exts, "-"),
strings.Join(groups, "-"),
strings.Join(ecPointFormats, "-"),
}
ja3Raw = strings.Join(parts, ",")
// --- JA3 hash = MD5 du raw ---
md5Hash := md5.Sum([]byte(ja3Raw))
ja3Hash = hex.EncodeToString(md5Hash[:])
return ja3Raw, ja3Hash
}