From 6a52c4a33c8286f939e405b3cb86713aab758b3c Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Mon, 6 Jul 2026 21:43:51 +0200 Subject: [PATCH 1/2] fix: hex-encode non-alphanumeric ALPN chars in JA4_a Per the JA4 spec, when the first or last byte of the first ALPN value is not ASCII alphanumeric, JA4_a uses the first and last characters of the value's hex representation instead of the raw bytes. Co-Authored-By: Claude Opus 4.8 --- ja4plus.go | 18 +++++++++++++++--- ja4plus_test.go | 11 +++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/ja4plus.go b/ja4plus.go index aedc1c9..c82e7e2 100644 --- a/ja4plus.go +++ b/ja4plus.go @@ -112,10 +112,17 @@ func JA4(hello *tls.ClientHelloInfo) string { break } } - if firstALPN != "" { - out = append(out, firstALPN[0], firstALPN[len(firstALPN)-1]) - } else { + if firstALPN == "" { out = append(out, '0', '0') + } else if first, last := firstALPN[0], firstALPN[len(firstALPN)-1]; isASCIIAlphanumeric(first) && isASCIIAlphanumeric(last) { + out = append(out, first, last) + } else { + // If the first or last byte is not ASCII alphanumeric, the JA4 spec uses + // the first and last characters of the hex representation of the whole + // value. Those are the high nibble of the first byte and the low nibble + // of the last byte, so we can emit them without hex-encoding the rest. + const hexdigits = "0123456789abcdef" + out = append(out, hexdigits[first>>4], hexdigits[last&0x0F]) } out = append(out, '_') @@ -192,6 +199,11 @@ func extensionHash(filteredExtensions []uint16, signatureSchemes []tls.Signature return truncated } +// isASCIIAlphanumeric reports whether b is 0-9, A-Z, or a-z. +func isASCIIAlphanumeric(b byte) bool { + return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') +} + func appendTwoDigits(dst []byte, v int) []byte { return append(dst, byte('0'+v/10), byte('0'+v%10)) } diff --git a/ja4plus_test.go b/ja4plus_test.go index cb399a8..de9b961 100644 --- a/ja4plus_test.go +++ b/ja4plus_test.go @@ -87,6 +87,17 @@ func TestJA4(t *testing.T) { }, expected: "t13i0202h1_62ed6f6ca7ad_5b56ea7744b1", }, + { + // Per the JA4 spec, if the first or last byte of the first ALPN is + // not ASCII alphanumeric, the first and last chars of the hex + // representation of the value are used instead. hex("02f3") -> "03". + name: "ClientHelloInfo with non-alphanumeric ALPN", + hello: &tls.ClientHelloInfo{ + SupportedVersions: []uint16{tls.VersionTLS13}, + SupportedProtos: []string{string([]byte{0x02, 0xf3})}, + }, + expected: "t13i000003_000000000000_000000000000", + }, { // the TLS stack should not allow this, but we ensure we're defensive. name: "Do not panic on invalid proto version", From ba67e6296ce78b8ce7579f2644fdd7e0707fb858 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Mon, 6 Jul 2026 21:44:33 +0200 Subject: [PATCH 2/2] fix: accept single-byte ALPN values in JA4_a A 1-byte ALPN protocol cannot be a GREASE value, but the previous grease check discarded any proto shorter than 2 bytes, falling back to "00". Such protocols are now used, matching the JA4 spec. Co-Authored-By: Claude Opus 4.8 --- ja4plus.go | 8 ++++++-- ja4plus_test.go | 9 +++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/ja4plus.go b/ja4plus.go index c82e7e2..bce591b 100644 --- a/ja4plus.go +++ b/ja4plus.go @@ -105,9 +105,13 @@ func JA4(hello *tls.ClientHelloInfo) string { // Extract first ALPN value var firstALPN string for _, proto := range hello.SupportedProtos { - // Protocols are tecnically strings, but grease values are 2-byte non-printable, so we convert. + // Protocols are technically strings, but grease values are 2-byte non-printable, so we convert. + // A 1-byte protocol cannot be a GREASE value, so it is always valid. // see: https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids - if len(proto) >= 2 && !greaseFilter(binary.BigEndian.Uint16([]byte(proto[:2]))) { + if len(proto) >= 2 && greaseFilter(binary.BigEndian.Uint16([]byte(proto[:2]))) { + continue + } + if proto != "" { firstALPN = proto break } diff --git a/ja4plus_test.go b/ja4plus_test.go index de9b961..4ce0107 100644 --- a/ja4plus_test.go +++ b/ja4plus_test.go @@ -99,13 +99,14 @@ func TestJA4(t *testing.T) { expected: "t13i000003_000000000000_000000000000", }, { - // the TLS stack should not allow this, but we ensure we're defensive. - name: "Do not panic on invalid proto version", + // A 1-byte ALPN cannot be GREASE, so it is used; per the JA4 spec a + // single character is used as both the first and last character. + name: "ClientHelloInfo with single-character ALPN", hello: &tls.ClientHelloInfo{ SupportedVersions: []uint16{tls.VersionTLS13}, - SupportedProtos: []string{"a"}, // invalid! + SupportedProtos: []string{"a"}, }, - expected: "t13i000000_000000000000_000000000000", + expected: "t13i0000aa_000000000000_000000000000", }, }