diff --git a/README.md b/README.md index 3bc82fd..e93f0ab 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,166 @@ -[![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. + +### HTTP Server Example + +```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) { + // Echo the hash back to the client + 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), // Generate a new http wrapper + ConnState: middleware.HTTPCallback, // Clean up connection cache + TLSConfig: middleware.ReturnTLSConfig(), // Use the wrapped tls config + } + + // Start the server + log.Fatal(srv.ListenAndServeTLS("server.crt", "server.key")) +} +``` + +### TLS Listener Example + +This demonstrates how to use JA4Plus with a raw TLS listener. + +```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 := middleware.JA4FromConn(tlsConn) + if ok { + // Manually clean fingerprint from the middleware + middleware.ListenerCallback(tlsConn) + log.Println("Got fingerprint:", hash) + return + } + + log.Println("failed to retrieve hash from listener") +} -## Examples +``` -For example usage, checkou out [examples_test.go](./examples_test.go). \ No newline at end of file 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.go b/middlewares.go index 69c3b1f..017f95a 100644 --- a/middlewares.go +++ b/middlewares.go @@ -3,6 +3,7 @@ package ja4plus import ( "context" "crypto/tls" + "fmt" "net" "net/http" "sync" @@ -10,44 +11,34 @@ import ( // 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 + tlsConfig *tls.Config } -// 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)) -} +type ja4FingerprintCtxKey struct{} -// 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()) +// NewJ4AMiddleware returns a new initialized middleware wrapper +func NewJ4AMiddleware() *JA4Middleware { + return &JA4Middleware{ + connectionFingerprints: sync.Map{}, } } -type ja4FingerprintCtxKey struct{} +// 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) { + // 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 -// 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 { @@ -57,11 +48,61 @@ func (m *JA4Middleware) Wrap(next http.Handler) http.Handler { }) } +// 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) { + // 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 + + listen, err := tls.Listen("tcp", addr, tlsConfig) + if err != nil { + return nil, err + + } + + return listen, nil +} + +// 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: + m.connectionFingerprints.Delete(conn.RemoteAddr().String()) + } +} + +// 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()) +} + +// Returns the modified TLS config +func (m *JA4Middleware) ReturnTLSConfig() *tls.Config { + return m.tlsConfig +} + // 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 +func JA4FromContext(ctx context.Context) (string, bool) { + fingerprint, ok := ctx.Value(ja4FingerprintCtxKey{}).(string) + return fingerprint, ok +} + +// 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 } - return "" + + f, ok := fingerprint.(string) + return f, ok } diff --git a/middlewares_test.go b/middlewares_test.go index b2a9d3d..062882d 100644 --- a/middlewares_test.go +++ b/middlewares_test.go @@ -1,31 +1,196 @@ 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 ExampleJA4Middleware() { - exampleHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = io.WriteString(w, ja4plus.JA4FromContext(r.Context())) +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) }) - 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 - }, + 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 * 30) +} + +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, + } + + middleware := ja4plus.NewJ4AMiddleware() + + listener, err := ja4plus.NewListenerWrapper(middleware, 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(middleware.JA4FromConn(tlsConn)) + + buffer := make([]byte, 1024) + for { + _, err := tlsConn.Read(buffer) + if err != nil { + middleware.ListenerCallback(tlsConn) + break + } + } + + return + } + + t.Error("Connection is not TLS") + } + }() + + time.Sleep(time.Second * 30) +} + +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{"ja4plus-test-cert"}, }, - ConnState: ja4middleware.ConnStateCallback, + 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}) - // srv.ListenAndServeTLS(...) + return certPEM, keyPEMBlock, nil }