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>
112 lines
3.4 KiB
Go
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
|
|
}
|