diff --git a/fingerprint/ja4.go b/fingerprint/ja4.go
@@ -3,19 +3,22 @@ package fingerprint
import (
"crypto/sha256"
"encoding/binary"
+ "encoding/hex"
"fmt"
- "net"
"slices"
"strings"
)
+// noValues is the JA4 placeholder for a field with no values. The spec uses it
+// rather than sha256 of the empty string so an empty field is visible.
+const noValues = "000000000000"
+
// ClientHelloFields holds the parsed TLS ClientHello data used to compute JA4.
type ClientHelloFields struct {
LegacyVersion uint16
CipherSuites []uint16 // non-GREASE, wire order
Extensions []uint16 // non-GREASE extension types, wire order
SNIHost string
- SNIIsIP bool
SupportedVersions []uint16 // from ext 0x002b, non-GREASE
ALPNValues []string // from ext 0x0010
SigAlgs []uint16 // from ext 0x000d, wire order
@@ -176,7 +179,6 @@ func parseSNI(data []byte, f *ClientHelloFields) {
data = data[nameLen:]
if nameType == 0 { // host_name (the only defined type)
f.SNIHost = name
- f.SNIIsIP = net.ParseIP(name) != nil
return
}
}
@@ -231,7 +233,10 @@ func parseSigAlgs(data []byte, f *ClientHelloFields) {
return
}
for i := 0; i < listLen; i += 2 {
- f.SigAlgs = append(f.SigAlgs, binary.BigEndian.Uint16(data[i:i+2]))
+ // RFC 8701 permits GREASE here too.
+ if a := binary.BigEndian.Uint16(data[i : i+2]); !isGREASE(a) {
+ f.SigAlgs = append(f.SigAlgs, a)
+ }
}
}
@@ -245,13 +250,10 @@ func buildJA4a(ch *ClientHelloFields) string {
tlsVer = tlsVersionStr(slices.Max(ch.SupportedVersions))
}
- sniChar := "n"
- if ch.SNIHost != "" {
- if ch.SNIIsIP {
- sniChar = "i"
- } else {
- sniChar = "d"
- }
+ // The spec keys on presence of the SNI extension, not on what it contains.
+ sniChar := "i"
+ if slices.Contains(ch.Extensions, 0x0000) {
+ sniChar = "d"
}
nc := min99(len(ch.CipherSuites))
@@ -259,18 +261,32 @@ func buildJA4a(ch *ClientHelloFields) string {
alpn := "00"
if len(ch.ALPNValues) > 0 {
- v := ch.ALPNValues[0]
- switch {
- case len(v) >= 2:
- alpn = v[:2]
- case len(v) == 1:
- alpn = v + "0"
- }
+ alpn = alpnChars(ch.ALPNValues[0])
}
return fmt.Sprintf("t%s%s%02d%02d%s", tlsVer, sniChar, nc, ne, alpn)
}
+// alpnChars is the two-character ALPN field: the first and last bytes of the
+// value. If either end byte is not ASCII alphanumeric, the whole value is
+// hex-encoded and the first and last characters of that hex string are used.
+// This also keeps client-controlled raw bytes out of the fingerprint.
+func alpnChars(v string) string {
+ if v == "" {
+ return "00"
+ }
+ first, last := v[0], v[len(v)-1]
+ if !isALPNAlnum(first) || !isALPNAlnum(last) {
+ h := hex.EncodeToString([]byte(v))
+ first, last = h[0], h[len(h)-1]
+ }
+ return string([]byte{first, last})
+}
+
+func isALPNAlnum(b byte) bool {
+ return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z'
+}
+
// min99 caps n at 99, the maximum that fits in the two-digit JA4_a counter.
func min99(n int) int {
if n > 99 {
@@ -289,12 +305,25 @@ func tlsVersionStr(v uint16) string {
return "11"
case 0x0301:
return "10"
+ case 0x0300:
+ return "s3"
+ case 0x0002:
+ return "s2"
+ case 0xfeff:
+ return "d1"
+ case 0xfefd:
+ return "d2"
+ case 0xfefc:
+ return "d3"
default:
return "00"
}
}
func buildJA4b(ciphers []uint16) string {
+ if len(ciphers) == 0 {
+ return noValues
+ }
sorted := slices.Clone(ciphers)
slices.Sort(sorted)
@@ -316,18 +345,26 @@ func buildJA4c(exts []uint16, sigAlgs []uint16) string {
filtered = append(filtered, e)
}
}
+ if len(filtered) == 0 {
+ return noValues
+ }
slices.Sort(filtered)
extParts := make([]string, len(filtered))
for i, e := range filtered {
extParts[i] = fmt.Sprintf("%04x", e)
}
+ preimage := strings.Join(extParts, ",")
- sigParts := make([]string, len(sigAlgs))
- for i, s := range sigAlgs {
- sigParts[i] = fmt.Sprintf("%04x", s)
+ // No separator at all when there are no signature algorithms.
+ if len(sigAlgs) > 0 {
+ sigParts := make([]string, len(sigAlgs))
+ for i, s := range sigAlgs {
+ sigParts[i] = fmt.Sprintf("%04x", s)
+ }
+ preimage += "_" + strings.Join(sigParts, ",")
}
- h := sha256.Sum256([]byte(strings.Join(extParts, ",") + "_" + strings.Join(sigParts, ",")))
+ h := sha256.Sum256([]byte(preimage))
return fmt.Sprintf("%x", h)[:12]
}
diff --git a/fingerprint/ja4_test.go b/fingerprint/ja4_test.go
@@ -94,8 +94,8 @@ func TestParseClientHello_Fields(t *testing.T) {
if want := []uint16{0x0000, 0x002b, 0x0010, 0x000d, 0x0033}; !equalU16(ch.Extensions, want) {
t.Errorf("extensions = %v; want %v", ch.Extensions, want)
}
- if ch.SNIHost != "example.com" || ch.SNIIsIP {
- t.Errorf("SNI = %q (ip=%v); want example.com (ip=false)", ch.SNIHost, ch.SNIIsIP)
+ if ch.SNIHost != "example.com" {
+ t.Errorf("SNI = %q; want example.com", ch.SNIHost)
}
if want := []uint16{0x0304}; !equalU16(ch.SupportedVersions, want) {
t.Errorf("supported versions = %v; want %v", ch.SupportedVersions, want)
@@ -108,14 +108,103 @@ func TestParseClientHello_Fields(t *testing.T) {
}
}
-func TestParseClientHello_SNI_IP(t *testing.T) {
+// An IP literal in SNI is still an SNI extension, so JA4_a stays "d".
+// The spec keys on extension presence, never on the value.
+func TestComputeJA4_a_SNI_IPLiteral(t *testing.T) {
exts := ext(0x0000, sniData("192.0.2.1"))
ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x1301}, exts))
if err != nil {
t.Fatalf("ParseClientHello: %v", err)
}
- if ch.SNIHost != "192.0.2.1" || !ch.SNIIsIP {
- t.Errorf("SNI = %q (ip=%v); want 192.0.2.1 (ip=true)", ch.SNIHost, ch.SNIIsIP)
+ if ch.SNIHost != "192.0.2.1" {
+ t.Errorf("SNI = %q; want 192.0.2.1", ch.SNIHost)
+ }
+ if got := ComputeJA4(ch)[:8]; got != "t12d0101" {
+ t.Errorf("JA4_a = %q; want %q", got, "t12d0101")
+ }
+}
+
+func TestParseClientHello_SigAlgsGREASEFiltered(t *testing.T) {
+ exts := ext(0x000d, sigAlgsData(0x0a0a, 0x0403, 0x1a1a))
+ ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x1301}, exts))
+ if err != nil {
+ t.Fatalf("ParseClientHello: %v", err)
+ }
+ if want := []uint16{0x0403}; !equalU16(ch.SigAlgs, want) {
+ t.Errorf("sig algs = %v; want %v (GREASE filtered)", ch.SigAlgs, want)
+ }
+}
+
+func TestTLSVersionStr(t *testing.T) {
+ cases := map[uint16]string{
+ 0x0304: "13", 0x0303: "12", 0x0302: "11", 0x0301: "10",
+ 0x0300: "s3", 0x0002: "s2",
+ 0xfeff: "d1", 0xfefd: "d2", 0xfefc: "d3",
+ 0x0999: "00",
+ }
+ for v, want := range cases {
+ if got := tlsVersionStr(v); got != want {
+ t.Errorf("tlsVersionStr(0x%04x) = %q; want %q", v, got, want)
+ }
+ }
+}
+
+// The 8 hex-fallback examples from the FoxIO spec, plus the plain cases.
+// Checked end to end so the ALPN bytes travel through the parser.
+func TestComputeJA4_a_ALPNValues(t *testing.T) {
+ cases := []struct {
+ in string
+ want string
+ }{
+ {"http/1.1", "h1"},
+ {"h2", "h2"},
+ {"x", "xx"},
+ {"", "00"},
+ {"\xab", "ab"},
+ {"\x20", "20"},
+ {"\xab\xcd", "ad"},
+ {"\x20\x61", "21"},
+ {"\x30\xab", "3b"},
+ {"\x61\x20", "60"},
+ {"\x30\x31\xab\xcd", "3d"},
+ {"\x30\xab\xcd\x31", "01"}, // both end bytes alphanumeric, middle ignored
+ }
+ for _, tc := range cases {
+ t.Run(fmt.Sprintf("%x", tc.in), func(t *testing.T) {
+ exts := ext(0x0010, alpnData(tc.in))
+ ch, err := ParseClientHello(buildClientHello(0x0303, []uint16{0x1301}, exts))
+ if err != nil {
+ t.Fatalf("ParseClientHello: %v", err)
+ }
+ want := "t12i0101" + tc.want
+ if got := ComputeJA4(ch)[:len(want)]; got != want {
+ t.Errorf("JA4_a = %q; want %q", got, want)
+ }
+ })
+ }
+}
+
+func TestBuildJA4b_EmptyCiphers(t *testing.T) {
+ if got := buildJA4b(nil); got != "000000000000" {
+ t.Errorf("buildJA4b(nil) = %q; want 000000000000", got)
+ }
+}
+
+// Only SNI and ALPN present: both are filtered out, so JA4_c has no values.
+func TestBuildJA4c_EmptyFilteredExts(t *testing.T) {
+ if got := buildJA4c([]uint16{0x0000, 0x0010}, []uint16{0x0403}); got != "000000000000" {
+ t.Errorf("buildJA4c = %q; want 000000000000", got)
+ }
+}
+
+// Spec vector: with no signature algorithms the trailing "_" is omitted.
+func TestBuildJA4c_NoSigAlgsOmitsSeparator(t *testing.T) {
+ exts := []uint16{
+ 0x0005, 0x000a, 0x000b, 0x000d, 0x0012, 0x0015, 0x0017, 0x001b,
+ 0x0023, 0x002b, 0x002d, 0x0033, 0x4469, 0xff01,
+ }
+ if got := buildJA4c(exts, nil); got != "6d807ffa2a79" {
+ t.Errorf("buildJA4c = %q; want 6d807ffa2a79", got)
}
}
@@ -187,14 +276,56 @@ func TestComputeJA4_a_Indicators(t *testing.T) {
legacyVer: 0x0303,
ciphers: []uint16{0x1301, 0x1302},
exts: ext(0x0033, []byte{}),
- wantPrefix: "t12n0201", // legacy 1.2, no SNI -> n, 2 ciphers, 1 ext, alpn 00
+ wantPrefix: "t12i0201", // legacy 1.2, no SNI ext -> i, 2 ciphers, 1 ext, alpn 00
},
{
name: "supported_versions max wins over legacy",
legacyVer: 0x0301,
ciphers: []uint16{0x1301},
exts: ext(0x002b, clientSuppVerData(0x0303, 0x0304)),
- wantPrefix: "t13n0101", // max(1.2,1.3)=1.3
+ wantPrefix: "t13i0101", // max(1.2,1.3)=1.3
+ },
+ {
+ name: "SNI extension present",
+ legacyVer: 0x0303,
+ ciphers: []uint16{0x1301},
+ exts: ext(0x0000, sniData("example.com")),
+ wantPrefix: "t12d0101",
+ },
+ {
+ name: "empty SNI extension still counts as present",
+ legacyVer: 0x0303,
+ ciphers: []uint16{0x1301},
+ exts: ext(0x0000, []byte{}),
+ wantPrefix: "t12d0101",
+ },
+ {
+ name: "alpn http/1.1 uses first and last",
+ legacyVer: 0x0303,
+ ciphers: []uint16{0x1301},
+ exts: ext(0x0010, alpnData("http/1.1", "h2")),
+ wantPrefix: "t12i0101h1",
+ },
+ {
+ name: "single-character alpn is doubled",
+ legacyVer: 0x0303,
+ ciphers: []uint16{0x1301},
+ exts: ext(0x0010, alpnData("x")),
+ wantPrefix: "t12i0101xx",
+ },
+ {
+ name: "non-alphanumeric alpn falls back to hex",
+ legacyVer: 0x0303,
+ ciphers: []uint16{0x1301},
+ exts: ext(0x0010, alpnData("\x20\x61")),
+ wantPrefix: "t12i010121",
+ },
+ {
+ name: "sslv3 legacy version",
+ legacyVer: 0x0300,
+ ciphers: []uint16{0x1301},
+ exts: ext(0x0033, []byte{}),
+ wantPrefix: "ts3i0101",
},
}
for _, tc := range tests {
@@ -211,6 +342,20 @@ func TestComputeJA4_a_Indicators(t *testing.T) {
}
}
+// Both hash fields hit the no-values sentinel: no ciphers, and the only two
+// extensions are the ones JA4_c filters out.
+func TestComputeJA4_BothSentinels(t *testing.T) {
+ exts := append(ext(0x0000, sniData("example.com")), ext(0x0010, alpnData("h2"))...)
+ ch, err := ParseClientHello(buildClientHello(0x0303, nil, exts))
+ if err != nil {
+ t.Fatalf("ParseClientHello: %v", err)
+ }
+ const want = "t12d0002h2_000000000000_000000000000"
+ if got := ComputeJA4(ch); got != want {
+ t.Errorf("JA4 = %q; want %q", got, want)
+ }
+}
+
func equalU16(a, b []uint16) bool {
if len(a) != len(b) {
return false
diff --git a/fingerprint/ja4s.go b/fingerprint/ja4s.go
@@ -3,6 +3,7 @@ package fingerprint
import (
"crypto/sha256"
"encoding/binary"
+ "encoding/hex"
"fmt"
"strings"
)
@@ -132,20 +133,37 @@ func buildJA4Sa(sh *ServerHelloFields) string {
nn := min99(len(sh.Extensions))
- alpn := "00"
- switch v := sh.ALPN; {
- case len(v) >= 2:
- alpn = v[:2]
- case len(v) == 1:
- alpn = v + "0"
- }
+ return fmt.Sprintf("t%s%02d%s", ver, nn, ja4sALPNCode(sh.ALPN))
+}
- return fmt.Sprintf("t%s%02d%s", ver, nn, alpn)
+// ja4sALPNCode encodes the negotiated ALPN as the JA4 two-character code:
+// first and last byte of the value. If either end byte is not ASCII
+// alphanumeric, hex-encode the whole value and take the first and last hex
+// characters instead. Duplicated from the JA4 side on purpose so the two
+// fingerprints stay independently editable.
+func ja4sALPNCode(v string) string {
+ if v == "" {
+ return "00"
+ }
+ b := []byte(v)
+ first, last := b[0], b[len(b)-1]
+ if !ja4sIsAlnum(first) || !ja4sIsAlnum(last) {
+ h := hex.EncodeToString(b)
+ return string(h[0]) + string(h[len(h)-1])
+ }
+ return string(first) + string(last)
+}
+
+func ja4sIsAlnum(c byte) bool {
+ return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
}
// buildJA4Sc hashes ServerHello extension types in wire order (no sort).
// This is the difference from JA4_c, which sorts.
func buildJA4Sc(exts []uint16) string {
+ if len(exts) == 0 {
+ return "000000000000"
+ }
parts := make([]string, len(exts))
for i, e := range exts {
parts[i] = fmt.Sprintf("%04x", e)
diff --git a/fingerprint/ja4s_test.go b/fingerprint/ja4s_test.go
@@ -139,20 +139,27 @@ func TestParseServerHello_GREASEFiltered(t *testing.T) {
func TestParseServerHello_Errors(t *testing.T) {
cases := []struct {
- name string
- raw []byte
+ name string
+ raw []byte
+ wantErr bool
}{
- {"empty", nil},
- {"too short", []byte{0x16, 0x03}},
- {"wrong content type", []byte{0x17, 0x03, 0x03, 0x00, 0x04, 0x02, 0x00, 0x00, 0x00}},
- {"wrong handshake type (ClientHello)", []byte{0x16, 0x03, 0x03, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00}},
- {"truncated record", []byte{0x16, 0x03, 0x03, 0x00, 0xff, 0x02, 0x00, 0x00, 0x00}},
+ {"empty", nil, true},
+ {"too short", []byte{0x16, 0x03}, true},
+ {"wrong content type", []byte{0x17, 0x03, 0x03, 0x00, 0x04, 0x02, 0x00, 0x00, 0x00}, true},
+ {"wrong handshake type (ClientHello)", []byte{0x16, 0x03, 0x03, 0x00, 0x04, 0x01, 0x00, 0x00, 0x00}, true},
+ {"truncated record", []byte{0x16, 0x03, 0x03, 0x00, 0xff, 0x02, 0x00, 0x00, 0x00}, true},
+ // Positive control: without it, a parser that always errors keeps this table green.
+ {"valid ServerHello", buildServerHello(0x0303, 32, 0x1301, ext(0x002b, []byte{0x03, 0x04})), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
- if _, err := ParseServerHello(tc.raw); err == nil {
+ _, err := ParseServerHello(tc.raw)
+ if tc.wantErr && err == nil {
t.Errorf("expected error, got nil")
}
+ if !tc.wantErr && err != nil {
+ t.Errorf("expected no error, got %v", err)
+ }
})
}
}
@@ -161,8 +168,8 @@ func TestComputeJA4S_Shape(t *testing.T) {
// Synthesize a typical TLS 1.3 ServerHello with [supported_versions, key_share]
// and assert the output shape: t<ver><nn><alpn>_<cipher>_<12hex>
sh := &ServerHelloFields{
- LegacyVersion: 0x0303,
- CipherSuite: 0x1301,
+ LegacyVersion: 0x0303,
+ CipherSuite: 0x1301,
Extensions: []uint16{0x002b, 0x0033},
SupportedVersion: 0x0304,
}
@@ -175,30 +182,67 @@ func TestComputeJA4S_Shape(t *testing.T) {
}
func TestComputeJA4S_KnownFixture(t *testing.T) {
- // Pinned value computed from this exact input. Acts as a regression guard
- // against accidental changes to the hashing/format.
+ // The extension list is DESCENDING on purpose. JA4S hashes wire order and
+ // must not sort; an ascending fixture cannot tell the two apart.
sh := &ServerHelloFields{
- LegacyVersion: 0x0303,
- CipherSuite: 0x1301,
- Extensions: []uint16{0x002b, 0x0033},
+ LegacyVersion: 0x0303,
+ CipherSuite: 0x1301,
+ Extensions: []uint16{0x0033, 0x002b},
SupportedVersion: 0x0304,
}
got := ComputeJA4S(sh)
- const want = "t130200_1301_" // prefix; suffix is the 12-hex hash we pin below
+ const want = "t130200_1301_"
if got[:len(want)] != want {
t.Errorf("JA4S prefix = %q; want prefix %q", got, want)
}
- // Hash of "002b,0033". Pinned: if this changes, the algorithm is wrong.
- const wantHash = "a56c5b993250"
+ // sha256("0033,002b")[:12]. Sorting would give sha256("002b,0033")[:12]
+ // = a56c5b993250 instead, which this pin rejects.
+ const wantHash = "234ea6891581"
if got[len(want):] != wantHash {
t.Errorf("JA4S ext hash = %q; want %q", got[len(want):], wantHash)
}
}
+func TestComputeJA4S_EmptyExtensions(t *testing.T) {
+ // Pre-TLS-1.3 ServerHello with no extensions block: JA4S_c is all zeroes,
+ // not sha256("").
+ sh := &ServerHelloFields{LegacyVersion: 0x0303, CipherSuite: 0xc02f}
+ if got := ComputeJA4S(sh); got != "t120000_c02f_000000000000" {
+ t.Errorf("JA4S = %q; want t120000_c02f_000000000000", got)
+ }
+}
+
+func TestJA4SALPNCode(t *testing.T) {
+ cases := []struct {
+ alpn string
+ want string
+ }{
+ {"", "00"},
+ {"h2", "h2"},
+ {"http/1.1", "h1"},
+ {"h", "hh"},
+ {"h3", "h3"},
+ {"\xab", "ab"},
+ {"\x20", "20"},
+ {"\xab\xcd", "ad"},
+ {"\x20\x61", "21"},
+ {"\x30\xab", "3b"},
+ {"\x61\x20", "60"},
+ {"\x30\x31\xab\xcd", "3d"},
+ // Both end bytes are alphanumeric, so the hex fallback must NOT trigger.
+ {"\x30\xab\xcd\x31", "01"},
+ }
+ for _, tc := range cases {
+ if got := ja4sALPNCode(tc.alpn); got != tc.want {
+ t.Errorf("ja4sALPNCode(%q) = %q; want %q", tc.alpn, got, tc.want)
+ }
+ }
+}
+
func TestComputeJA4S_ALPN_h2(t *testing.T) {
sh := &ServerHelloFields{
- LegacyVersion: 0x0303,
- CipherSuite: 0x1301,
+ LegacyVersion: 0x0303,
+ CipherSuite: 0x1301,
Extensions: []uint16{0x002b, 0x0010, 0x0033},
SupportedVersion: 0x0304,
ALPN: "h2",
| | | |