Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions ja4plus.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,28 @@ 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
}
}
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, '_')
Expand Down Expand Up @@ -192,6 +203,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))
}
Expand Down
20 changes: 16 additions & 4 deletions ja4plus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,25 @@ func TestJA4(t *testing.T) {
expected: "t13i0202h1_62ed6f6ca7ad_5b56ea7744b1",
},
{
// the TLS stack should not allow this, but we ensure we're defensive.
name: "Do not panic on invalid proto version",
// 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{"a"}, // invalid!
SupportedProtos: []string{string([]byte{0x02, 0xf3})},
},
expected: "t13i000000_000000000000_000000000000",
expected: "t13i000003_000000000000_000000000000",
},
{
// 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"},
},
expected: "t13i0000aa_000000000000_000000000000",
},
}

Expand Down
Loading