From 110ebe6fcbc1cf4c1e4f67226610da12a00d1795 Mon Sep 17 00:00:00 2001 From: Justin Timperio Date: Sat, 7 Jun 2025 13:27:15 -0700 Subject: [PATCH 1/5] Add net.listener support, improve http support, improve docs, expanded unit tests --- README.md | 163 +++++++++++++++++++++++++++++++++--- middleware.go | 101 +++++++++++++++++++++++ middleware_test.go | 195 ++++++++++++++++++++++++++++++++++++++++++++ middlewares.go | 67 --------------- middlewares_test.go | 31 ------- 5 files changed, 446 insertions(+), 111 deletions(-) create mode 100644 middleware.go create mode 100644 middleware_test.go delete mode 100644 middlewares.go delete mode 100644 middlewares_test.go diff --git a/README.md b/README.md index 3bc82fd..cecd539 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,162 @@ -[![Tests](https://github.com/exaring/ja4plus/actions/workflows/ci.yml/badge.svg)](https://github.com/exaring/ja4plus/actions/workflows/main.yaml) -[![Docs](https://pkg.go.dev/badge/github.com/exaring/ja4plus.svg)](https://pkg.go.dev/github.com/exaring/ja4plus) -[![Report Card](https://goreportcard.com/badge/github.com/exaring/ja4plus)](https://goreportcard.com/report/github.com/exaring/ja4plus) - # JA4Plus +

+ +

+ +

+ + + +

-ja4plus logo -JA4Plus is a go library for generating [ja4+ fingerprints](https://github.com/FoxIO-LLC/ja4). +JA4Plus is a pure Go library for generating [JA4+ fingerprints](https://github.com/FoxIO-LLC/ja4) on TLS port listeners and https servers. ## Overview -JA4Plus currently offers a single fingerprinting function: -- **JA4**: Fingerprint based on [TLS ClientHello](https://pkg.go.dev/crypto/tls#ClientHelloInfo) information. +JA4Plus aims to provide a simple and reliable way to identify clients based on their TLS characteristics. This is useful for various security applications, including: + +* Bot detection +* Client profiling +* Security monitoring -Contributions are welcome for the other fingerprints in the family 😉 +Currently, JA4Plus offers a single fingerprinting function based on [TLS ClientHello](https://pkg.go.dev/crypto/tls#ClientHelloInfo) information. Contributions are welcome for implementing other fingerprints in the JA4 family! ### Omission of JA4H -The JA4H hash, based on properties of the HTTP request, cannot currently be easily implemented in go, since it requires -headers to be observed in the order sent by the client. See e.g.: https://go.dev/issue/24375 +The JA4H hash, which relies on properties of the HTTP request, cannot currently be easily implemented in Go. This is because Go's standard library does not provide a direct mechanism for observing HTTP headers in the order they are sent by the client. See [this issue](https://go.dev/issue/24375) for more details. + +## Usage + +The core challenge when integrating JA4 fingerprinting is that TLS handshake information is typically not directly accessible within standard HTTP handlers. The `ja4plus` library addresses this limitation with a middleware approach. + +### The Middleware Pattern + +The `JA4Middleware` struct and its associated methods are designed to bridge the gap between the TLS handshake and the HTTP request context. + +1. **TLS Configuration:** The middleware modifies the TLS configuration to intercept the `ClientHello` information. +2. **Fingerprint Storage:** When a `ClientHello` is received, the corresponding JA4 fingerprint is stored in a `sync.Map` associated with the client's remote address. +3. **Context Injection:** The middleware wraps the HTTP handler and injects the JA4 fingerprint into the `http.Request.Context` before the handler is called. + +### Important Considerations + +* **Certificate Management:** Ensure you have valid TLS certificates configured for your server. +* **Connection Cache:** The `JA4Middleware` stores fingerprints in a `sync.Map` indexed by client's remote address. The `HTTPCallback` function should be set on your `http.Server`'s `ConnState` to clean up this cache automatically when connections are closed. The net listener though *must sadly be manually cleaned* up using the `ListenerCallback` to avoid memory leaks in the same way as the http server. + +### Code Example (HTTP Server) + +```go +package main + +import ( + "crypto/tls" + "fmt" + "io" + "log" + "net/http" + + "github.com/exaring/ja4plus" +) + +func main() { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hash, ok := ja4plus.JA4FromContext(r.Context()) + if !ok { + io.WriteString(w, "missing hash") + return + } + io.WriteString(w, hash) + }) + + // Load TLS certificates (replace with your actual certificates) + cert, err := tls.LoadX509KeyPair("server.crt", "server.key") // Example. Consider using a proper configuration. + if err != nil { + log.Fatal(err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + // Create a new JA4Middleware instance + middleware := ja4plus.NewJ4AMiddleware() + + // Create an HTTP server + srv := http.Server{ + Addr: ":9000", + Handler: middleware.NewHandlerWrapper(middleware, tlsConfig, handler), + ConnState: middleware.HTTPCallback, // Clean up connection cache + TLSConfig: middleware.ReturnTLSConfig(), + } + + // Start the server + log.Fatal(srv.ListenAndServeTLS("server.crt", "server.key")) +} +``` + +### Code Example (TLS Listener) + +This demonstrates how to use JA4Plus with a raw TLS listener, rather than an HTTP server. + +```go +package main + +import ( + "crypto/tls" + "fmt" + "io" + "log" + "net" + + "github.com/exaring/ja4plus" +) + +func main() { + // Load TLS certificates (replace with your actual certificates) + cert, err := tls.LoadX509KeyPair("server.crt", "server.key") + if err != nil { + log.Fatal(err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + middleware := ja4plus.NewJ4AMiddleware() + + listener, err := ja4plus.NewListenerWrapper(middleware, tlsConfig, 9001) + if err != nil { + log.Fatal(err) + } + defer listener.Close() + + for { + conn, err := listener.Accept() + if err != nil { + log.Fatal(err) + } + + go handleConnection(conn, middleware) + } +} + +func handleConnection(conn net.Conn, middleware *ja4plus.JA4Middleware) { + defer conn.Close() + + tlsConn, ok := conn.(*tls.Conn) + if !ok { + log.Println("Connection is not TLS") + return + } + + tlsConn.Handshake() + hash, ok := ja4plus.JA4FromListener(middleware, tlsConn) + if !ok { + log.Println("failed to retrieve hash from listener") + return + } -## Examples + fmt.Println(hash) +} +``` -For example usage, checkou out [examples_test.go](./examples_test.go). \ No newline at end of file diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..f7b445b --- /dev/null +++ b/middleware.go @@ -0,0 +1,101 @@ +package ja4plus + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "sync" +) + +// JA4Middleware is a helper to plug the JA4 fingerprinting into your HTTP server. +// It only exists because there is no direct way to pass information from the TLS handshake to the HTTP handler. +type JA4Middleware struct { + connectionFingerprints sync.Map + tlsConfig *tls.Config +} + +type ja4FingerprintCtxKey struct{} + +func NewJ4AMiddleware() *JA4Middleware { + return &JA4Middleware{ + connectionFingerprints: sync.Map{}, + } +} + +// Wrap wraps the provided [http.Handler] and injects the JA4 fingerprint into the [http.Request.Context]. +// It requires a server set up with [JA4Middleware] to work. +func (m *JA4Middleware) NewHandlerWrapper(middleware *JA4Middleware, tlsConfig *tls.Config, next http.Handler) http.Handler { + + tlsConfig.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) { + middleware.storeFingerprintFromClientHello(chi) + return nil, nil + } + middleware.tlsConfig = tlsConfig + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if cacheEntry, _ := m.connectionFingerprints.Load(r.RemoteAddr); cacheEntry != nil { + ctx = context.WithValue(ctx, ja4FingerprintCtxKey{}, cacheEntry.(string)) + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func NewListenerWrapper(middleware *JA4Middleware, tlsConfig *tls.Config, port int) (net.Listener, error) { + tlsConfig.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) { + middleware.storeFingerprintFromClientHello(chi) + return nil, nil + } + + middleware.tlsConfig = tlsConfig + + listen, err := tls.Listen("tcp", fmt.Sprintf(":%d", port), tlsConfig) + if err != nil { + return nil, err + + } + + return listen, nil +} + +// ConnStateCallback is a callback that should be set as the [http.Server]'s ConnState to clean up the fingerprint cache. +func (m *JA4Middleware) HTTPCallback(conn net.Conn, state http.ConnState) { + switch state { + case http.StateClosed, http.StateHijacked: + m.connectionFingerprints.Delete(conn.RemoteAddr().String()) + } +} + +// ConnStateCallback is a callback that should be set as the [http.Server]'s ConnState to clean up the fingerprint cache. +func (m *JA4Middleware) ListenerCallback(conn net.Conn) { + m.connectionFingerprints.Delete(conn.RemoteAddr().String()) +} + +// Returns the modified TLS config +func (m *JA4Middleware) ReturnTLSConfig() *tls.Config { + return m.tlsConfig +} + +func (m *JA4Middleware) storeFingerprintFromClientHello(hello *tls.ClientHelloInfo) { + m.connectionFingerprints.Store(hello.Conn.RemoteAddr().String(), JA4(hello)) +} + +// JA4FromContext extracts the JA4 fingerprint from the provided [http.Request.Context]. +// It requires a server set up with [JA4Middleware] to work. +func JA4FromContext(ctx context.Context) (string, bool) { + fingerprint, ok := ctx.Value(ja4FingerprintCtxKey{}).(string) + return fingerprint, ok +} + +// ConnStateCallback is a callback that should be set as the [http.Server]'s ConnState to clean up the fingerprint cache. +func JA4FromListener(m *JA4Middleware, conn net.Conn) (string, bool) { + fingerprint, ok := m.connectionFingerprints.Load(conn.RemoteAddr().String()) + if !ok { + return "", ok + } + + f, ok := fingerprint.(string) + return f, ok +} diff --git a/middleware_test.go b/middleware_test.go new file mode 100644 index 0000000..4ef2ed0 --- /dev/null +++ b/middleware_test.go @@ -0,0 +1,195 @@ +package ja4plus_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "io" + "math/big" + "net/http" + "os" + "testing" + "time" + + "github.com/exaring/ja4plus" +) + +func TestHTTPMiddleware(t *testing.T) { + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hash, ok := ja4plus.JA4FromContext(r.Context()) + if !ok { + io.WriteString(w, "missing hash") + return + } + io.WriteString(w, hash) + }) + + certBytes, keyBytes, err := generateSelfSignedCert() + if err != nil { + t.Error(err) + } + + err = os.WriteFile("server.crt", certBytes, 0600) + if err != nil { + t.Error(err) + } + err = os.WriteFile("server.key", keyBytes, 0600) + if err != nil { + t.Error(err) + } + defer os.Remove("server.key") + defer os.Remove("server.crt") + + cert, err := tls.LoadX509KeyPair("server.crt", "server.key") + if err != nil { + t.Error(err) + } + certPool := x509.NewCertPool() + certPool.AppendCertsFromPEM(certBytes) + + var supported []uint16 + for _, cs := range tls.CipherSuites() { + supported = append(supported, cs.ID) + } + + config := &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.NoClientCert, // Optional - for mutual TLS, use tls.RequireAndVerifyClientCert + RootCAs: certPool, + CipherSuites: supported, + } + + ja4Middleware := ja4plus.NewJ4AMiddleware() + + srv := http.Server{ + Addr: ":9000", + Handler: ja4Middleware.NewHandlerWrapper(ja4Middleware, config, handler), + ConnState: ja4Middleware.HTTPCallback, + TLSConfig: ja4Middleware.ReturnTLSConfig(), + } + go srv.ListenAndServeTLS("server.crt", "server.key") + + time.Sleep(time.Second * 25) +} + +func TestMiddlewareListener(t *testing.T) { + certBytes, keyBytes, err := generateSelfSignedCert() + if err != nil { + t.Error(err) + } + + err = os.WriteFile("server.crt", certBytes, 0600) + if err != nil { + t.Error(err) + } + err = os.WriteFile("server.key", keyBytes, 0600) + if err != nil { + t.Error(err) + } + defer os.Remove("server.key") + defer os.Remove("server.crt") + + cert, err := tls.LoadX509KeyPair("server.crt", "server.key") + if err != nil { + t.Error(err) + } + certPool := x509.NewCertPool() + certPool.AppendCertsFromPEM(certBytes) + + var supported []uint16 + for _, cs := range tls.CipherSuites() { + supported = append(supported, cs.ID) + } + + config := &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.NoClientCert, // Optional - for mutual TLS, use tls.RequireAndVerifyClientCert + RootCAs: certPool, + CipherSuites: supported, + } + + ja4Middleware := ja4plus.NewJ4AMiddleware() + + listener, err := ja4plus.NewListenerWrapper(ja4Middleware, config, 9001) + if err != nil { + t.Error(err) + } + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + t.Error(err) + } + defer conn.Close() + + tlsConn, ok := conn.(*tls.Conn) + if ok { + tlsConn.Handshake() + fmt.Println(ja4plus.JA4FromListener(ja4Middleware, tlsConn)) + + buffer := make([]byte, 1024) + for { + _, err := tlsConn.Read(buffer) + if err != nil { + ja4Middleware.ListenerCallback(tlsConn) + break + } + } + + } else { + t.Error("Connection is not TLS") + } + } + }() + + time.Sleep(time.Second * 25) +} + +func generateSelfSignedCert() ([]byte, []byte, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + + notBefore := time.Now() + notAfter := notBefore.Add(365 * 24 * time.Hour) + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Tripwire"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return nil, nil, err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyPEM, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, nil, err + } + keyPEMBlock := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyPEM}) + + return certPEM, keyPEMBlock, nil +} diff --git a/middlewares.go b/middlewares.go deleted file mode 100644 index 69c3b1f..0000000 --- a/middlewares.go +++ /dev/null @@ -1,67 +0,0 @@ -package ja4plus - -import ( - "context" - "crypto/tls" - "net" - "net/http" - "sync" -) - -// JA4Middleware is a helper to plug the JA4 fingerprinting into your HTTP server. -// It only exists because there is no direct way to pass information from the TLS handshake to the HTTP handler. -// Usage: -// -// ja4middleware := ja4plus.JA4Middleware{} -// srv := http.Server{ -// Handler: ja4middleware.Wrap(...), -// TLSConfig: &tls.Config{ -// GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) { -// ja4middleware.StoreFingerprintFromClientHello(chi) -// return nil, nil -// }, -// }, -// ConnState: ja4middleware.ConnStateCallback, -// } -// srv.ListenAndServeTLS("cert.pem", "key.pem") -// -// Afterwards the fingerprint can be accessed via [JA4FromContext] -type JA4Middleware struct { - connectionFingerprints sync.Map -} - -// StoreFingerprintFromClientHello stores the JA4 fingerprint of the provided [tls.ClientHelloInfo] in the middleware. -func (m *JA4Middleware) StoreFingerprintFromClientHello(hello *tls.ClientHelloInfo) { - m.connectionFingerprints.Store(hello.Conn.RemoteAddr().String(), JA4(hello)) -} - -// ConnStateCallback is a callback that should be set as the [http.Server]'s ConnState to clean up the fingerprint cache. -func (m *JA4Middleware) ConnStateCallback(conn net.Conn, state http.ConnState) { - switch state { - case http.StateClosed, http.StateHijacked: - m.connectionFingerprints.Delete(conn.RemoteAddr().String()) - } -} - -type ja4FingerprintCtxKey struct{} - -// Wrap wraps the provided [http.Handler] and injects the JA4 fingerprint into the [http.Request.Context]. -// It requires a server set up with [JA4Middleware] to work. -func (m *JA4Middleware) Wrap(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if cacheEntry, _ := m.connectionFingerprints.Load(r.RemoteAddr); cacheEntry != nil { - ctx = context.WithValue(ctx, ja4FingerprintCtxKey{}, cacheEntry.(string)) - } - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -// JA4FromContext extracts the JA4 fingerprint from the provided [http.Request.Context]. -// It requires a server set up with [JA4Middleware] to work. -func JA4FromContext(ctx context.Context) string { - if fingerprint, ok := ctx.Value(ja4FingerprintCtxKey{}).(string); ok { - return fingerprint - } - return "" -} diff --git a/middlewares_test.go b/middlewares_test.go deleted file mode 100644 index b2a9d3d..0000000 --- a/middlewares_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package ja4plus_test - -import ( - "crypto/tls" - "io" - "net/http" - - "github.com/exaring/ja4plus" -) - -func ExampleJA4Middleware() { - exampleHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = io.WriteString(w, ja4plus.JA4FromContext(r.Context())) - }) - - ja4middleware := ja4plus.JA4Middleware{} - - /* srv */ _ = http.Server{ - Addr: ":8080", - Handler: ja4middleware.Wrap(exampleHandler), - TLSConfig: &tls.Config{ - GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) { - ja4middleware.StoreFingerprintFromClientHello(chi) - return nil, nil - }, - }, - ConnState: ja4middleware.ConnStateCallback, - } - - // srv.ListenAndServeTLS(...) -} From 50cad675897853fa822303e40b7a845dc4c9ee77 Mon Sep 17 00:00:00 2001 From: Justin Timperio Date: Sat, 7 Jun 2025 13:48:41 -0700 Subject: [PATCH 2/5] Revert file names --- middleware.go => middlewares.go | 0 middleware_test.go => middlewares_test.go | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename middleware.go => middlewares.go (100%) rename middleware_test.go => middlewares_test.go (100%) diff --git a/middleware.go b/middlewares.go similarity index 100% rename from middleware.go rename to middlewares.go diff --git a/middleware_test.go b/middlewares_test.go similarity index 100% rename from middleware_test.go rename to middlewares_test.go From 6f1f55383ec8bbe7248fb1f8698f6f00790fe2dd Mon Sep 17 00:00:00 2001 From: Justin Timperio Date: Sat, 7 Jun 2025 14:31:10 -0700 Subject: [PATCH 3/5] Doc and api sanity check --- README.md | 2 +- middlewares.go | 19 ++++++++++--------- middlewares_test.go | 4 ++-- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index cecd539..d894c27 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ func main() { middleware := ja4plus.NewJ4AMiddleware() - listener, err := ja4plus.NewListenerWrapper(middleware, tlsConfig, 9001) + listener, err := ja4plus.NewListenerWrapper(middleware, tlsConfig, ":9001") if err != nil { log.Fatal(err) } diff --git a/middlewares.go b/middlewares.go index f7b445b..f7c8d57 100644 --- a/middlewares.go +++ b/middlewares.go @@ -3,7 +3,6 @@ package ja4plus import ( "context" "crypto/tls" - "fmt" "net" "net/http" "sync" @@ -18,14 +17,15 @@ type JA4Middleware struct { type ja4FingerprintCtxKey struct{} +// NewJ4AMiddleware returns a new initialized middleware wrapper func NewJ4AMiddleware() *JA4Middleware { return &JA4Middleware{ connectionFingerprints: sync.Map{}, } } -// Wrap wraps the provided [http.Handler] and injects the JA4 fingerprint into the [http.Request.Context]. -// It requires a server set up with [JA4Middleware] to work. +// NewHandlerWrapper takes a middleware, a tls config, and a http.handler and returns a modified handler that injects +// fingerprints into the context of the a connection so they can be consumed later. func (m *JA4Middleware) NewHandlerWrapper(middleware *JA4Middleware, tlsConfig *tls.Config, next http.Handler) http.Handler { tlsConfig.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) { @@ -43,7 +43,9 @@ func (m *JA4Middleware) NewHandlerWrapper(middleware *JA4Middleware, tlsConfig * }) } -func NewListenerWrapper(middleware *JA4Middleware, tlsConfig *tls.Config, port int) (net.Listener, error) { +// NewListenerWrapper takes a middleware, a tls config and a address and returns a fully wrapped net.Listener. +// You will still need to manually clear fingerprints from memory as connections close with ListenerCallback. +func NewListenerWrapper(middleware *JA4Middleware, tlsConfig *tls.Config, addr string) (net.Listener, error) { tlsConfig.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) { middleware.storeFingerprintFromClientHello(chi) return nil, nil @@ -51,7 +53,7 @@ func NewListenerWrapper(middleware *JA4Middleware, tlsConfig *tls.Config, port i middleware.tlsConfig = tlsConfig - listen, err := tls.Listen("tcp", fmt.Sprintf(":%d", port), tlsConfig) + listen, err := tls.Listen("tcp", addr, tlsConfig) if err != nil { return nil, err @@ -60,7 +62,7 @@ func NewListenerWrapper(middleware *JA4Middleware, tlsConfig *tls.Config, port i return listen, nil } -// ConnStateCallback is a callback that should be set as the [http.Server]'s ConnState to clean up the fingerprint cache. +// HTTPCallback is a callback that should be set as the [http.Server]'s ConnState to clean up the fingerprint cache. func (m *JA4Middleware) HTTPCallback(conn net.Conn, state http.ConnState) { switch state { case http.StateClosed, http.StateHijacked: @@ -68,7 +70,7 @@ func (m *JA4Middleware) HTTPCallback(conn net.Conn, state http.ConnState) { } } -// ConnStateCallback is a callback that should be set as the [http.Server]'s ConnState to clean up the fingerprint cache. +// Listener is a manually called deletion method for clearing fingerprint state after a connection is closed func (m *JA4Middleware) ListenerCallback(conn net.Conn) { m.connectionFingerprints.Delete(conn.RemoteAddr().String()) } @@ -83,13 +85,12 @@ func (m *JA4Middleware) storeFingerprintFromClientHello(hello *tls.ClientHelloIn } // JA4FromContext extracts the JA4 fingerprint from the provided [http.Request.Context]. -// It requires a server set up with [JA4Middleware] to work. func JA4FromContext(ctx context.Context) (string, bool) { fingerprint, ok := ctx.Value(ja4FingerprintCtxKey{}).(string) return fingerprint, ok } -// ConnStateCallback is a callback that should be set as the [http.Server]'s ConnState to clean up the fingerprint cache. +// JA4FromContext extracts the JA4 fingerprint from the provided middleware and current net connection. func JA4FromListener(m *JA4Middleware, conn net.Conn) (string, bool) { fingerprint, ok := m.connectionFingerprints.Load(conn.RemoteAddr().String()) if !ok { diff --git a/middlewares_test.go b/middlewares_test.go index 4ef2ed0..0b843c2 100644 --- a/middlewares_test.go +++ b/middlewares_test.go @@ -116,7 +116,7 @@ func TestMiddlewareListener(t *testing.T) { ja4Middleware := ja4plus.NewJ4AMiddleware() - listener, err := ja4plus.NewListenerWrapper(ja4Middleware, config, 9001) + listener, err := ja4plus.NewListenerWrapper(ja4Middleware, config, ":9001") if err != nil { t.Error(err) } @@ -169,7 +169,7 @@ func generateSelfSignedCert() ([]byte, []byte, error) { template := x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{ - Organization: []string{"Tripwire"}, + Organization: []string{"ja4plus-test-cert"}, }, NotBefore: notBefore, NotAfter: notAfter, From 7cdb9ff7989efaacf70da1851f3b8356b9480dcd Mon Sep 17 00:00:00 2001 From: Justin Timperio Date: Sun, 8 Jun 2025 11:03:39 -0700 Subject: [PATCH 4/5] Prevent panics when tls hello is nil, adjust docs, and api ergonomics --- README.md | 22 +++++++++++++--------- middlewares.go | 28 +++++++++++++++++----------- middlewares_test.go | 15 ++++++++------- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d894c27..e93f0ab 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The `JA4Middleware` struct and its associated methods are designed to bridge the * **Certificate Management:** Ensure you have valid TLS certificates configured for your server. * **Connection Cache:** The `JA4Middleware` stores fingerprints in a `sync.Map` indexed by client's remote address. The `HTTPCallback` function should be set on your `http.Server`'s `ConnState` to clean up this cache automatically when connections are closed. The net listener though *must sadly be manually cleaned* up using the `ListenerCallback` to avoid memory leaks in the same way as the http server. -### Code Example (HTTP Server) +### HTTP Server Example ```go package main @@ -60,6 +60,7 @@ import ( func main() { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Echo the hash back to the client hash, ok := ja4plus.JA4FromContext(r.Context()) if !ok { io.WriteString(w, "missing hash") @@ -84,9 +85,9 @@ func main() { // Create an HTTP server srv := http.Server{ Addr: ":9000", - Handler: middleware.NewHandlerWrapper(middleware, tlsConfig, handler), + Handler: middleware.NewHandlerWrapper(middleware, tlsConfig, handler), // Generate a new http wrapper ConnState: middleware.HTTPCallback, // Clean up connection cache - TLSConfig: middleware.ReturnTLSConfig(), + TLSConfig: middleware.ReturnTLSConfig(), // Use the wrapped tls config } // Start the server @@ -94,9 +95,9 @@ func main() { } ``` -### Code Example (TLS Listener) +### TLS Listener Example -This demonstrates how to use JA4Plus with a raw TLS listener, rather than an HTTP server. +This demonstrates how to use JA4Plus with a raw TLS listener. ```go package main @@ -150,13 +151,16 @@ func handleConnection(conn net.Conn, middleware *ja4plus.JA4Middleware) { } tlsConn.Handshake() - hash, ok := ja4plus.JA4FromListener(middleware, tlsConn) - if !ok { - log.Println("failed to retrieve hash from listener") + hash, ok := middleware.JA4FromConn(tlsConn) + if ok { + // Manually clean fingerprint from the middleware + middleware.ListenerCallback(tlsConn) + log.Println("Got fingerprint:", hash) return } - fmt.Println(hash) + log.Println("failed to retrieve hash from listener") } + ``` diff --git a/middlewares.go b/middlewares.go index f7c8d57..017f95a 100644 --- a/middlewares.go +++ b/middlewares.go @@ -3,6 +3,7 @@ package ja4plus import ( "context" "crypto/tls" + "fmt" "net" "net/http" "sync" @@ -29,8 +30,12 @@ func NewJ4AMiddleware() *JA4Middleware { func (m *JA4Middleware) NewHandlerWrapper(middleware *JA4Middleware, tlsConfig *tls.Config, next http.Handler) http.Handler { tlsConfig.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) { - middleware.storeFingerprintFromClientHello(chi) - return nil, nil + // Protects against panics when generating the JA4 + if chi != nil { + m.connectionFingerprints.Store(chi.Conn.RemoteAddr().String(), JA4(chi)) + return nil, nil + } + return nil, fmt.Errorf("Failed to extract client tls hello") } middleware.tlsConfig = tlsConfig @@ -46,9 +51,14 @@ func (m *JA4Middleware) NewHandlerWrapper(middleware *JA4Middleware, tlsConfig * // NewListenerWrapper takes a middleware, a tls config and a address and returns a fully wrapped net.Listener. // You will still need to manually clear fingerprints from memory as connections close with ListenerCallback. func NewListenerWrapper(middleware *JA4Middleware, tlsConfig *tls.Config, addr string) (net.Listener, error) { + tlsConfig.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) { - middleware.storeFingerprintFromClientHello(chi) - return nil, nil + // Protects against panics when generating the JA4 + if chi != nil { + middleware.connectionFingerprints.Store(chi.Conn.RemoteAddr().String(), JA4(chi)) + return nil, nil + } + return nil, fmt.Errorf("Failed to extract client tls hello") } middleware.tlsConfig = tlsConfig @@ -70,7 +80,7 @@ func (m *JA4Middleware) HTTPCallback(conn net.Conn, state http.ConnState) { } } -// Listener is a manually called deletion method for clearing fingerprint state after a connection is closed +// ListenerCallback is a manually called deletion method for clearing fingerprint state after a connection is closed func (m *JA4Middleware) ListenerCallback(conn net.Conn) { m.connectionFingerprints.Delete(conn.RemoteAddr().String()) } @@ -80,18 +90,14 @@ func (m *JA4Middleware) ReturnTLSConfig() *tls.Config { return m.tlsConfig } -func (m *JA4Middleware) storeFingerprintFromClientHello(hello *tls.ClientHelloInfo) { - m.connectionFingerprints.Store(hello.Conn.RemoteAddr().String(), JA4(hello)) -} - // JA4FromContext extracts the JA4 fingerprint from the provided [http.Request.Context]. func JA4FromContext(ctx context.Context) (string, bool) { fingerprint, ok := ctx.Value(ja4FingerprintCtxKey{}).(string) return fingerprint, ok } -// JA4FromContext extracts the JA4 fingerprint from the provided middleware and current net connection. -func JA4FromListener(m *JA4Middleware, conn net.Conn) (string, bool) { +// JA4FromContext extracts the JA4 fingerprint from the middleware using the a connection. +func (m *JA4Middleware) JA4FromConn(conn net.Conn) (string, bool) { fingerprint, ok := m.connectionFingerprints.Load(conn.RemoteAddr().String()) if !ok { return "", ok diff --git a/middlewares_test.go b/middlewares_test.go index 0b843c2..07aac52 100644 --- a/middlewares_test.go +++ b/middlewares_test.go @@ -114,9 +114,9 @@ func TestMiddlewareListener(t *testing.T) { CipherSuites: supported, } - ja4Middleware := ja4plus.NewJ4AMiddleware() + middleware := ja4plus.NewJ4AMiddleware() - listener, err := ja4plus.NewListenerWrapper(ja4Middleware, config, ":9001") + listener, err := ja4plus.NewListenerWrapper(middleware, config, ":9001") if err != nil { t.Error(err) } @@ -132,24 +132,25 @@ func TestMiddlewareListener(t *testing.T) { tlsConn, ok := conn.(*tls.Conn) if ok { tlsConn.Handshake() - fmt.Println(ja4plus.JA4FromListener(ja4Middleware, tlsConn)) + fmt.Println(middleware.JA4FromConn(tlsConn)) buffer := make([]byte, 1024) for { _, err := tlsConn.Read(buffer) if err != nil { - ja4Middleware.ListenerCallback(tlsConn) + middleware.ListenerCallback(tlsConn) break } } - } else { - t.Error("Connection is not TLS") + return } + + t.Error("Connection is not TLS") } }() - time.Sleep(time.Second * 25) + time.Sleep(time.Second * 30) } func generateSelfSignedCert() ([]byte, []byte, error) { From 2b47da23690aba05013d17528454230b32de3337 Mon Sep 17 00:00:00 2001 From: Justin Timperio Date: Sun, 8 Jun 2025 12:56:51 -0700 Subject: [PATCH 5/5] Fix panic when supported versions is empty --- ja4plus.go | 46 ++++++++++++++++++++++++--------------------- middlewares_test.go | 2 +- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/ja4plus.go b/ja4plus.go index d362d59..c68d66e 100644 --- a/ja4plus.go +++ b/ja4plus.go @@ -36,27 +36,31 @@ func JA4(hello *tls.ClientHelloInfo) string { } // Extract TLS version - supporetdVersions := slices.Sorted(slices.Values(hello.SupportedVersions)) - switch supporetdVersions[len(supporetdVersions)-1] { - case tls.VersionTLS10: - out = append(out, '1', '0') - case tls.VersionTLS11: - out = append(out, '1', '1') - case tls.VersionTLS12: - out = append(out, '1', '2') - case tls.VersionTLS13: - out = append(out, '1', '3') - case tls.VersionSSL30: // deprecated, but still seen in the wild - out = append(out, 's', '3') - case 0x0002: // unsupported by go; still seen in the wild - out = append(out, 's', '2') - case 0xfeff: // DTLS 1.0 - out = append(out, 'd', '1') - case 0xfefd: // DTLS 1.2 - out = append(out, 'd', '2') - case 0xfefc: // DTLS 1.3 - out = append(out, 'd', '3') - default: + supportedVersions := slices.Sorted(slices.Values(hello.SupportedVersions)) + if len(supportedVersions) > 0 { + switch supportedVersions[len(supportedVersions)-1] { + case tls.VersionTLS10: + out = append(out, '1', '0') + case tls.VersionTLS11: + out = append(out, '1', '1') + case tls.VersionTLS12: + out = append(out, '1', '2') + case tls.VersionTLS13: + out = append(out, '1', '3') + case tls.VersionSSL30: // deprecated, but still seen in the wild + out = append(out, 's', '3') + case 0x0002: // unsupported by go; still seen in the wild + out = append(out, 's', '2') + case 0xfeff: // DTLS 1.0 + out = append(out, 'd', '1') + case 0xfefd: // DTLS 1.2 + out = append(out, 'd', '2') + case 0xfefc: // DTLS 1.3 + out = append(out, 'd', '3') + default: + out = append(out, '0', '0') + } + } else { out = append(out, '0', '0') } diff --git a/middlewares_test.go b/middlewares_test.go index 07aac52..062882d 100644 --- a/middlewares_test.go +++ b/middlewares_test.go @@ -75,7 +75,7 @@ func TestHTTPMiddleware(t *testing.T) { } go srv.ListenAndServeTLS("server.crt", "server.key") - time.Sleep(time.Second * 25) + time.Sleep(time.Second * 30) } func TestMiddlewareListener(t *testing.T) {