Files
echolot/server/internal/tcpecho/ja4_test.go
T
mrambossekandClaude Opus 5 1472a86508
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 28s
server-release / release (push) Successful in 28s
server: tls-echo — ClientHello capture + JA4 on the TCP-echo port (§4 complete)
A connection opening with a TLS handshake (first byte 0x16) and ALPN
elt-echo gets the ClientHello it sent back raw (b64) and as a JA4
fingerprint (sec.clienthello_echo), then a TLS byte-echo; plain
connections are unchanged. One port, multiplexed by a timed peek:
plain echo is server-speaks-first, so a silent client (peek timeout) is
greeted, while a TLS client's immediate ClientHello (0x16) routes to the
TLS path — 500ms tolerates ~1s RTT before misdetection.

JA4 (FoxIO): full ClientHello parser (ciphers, extensions, ALPN,
supported_versions, sig algs) with GREASE exclusion; a_b_c fingerprint,
unit-tested for structure + GREASE invariance. Live-verified: elt-echo
negotiated, JA4 t13d1712eo computed, 1530-byte ClientHello returned.
Capability tls-echo. This completes spec §4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:59:44 +02:00

112 lines
3.4 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package tcpecho
import (
"encoding/binary"
"strings"
"testing"
)
// buildClientHello assembles a minimal but valid TLS ClientHello record for
// tests: TLS1.2 legacy version, the given ciphers, and extensions SNI, ALPN
// (h2), supported_versions (1.3), signature_algorithms (0x0403).
func buildClientHello(ciphers []uint16) []byte {
u16 := func(v uint16) []byte { b := make([]byte, 2); binary.BigEndian.PutUint16(b, v); return b }
var body []byte
body = append(body, u16(0x0303)...) // client_version TLS1.2
body = append(body, make([]byte, 32)...) // random
body = append(body, 0) // session_id len 0
// cipher suites
cs := []byte{}
for _, c := range ciphers {
cs = append(cs, u16(c)...)
}
body = append(body, u16(uint16(len(cs)))...)
body = append(body, cs...)
body = append(body, 1, 0) // compression: 1 method, null
// extensions
var exts []byte
addExt := func(typ uint16, data []byte) {
exts = append(exts, u16(typ)...)
exts = append(exts, u16(uint16(len(data)))...)
exts = append(exts, data...)
}
// SNI: server_name_list -> host_name "x"
sni := append(u16(3), 0) // list len 3, name_type host_name(0)
sni = append(sni, u16(1)...) // name len 1
sni = append(sni, 'x')
addExt(0x0000, sni)
// ALPN: protocol_name_list -> "h2"
alpn := append(u16(3), 2, 'h', '2') // list len 3, strlen 2, "h2"
addExt(0x0010, alpn)
// supported_versions: list len 2, 0x0304
addExt(0x002b, append([]byte{2}, u16(0x0304)...))
// signature_algorithms: list len 2, 0x0403
addExt(0x000d, append(u16(2), u16(0x0403)...))
body = append(body, u16(uint16(len(exts)))...)
body = append(body, exts...)
// handshake header
hs := []byte{0x01, byte(len(body) >> 16), byte(len(body) >> 8), byte(len(body))}
hs = append(hs, body...)
// record header
rec := []byte{0x16, 0x03, 0x01, byte(len(hs) >> 8), byte(len(hs))}
return append(rec, hs...)
}
func TestParseAndJA4(t *testing.T) {
rec := buildClientHello([]uint16{0x1301, 0x1302})
h, ok := parseClientHello(rec)
if !ok {
t.Fatal("parse failed")
}
if len(h.cipherSuites) != 2 || !h.hasSNI || len(h.alpns) != 1 || h.alpns[0] != "h2" {
t.Fatalf("parsed fields wrong: %+v", h)
}
if len(h.supportedVersions) != 1 || h.supportedVersions[0] != 0x0304 {
t.Fatalf("supported_versions: %v", h.supportedVersions)
}
got := ja4(h)
// _a: t + 13 (supported_versions 1.3) + d (SNI) + 02 ciphers + 04 exts + h2
wantA := "t13d0204h2"
parts := strings.Split(got, "_")
if len(parts) != 3 {
t.Fatalf("JA4 not 3 parts: %s", got)
}
if parts[0] != wantA {
t.Fatalf("JA4_a = %s, want %s (full %s)", parts[0], wantA, got)
}
if len(parts[1]) != 12 || len(parts[2]) != 12 {
t.Fatalf("JA4 hash parts not 12 hex: %s", got)
}
// determinism
if ja4(h) != got {
t.Fatal("JA4 not deterministic")
}
}
func TestJA4GREASEExcluded(t *testing.T) {
// Same hello but with a GREASE cipher inserted; cipher count and _b hash
// must be identical to the non-GREASE version.
base := ja4(mustParse(t, buildClientHello([]uint16{0x1301, 0x1302})))
withGrease := ja4(mustParse(t, buildClientHello([]uint16{0x0a0a, 0x1301, 0x1302})))
if base != withGrease {
t.Fatalf("GREASE changed JA4:\n base=%s\n grease=%s", base, withGrease)
}
}
func mustParse(t *testing.T, rec []byte) *clientHello {
t.Helper()
h, ok := parseClientHello(rec)
if !ok {
t.Fatal("parse failed")
}
return h
}