diff --git a/README.md b/README.md index 3bc82fd..e93f0ab 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,166 @@ -[](https://github.com/exaring/ja4plus/actions/workflows/main.yaml) -[](https://pkg.go.dev/github.com/exaring/ja4plus) -[](https://goreportcard.com/report/github.com/exaring/ja4plus) - # JA4Plus +
+
+
+
+
+
+
-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
}