pkg/ja4/ja4.go filters GREASE out of the TLS version, cipher suites and extension list, but unmarshalSignatureAlgorithm copies the signature-algorithm list verbatim:
// pkg/ja4/ja4.go:253-263
func (j *JA4Fingerprint) unmarshalSignatureAlgorithm(chs *utls.ClientHelloSpec) {
var algo []uint16
for _, e := range chs.Extensions {
if sae, ok := e.(*utls.SignatureAlgorithmsExtension); ok {
for _, a := range sae.SupportedSignatureAlgorithms {
algo = append(algo, uint16(a)) // <- no isGREASEUint16 filter
}
}
}
j.SignatureAlgorithms = algo
}
Compare unmarshalTLSVersion (:116), unmarshalCipherSuites (:142, :189) and unmarshalExtensions (:152, :207), which all filter. It looks like a plain oversight rather than a design choice.
The JA4 specification says the implementation "needs to ignore GREASE values anywhere it sees them", and FoxIO's reference implementation filters the sigalg list (python/common.py:213).
Why it matters now. Chrome began emitting a GREASE value inside signature_algorithms at M152 (kTlsGreaseSigalgs, gating BoringSSL's ssl_get_grease_value(hs, ssl_grease_signature_algorithm)). The value is redrawn on every handshake, so from M152 onward fingerproxy emits a different JA4 for every connection from the same browser. SignatureAlgorithms feeds only the JA4_c hash (fmt.Sprintf("%s_%s", j.Extensions, j.SignatureAlgorithms), :99), so JA4_a and JA4_b are unaffected — which makes it easy to miss.
Measured over 8 cold ClientHellos from Chrome M152–M155: 8 distinct JA4_c values. Recomputed with GREASE stripped from sigalgs and nothing else changed, all 8 collapse to one, t13d1517h2_8daaf6152771_cb7bf5808d99.
The fix is one filter:
j.SignatureAlgorithms = filterGREASEUint16(algo)
(matching whatever helper shape suits — isGREASEUint16 already exists in the package.)
Happy to send a PR with a regression test if that is useful.
pkg/ja4/ja4.gofilters GREASE out of the TLS version, cipher suites and extension list, butunmarshalSignatureAlgorithmcopies the signature-algorithm list verbatim:Compare
unmarshalTLSVersion(:116),unmarshalCipherSuites(:142, :189) andunmarshalExtensions(:152, :207), which all filter. It looks like a plain oversight rather than a design choice.The JA4 specification says the implementation "needs to ignore GREASE values anywhere it sees them", and FoxIO's reference implementation filters the sigalg list (
python/common.py:213).Why it matters now. Chrome began emitting a GREASE value inside
signature_algorithmsat M152 (kTlsGreaseSigalgs, gating BoringSSL'sssl_get_grease_value(hs, ssl_grease_signature_algorithm)). The value is redrawn on every handshake, so from M152 onward fingerproxy emits a different JA4 for every connection from the same browser.SignatureAlgorithmsfeeds only the JA4_c hash (fmt.Sprintf("%s_%s", j.Extensions, j.SignatureAlgorithms), :99), so JA4_a and JA4_b are unaffected — which makes it easy to miss.Measured over 8 cold ClientHellos from Chrome M152–M155: 8 distinct JA4_c values. Recomputed with GREASE stripped from sigalgs and nothing else changed, all 8 collapse to one,
t13d1517h2_8daaf6152771_cb7bf5808d99.The fix is one filter:
(matching whatever helper shape suits —
isGREASEUint16already exists in the package.)Happy to send a PR with a regression test if that is useful.