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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8a854141c5
commit
1472a86508
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user