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>
287 lines
6.4 KiB
Go
287 lines
6.4 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package tcpecho
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// clientHello holds the fields JA4 needs from a parsed TLS ClientHello.
|
|
type clientHello struct {
|
|
legacyVersion uint16
|
|
cipherSuites []uint16
|
|
extensions []uint16 // in wire order
|
|
hasSNI bool
|
|
alpns []string
|
|
supportedVersions []uint16
|
|
sigAlgs []uint16 // in wire order
|
|
}
|
|
|
|
// isGREASE reports whether a code point is a GREASE value (RFC 8701): both
|
|
// bytes equal and of the form 0x?a. JA4 excludes these everywhere.
|
|
func isGREASE(v uint16) bool {
|
|
return v&0x0f0f == 0x0a0a && v>>8 == v&0xff
|
|
}
|
|
|
|
// parseClientHello parses a full TLS record (starting at the 0x16 record
|
|
// header) and extracts the ClientHello fields. Returns false if the bytes are
|
|
// not a well-formed ClientHello.
|
|
func parseClientHello(rec []byte) (*clientHello, bool) {
|
|
// Record header: type(1)=0x16, version(2), length(2).
|
|
if len(rec) < 5 || rec[0] != 0x16 {
|
|
return nil, false
|
|
}
|
|
recLen := int(binary.BigEndian.Uint16(rec[3:5]))
|
|
if len(rec) < 5+recLen {
|
|
return nil, false
|
|
}
|
|
b := rec[5 : 5+recLen]
|
|
// Handshake header: msg_type(1)=0x01 ClientHello, length(3).
|
|
if len(b) < 4 || b[0] != 0x01 {
|
|
return nil, false
|
|
}
|
|
hsLen := int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
|
b = b[4:]
|
|
if len(b) < hsLen {
|
|
return nil, false
|
|
}
|
|
b = b[:hsLen]
|
|
|
|
h := &clientHello{}
|
|
// client_version(2), random(32).
|
|
if len(b) < 34 {
|
|
return nil, false
|
|
}
|
|
h.legacyVersion = binary.BigEndian.Uint16(b[0:2])
|
|
b = b[34:]
|
|
// session_id.
|
|
if len(b) < 1 || len(b) < 1+int(b[0]) {
|
|
return nil, false
|
|
}
|
|
b = b[1+int(b[0]):]
|
|
// cipher_suites.
|
|
if len(b) < 2 {
|
|
return nil, false
|
|
}
|
|
cslen := int(binary.BigEndian.Uint16(b[0:2]))
|
|
b = b[2:]
|
|
if len(b) < cslen || cslen%2 != 0 {
|
|
return nil, false
|
|
}
|
|
for i := 0; i < cslen; i += 2 {
|
|
h.cipherSuites = append(h.cipherSuites, binary.BigEndian.Uint16(b[i:i+2]))
|
|
}
|
|
b = b[cslen:]
|
|
// compression_methods.
|
|
if len(b) < 1 || len(b) < 1+int(b[0]) {
|
|
return nil, false
|
|
}
|
|
b = b[1+int(b[0]):]
|
|
// extensions (optional).
|
|
if len(b) < 2 {
|
|
return h, true
|
|
}
|
|
extTotal := int(binary.BigEndian.Uint16(b[0:2]))
|
|
b = b[2:]
|
|
if len(b) < extTotal {
|
|
return nil, false
|
|
}
|
|
ext := b[:extTotal]
|
|
for len(ext) >= 4 {
|
|
etype := binary.BigEndian.Uint16(ext[0:2])
|
|
elen := int(binary.BigEndian.Uint16(ext[2:4]))
|
|
if len(ext) < 4+elen {
|
|
break
|
|
}
|
|
data := ext[4 : 4+elen]
|
|
h.extensions = append(h.extensions, etype)
|
|
switch etype {
|
|
case 0x0000: // server_name
|
|
h.hasSNI = true
|
|
case 0x0010: // ALPN
|
|
h.alpns = append(h.alpns, parseALPN(data)...)
|
|
case 0x002b: // supported_versions
|
|
h.supportedVersions = parseSupportedVersions(data)
|
|
case 0x000d: // signature_algorithms
|
|
h.sigAlgs = parseU16List(data)
|
|
}
|
|
ext = ext[4+elen:]
|
|
}
|
|
return h, true
|
|
}
|
|
|
|
func parseALPN(d []byte) []string {
|
|
if len(d) < 2 {
|
|
return nil
|
|
}
|
|
listLen := int(binary.BigEndian.Uint16(d[0:2]))
|
|
d = d[2:]
|
|
if len(d) < listLen {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for len(d) >= 1 {
|
|
n := int(d[0])
|
|
if len(d) < 1+n {
|
|
break
|
|
}
|
|
out = append(out, string(d[1:1+n]))
|
|
d = d[1+n:]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseSupportedVersions(d []byte) []uint16 {
|
|
if len(d) < 1 {
|
|
return nil
|
|
}
|
|
n := int(d[0])
|
|
d = d[1:]
|
|
if len(d) < n || n%2 != 0 {
|
|
return nil
|
|
}
|
|
var out []uint16
|
|
for i := 0; i < n; i += 2 {
|
|
out = append(out, binary.BigEndian.Uint16(d[i:i+2]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseU16List parses a 2-byte-length-prefixed list of u16 values (used for
|
|
// signature_algorithms).
|
|
func parseU16List(d []byte) []uint16 {
|
|
if len(d) < 2 {
|
|
return nil
|
|
}
|
|
n := int(binary.BigEndian.Uint16(d[0:2]))
|
|
d = d[2:]
|
|
if len(d) < n || n%2 != 0 {
|
|
return nil
|
|
}
|
|
var out []uint16
|
|
for i := 0; i < n; i += 2 {
|
|
out = append(out, binary.BigEndian.Uint16(d[i:i+2]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ja4 computes the JA4 TLS client fingerprint (FoxIO spec) from a parsed
|
|
// ClientHello: a_b_c where a is a human-readable prefix, b hashes the sorted
|
|
// cipher list, c hashes the sorted extensions + signature algorithms.
|
|
func ja4(h *clientHello) string {
|
|
// --- a ---
|
|
ver := ja4Version(h)
|
|
sni := "i"
|
|
if h.hasSNI {
|
|
sni = "d"
|
|
}
|
|
nCiphers := countNonGREASE(h.cipherSuites)
|
|
nExts := countNonGREASE(h.extensions) // count includes SNI + ALPN
|
|
alpn := "00"
|
|
if len(h.alpns) > 0 && h.alpns[0] != "" {
|
|
a := h.alpns[0]
|
|
alpn = string(a[0]) + string(a[len(a)-1])
|
|
}
|
|
a := fmt.Sprintf("t%s%s%02d%02d%s", ver, sni, capAt99(nCiphers), capAt99(nExts), alpn)
|
|
|
|
// --- b: sorted non-GREASE cipher suites, lowercase hex, comma-joined ---
|
|
b := hash12(strings.Join(sortedHex(nonGREASE(h.cipherSuites)), ","))
|
|
|
|
// --- c: sorted non-GREASE extensions (minus SNI 0000 and ALPN 0010),
|
|
// then "_", then signature algorithms IN ORDER (non-GREASE) ---
|
|
extsForC := filterOut(nonGREASE(h.extensions), 0x0000, 0x0010)
|
|
cInput := strings.Join(sortedHex(extsForC), ",") + "_" + strings.Join(hexList(nonGREASE(h.sigAlgs)), ",")
|
|
c := hash12(cInput)
|
|
|
|
return a + "_" + b + "_" + c
|
|
}
|
|
|
|
// ja4Version picks the highest offered version (supported_versions if present,
|
|
// else the legacy field) mapped to JA4's two-char code.
|
|
func ja4Version(h *clientHello) string {
|
|
best := h.legacyVersion
|
|
for _, v := range h.supportedVersions {
|
|
if isGREASE(v) {
|
|
continue
|
|
}
|
|
if v > best {
|
|
best = v
|
|
}
|
|
}
|
|
switch best {
|
|
case 0x0304:
|
|
return "13"
|
|
case 0x0303:
|
|
return "12"
|
|
case 0x0302:
|
|
return "11"
|
|
case 0x0301:
|
|
return "10"
|
|
case 0x0300:
|
|
return "s3"
|
|
}
|
|
return "00"
|
|
}
|
|
|
|
func nonGREASE(in []uint16) []uint16 {
|
|
out := make([]uint16, 0, len(in))
|
|
for _, v := range in {
|
|
if !isGREASE(v) {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func countNonGREASE(in []uint16) int { return len(nonGREASE(in)) }
|
|
|
|
func filterOut(in []uint16, drop ...uint16) []uint16 {
|
|
out := make([]uint16, 0, len(in))
|
|
for _, v := range in {
|
|
skip := false
|
|
for _, d := range drop {
|
|
if v == d {
|
|
skip = true
|
|
}
|
|
}
|
|
if !skip {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sortedHex(in []uint16) []string {
|
|
cp := append([]uint16(nil), in...)
|
|
sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] })
|
|
return hexList(cp)
|
|
}
|
|
|
|
func hexList(in []uint16) []string {
|
|
out := make([]string, len(in))
|
|
for i, v := range in {
|
|
var b [2]byte
|
|
binary.BigEndian.PutUint16(b[:], v)
|
|
out[i] = hex.EncodeToString(b[:])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func hash12(s string) string {
|
|
sum := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(sum[:])[:12]
|
|
}
|
|
|
|
func capAt99(n int) int {
|
|
if n > 99 {
|
|
return 99
|
|
}
|
|
return n
|
|
}
|