// 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 }