server: tls-echo — ClientHello capture + JA4 on the TCP-echo port (§4 complete)
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 28s
server-release / release (push) Successful in 28s

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>
This commit is contained in:
mrambossek
2026-07-31 20:59:44 +02:00
co-authored by Claude Opus 5
parent 8a854141c5
commit 1472a86508
5 changed files with 570 additions and 16 deletions
+286
View File
@@ -0,0 +1,286 @@
// 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
}