ja4plus

package module
v1.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 16, 2026 License: BSD-3-Clause Imports: 25 Imported by: 0

README

ja4plus-go is a Go library and a command-line program for JA4+ network fingerprinting. It implements eleven JA4+ methods, and ten fingerprinters carry them. It reads TLS, TCP, HTTP, SSH, X.509 and DHCP characteristics, and it decodes a QUIC Initial packet.

JA4+ is a set of network fingerprinting standards that FoxIO publishes. This library is an independent Go implementation. The FoxIO JA4+ repository holds the original specification.

CI Go Reference License

Documentation: https://crank-git.github.io/ja4plus-go/ — the usage guide, the output schema, the concurrency contract, the packet-throughput measurement and the licensing terms.

Supported Fingerprint Types

Type Protocol Description
JA4 TLS/QUIC Client fingerprint from ClientHello messages
JA4S TLS/QUIC Server fingerprint from ServerHello messages
JA4H HTTP Client fingerprint from request headers and cookies
JA4T TCP Client OS fingerprint from SYN packets
JA4TS TCP Server fingerprint from SYN-ACK packets
JA4L TCP/QUIC Light distance and latency estimation
JA4LS TCP/QUIC Server light distance and latency estimation
JA4X X.509 Certificate structure fingerprint from OID sequences
JA4SSH SSH Session type classification from traffic patterns
JA4D DHCPv4 Per-packet DHCPv4 fingerprint (FoxIO PR #267/#270)
JA4D6 DHCPv6 Per-packet DHCPv6 fingerprint

The table above holds eleven rows, and ten fingerprinters carry those methods. JA4LFingerprinter writes JA4L and it writes JA4LS, so one fingerprinter carries two of the rows. Read the ten as a count of fingerprinters, and never as a count of methods.

The library decrypts a QUIC Initial packet (RFC 9001 and RFC 9369) and it reads the TLS ClientHello inside.

The library also decrypts a protected TLS 1.3 record on TCP, where a key log supplies the secret. JA4X reads the Certificate message of that record, and JA4H reads an HTTP/2 request from it. The library reads no HTTP/3 request, because a QPACK header block travels inside a protected QUIC packet that the library does not decode.

Installation

The module requires Go 1.25 or later. That sentence states a language version, and the or later names the toolchain that compiles the module. It names no toolchain that builds a binary free of a called vulnerability, and the section below names that one.

go get github.com/Crank-Git/ja4plus-go@latest

v1.0.0 freezes the exported API. docs/api/v1.md records every exported name with its signature, and a test fails when the surface and that record differ.

The command states @latest, and it states no version. @latest resolves to the newest published tag, so the command works before the v1.0.0 tag exists and after it lands. A command that names an unpublished tag fails, and the module proxy holds no v1.0.0 tag on 2026-08-15 UTC. docs/index.md and docs/usage.md state the same form.

The language version and the build toolchain

These two statements answer two different questions, and this section states both.

  • A language version decides which consumer compiles the module.
  • A build toolchain decides which standard library a built binary links.

The language version is 1.25. go.mod declares go 1.25.0, and a language version is not a toolchain. So a consumer on a Go 1.25 toolchain compiles this module.

The maintainer moved the version from 1.24 to 1.25 on 2026-08-15, and #725 holds the ruling. github.com/gopacket/gopacket v1.7.1 declares go 1.25.0 in its own go.mod, and Go requires the main module to declare a language version at or above every dependency. v1.7.1 repairs a decoder panic on untrusted input, under GHSA-6h9g-cjv3-pg2c. No patch release of the 1.6 line carries that repair, so the choice was binary. A Go 1.24 consumer no longer compiles this module, and that cost is the accepted one. The ruling lands in v1.1.0, because a minimum language version raise is a minor version change.

The minimum build toolchain is go1.25.13. A user who builds a binary from this source takes go1.25.13 or a later toolchain, and the measurement below states why. go1.25.13 is the oldest toolchain this project measured at zero called vulnerabilities, and no claim here covers a patch that the table does not name.

govulncheck reads the standard library of the go command on the PATH, so the toolchain decides the result. govulncheck v1.6.0 reported this on 2026-08-14, against the vulnerability database that https://vuln.go.dev published at 2026-08-13 21:43:54 UTC:

Toolchain Called standard library vulnerabilities Exit status
go1.24.13 13 3
go1.25.13 0 0
go1.26.5 4 3
go1.26.6 0 0

A later toolchain is not a clean toolchain by itself. go1.26.5 is later than go1.25.13, and it carries four. So a user on the Go 1.26 line takes go1.26.6 or later.

No Go 1.24 patch clears the 13. go1.24.13 is the newest Go 1.24 patch, and each advisory names a fix in a go1.25.x release or a later one.

The go1.24.13 row is a record of the 2026-08-14 measurement, and it names no toolchain a builder may use today. The language version moved to 1.25 on 2026-08-15, so a Go 1.24 toolchain compiles no source of this module.

Every count above moves without a change to this repository, because the vulnerability database is live. Run make vuln to re-take the measurement on your own toolchain.

This project builds and releases on the range ~1.26.6. .github/workflows/ci.yml names that range for every job, and .github/workflows/release.yml names it for every released binary.

CLI

Pre-built binaries are available on the Releases page. Or build from source:

go install github.com/Crank-Git/ja4plus-go/cmd/ja4plus@latest
# Analyze a PCAP file
ja4plus analyze capture.pcap

# JSON output for SIEM ingestion
ja4plus analyze capture.pcap --json

# Only specific fingerprint types
ja4plus analyze capture.pcap --types ja4,ja4t

# CSV output
ja4plus analyze capture.pcap --csv

# Include fingerprint identification
ja4plus analyze capture.pcap --lookup

# Watch one live interface
ja4plus watch --interface eth0

# The analyze options hold the same meaning on the monitor
ja4plus watch --interface eth0 --json --types ja4,ja4t --lookup

# One statistics line every 10 seconds, on standard error
ja4plus watch --interface eth0 --stats-interval 10

# Fingerprint a certificate
ja4plus cert server.der
ja4plus cert server.pem

# Update / inspect the local lookup database
ja4plus db update
ja4plus db info

One run, and the output it writes

make corpus fetches the FoxIO corpus, and the capture below comes from it. Run the command from the repository root:

ja4plus analyze testdata/foxio/pcap/tls12.pcap

The program writes these two lines:

Type  Source                 Destination         Fingerprint
ja4   192.168.133.129:36372  34.117.237.239:443  t13d1715h2_5b57614c22b0_3d5424432f57

TestTheReadmeCommandLineOutputMatchesTheProgram in readme_code_blocks_test.go runs that command and it compares the output to the block above. The test skips when the worktree holds no corpus.

The capture filter of ja4plus watch

A capture filter needs the libpcap build tag. The default build holds no cgo, and it reaches no compiler of a filter expression. So the default build declines --bpf, and it names the build command:

go build -tags libpcap ./cmd/ja4plus
ja4plus watch --interface eth0 --bpf "tcp port 443"

The maintainer ruled this on 2026-08-14, and issue #564 is the reversal path. The monitor reads every packet of the interface without the tag, and --types filters the methods on any build. docs/specs/features/13-live-capture.md FR-capture-15 states the ruling.

The same tag reaches the monitor on macOS, because the pure-Go capture handle builds on Linux alone. The monitor reads no interface on Windows, and ja4plus analyze reads a capture file on every platform.

Go API

Quick Start

package main

import (
    "fmt"
    "os"

    ja4plus "github.com/Crank-Git/ja4plus-go"
    "github.com/gopacket/gopacket"
    "github.com/gopacket/gopacket/pcapgo"
)

func main() {
    f, _ := os.Open("capture.pcap")
    defer f.Close()

    reader, _ := pcapgo.NewReader(f)
    proc := ja4plus.NewProcessor()

    for {
        data, ci, err := reader.ReadPacketData()
        if err != nil {
            break
        }
        pkt := gopacket.NewPacket(data, reader.LinkType(), gopacket.Default)
        pkt.Metadata().Timestamp = ci.Timestamp

        results, _ := proc.ProcessPacket(pkt)
        for _, r := range results {
            fmt.Printf("[%s] %s:%d -> %s:%d  %s\n",
                r.Type, r.SrcIP, r.SrcPort, r.DstIP, r.DstPort, r.Fingerprint)
        }
    }
}

Individual Fingerprinters

ja4  := ja4plus.NewJA4()
ja4s := ja4plus.NewJA4S()
ja4h := ja4plus.NewJA4H()
ja4t := ja4plus.NewJA4T()
ja4ts := ja4plus.NewJA4TS()
ja4l := ja4plus.NewJA4L()
ja4x := ja4plus.NewJA4X()
ja4ssh := ja4plus.NewJA4SSH(0) // 0 = default 200-packet window
ja4d := ja4plus.NewJA4D()
ja4d6 := ja4plus.NewJA4D6()

All fingerprinters share a common interface:

Method Description
ProcessPacket(pkt) Process a packet, returns []FingerprintResult or nil
Reset() Clears all collected state
CleanupConnection(srcIP, srcPort, dstIP, dstPort, proto) Removes the state that the named connection holds

JA4SSHFingerprinter also implements WindowCloser and ConnectionWindowCloser. Each interface carries one method, so a type that implements one of them reaches that method's dispatch and never loses the other. The maintainer ruled the split on 2026-08-12, and issue #268 records the ruling.

Interface Method Description
WindowCloser CloseOpenWindows() Emits the window each connection holds open, and returns the results
ConnectionWindowCloser CloseConnectionWindow(srcIP, srcPort, dstIP, dstPort, proto) Emits the window one connection holds open, removes that connection, and returns the results

JA4SSH emits one value for every 200 SSH packets of a connection. A connection whose last window never reaches that count holds the window open, and no packet emits it. Call CloseOpenWindows when the packet source ends, or lose that window. When one connection ends before the packet source does, call CloseConnectionWindow for it instead. Processor and SyncProcessor each carry both methods. CloseOpenWindows reaches every fingerprinter that implements WindowCloser. CloseConnectionWindow reaches every fingerprinter that implements ConnectionWindowCloser. A fingerprinter that implements one of the two interfaces alone reaches that interface's method.

proc := ja4plus.NewProcessor()
for _, pkt := range packets {
    results, _ := proc.ProcessPacket(pkt)
    _ = results
}

// The capture ends, so emit the window each connection holds open.
trailing := proc.CloseOpenWindows()

One-Shot Functions

Each one-shot function below computes one fingerprint, and it holds no connection state:

fp := ja4plus.ComputeJA4(packet)
fp := ja4plus.ComputeJA4S(packet)
fp := ja4plus.ComputeJA4H(packet)
fp := ja4plus.ComputeJA4T(packet)
fp := ja4plus.ComputeJA4TS(packet)
fp := ja4plus.ComputeJA4D(packet)
fp := ja4plus.ComputeJA4D6(packet)
fp := ja4plus.ComputeJA4XFromDER(certBytes)
fp := ja4plus.ComputeJA4XFromPEM(pemBytes)
fp := ja4plus.ComputeJA4XFromPacket(packet)

Note: JA4L, JA4LS and JA4SSH are multi-packet methods, and none of them reaches a one-shot function. Use NewJA4L and NewJA4SSH instead. NewJA4L serves JA4L and JA4LS, and the library exports no NewJA4LS.

Fingerprint Lookup

ja4plus-go includes a bundled database of known JA4+ fingerprints from FoxIO's ja4plus-mapping.csv.

result := ja4plus.LookupFingerprint("t13d1516h2_8daaf6152771_02713d6af862")
if result != nil {
    fmt.Println(result.Application) // "Chromium Browser"
}
Which functions reach the network

The ja4plus package performs no network input and no network output. It imports no HTTP client, so a program that imports it alone reaches no network. LookupFingerprint reads the embedded table or the cache file, and it makes no request.

One function of this library reaches the network, and it lives in another package. LookupFingerprintRemote of github.com/Crank-Git/ja4plus-go/ja4db asks the ja4db.com service for one record. A caller reaches it only when it imports that package.

import (
	"context"
	"net/http"
	"time"

	"github.com/Crank-Git/ja4plus-go/ja4db"
)

// lookupRemote asks the ja4db.com service for the record of one fingerprint.
// The caller supplies the client, so the caller owns the timeout.
func lookupRemote(ctx context.Context, fingerprint string) error {
	cfg := &ja4db.RemoteLookupConfig{HTTPClient: &http.Client{Timeout: 10 * time.Second}}

	result, err := ja4db.LookupFingerprintRemote(ctx, cfg, fingerprint)
	if err != nil {
		return err
	}
	_ = result

	return nil
}

The command-line program carries its own HTTP client, and ja4plus db update downloads the mapping file. The maintainer ruled the boundary on 2026-08-14, and docs/audit/network-boundary.md holds the record and the reason.

All-In-One Processor

Runs all 10 fingerprinters on each packet:

proc := ja4plus.NewProcessor()
results, errs := proc.ProcessPacket(packet)

Concurrency

One Processor serves one goroutine, and one fingerprinter serves one goroutine. Every fingerprinter holds state that no lock guards. Two goroutines that share one instance write a data race. The race detector reports the race. The library does not detect it at run time.

A caller who wants more than one goroutine takes one of two patterns.

The sharded pattern

GetShardKey returns one key for both directions of one connection, so a packet and its reply reach one Processor. This pattern gives higher throughput, because the per-packet path acquires no lock.

// processSharded runs one Processor for each shard and returns every result.
// Each goroutine owns its Processor, so no lock guards the per-packet path.
func processSharded(packets []gopacket.Packet, shards int) []ja4plus.FingerprintResult {
	inputs := make([]chan gopacket.Packet, shards)
	outputs := make(chan []ja4plus.FingerprintResult, shards)

	var wg sync.WaitGroup
	for i := range inputs {
		inputs[i] = make(chan gopacket.Packet, 16)

		wg.Add(1)
		go func(in <-chan gopacket.Packet) {
			defer wg.Done()

			proc := ja4plus.NewProcessor()

			var results []ja4plus.FingerprintResult
			for packet := range in {
				got, _ := proc.ProcessPacket(packet)
				results = append(results, got...)
			}

			outputs <- results
		}(inputs[i])
	}

	// GetShardKey holds no state, so the router calls it on its own Processor.
	router := ja4plus.NewProcessor()
	for _, packet := range packets {
		key := router.GetShardKey(packet)
		if key == "" {
			// The packet carries neither a TCP layer nor a UDP layer.
			continue
		}

		inputs[shardIndex(key, shards)] <- packet
	}

	for _, in := range inputs {
		close(in)
	}
	wg.Wait()
	close(outputs)

	var all []ja4plus.FingerprintResult
	for results := range outputs {
		all = append(all, results...)
	}

	return all
}

// shardIndex returns the shard that owns the key. The key holds the sorted five-tuple,
// so a packet and its reply reach one shard and one Processor.
func shardIndex(key string, shards int) int {
	digest := fnv.New32a()
	_, _ = digest.Write([]byte(key))

	return int(digest.Sum32() % uint32(shards))
}

The shared pattern

SyncProcessor wraps a Processor and serializes every call with one mutex. It costs one mutex acquisition for each packet. SyncProcessor exports ProcessPacket, Reset, CleanupConnection, CloseOpenWindows, CloseConnectionWindow and GetShardKey, and it exposes no way to reach the inner Processor.

// processShared shares one SyncProcessor between the workers and returns every result.
// The mutex serializes every call, so this pattern gives lower throughput than a shard
// for each goroutine.
func processShared(packets []gopacket.Packet, workers int) []ja4plus.FingerprintResult {
	proc := ja4plus.NewSyncProcessor()

	input := make(chan gopacket.Packet, 16)
	outputs := make(chan []ja4plus.FingerprintResult, workers)

	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()

			var results []ja4plus.FingerprintResult
			for packet := range input {
				got, _ := proc.ProcessPacket(packet)
				results = append(results, got...)
			}

			outputs <- results
		}()
	}

	for _, packet := range packets {
		input <- packet
	}
	close(input)

	wg.Wait()
	close(outputs)

	var all []ja4plus.FingerprintResult
	for results := range outputs {
		all = append(all, results...)
	}

	return all
}

The test file concurrency_doc_test.go holds both functions above and runs them, so the code that this section shows compiles.

Fingerprint Formats

Type Format Example
JA4 {proto}{ver}{sni}{ciphers}{exts}{alpn}_{hash}_{hash} t13d1516h2_8daaf6152771_e5627efa2ab1
JA4S {proto}{ver}{exts}{alpn}_{cipher}_{hash} t130200_1301_a56c5b993250
JA4H {method}{ver}{cookie}{ref}{cnt}{lang}_{h}_{h}_{h} ge11cr0800_edb4461d7a83_...
JA4T {window}_{options}_{mss}_{wscale} 65535_2-4-8-1-3_1460_7
JA4TS {window}_{options}_{mss}_{wscale}[_{synack_delays}] 14600_2-4-8-1-3_1460_0
JA4L JA4L-{C|S}={latency_us}_{ttl} JA4L-S=2500_56
JA4X {issuer}_{subject}_{extensions} a37f49ba31e2_a37f49ba31e2_dd4f1a0ef8b2
JA4SSH c{mode}s{mode}_c{pkts}s{pkts}_c{acks}s{acks} c36s36_c51s80_c69s0
JA4D {type:5}{size:4}{ip:1}{fqdn:1}_{options}_{request_list} disco0000in_61-55_1-3-6-42
JA4D6 {type:5}{size:4}{ip:1}{fqdn:1}_{options}_{request_list} solct0014nn_1-6-8-25_23-24

A JA4TS value carries synack_delays only when the server sent two SYN-ACK packets or more. That part holds the delay of each SYN-ACK after the first, in whole seconds, joined by -. A RST that the server sends on such a connection appends -R and the delay of the RST, which gives 65535_2-1-3-1-1-4_65495_8_1-2-4-8-R6. One JA4TSFingerprinter reads every packet of the connection, and ComputeJA4TS reads one packet and writes four parts.

Conformance

make conformance tests this library against the FoxIO corpus at commit 27f0cbf9fd3000c072f82a0f7d0361dc99acf6c8. testdata/foxio.pin holds that commit, and make corpus fetches the corpus at it. The corpus is FoxIO-licensed material, so this repository tracks the pin and never the captures.

The suite compares every value this library computes against the FoxIO vector for the same packet, and it reports one entry for each difference. testdata/deviations.json is the register: it holds one entry for each accepted difference, with the issue that ruled it. The suite fails on a difference that the register does not hold, and it fails on a register entry whose comparison now matches.

The run reports these figures, measured on 2026-08-15 UTC at the pinned commit:

Figure Count
Matches 1754
Deviations 2
Accepted deviations 850
Register keys 882

The 2 deviations are the two comparisons that the register does not hold, and each one awaits a maintainer ruling. They are ssh2.pcapng/33/JA4L-S and tls3.pcapng/25/JA4L-S. Issue #675 and issue #686 hold them, so make conformance exits 2 on this tree.

The FoxIO reference decides every disputed value. Where this library and a FoxIO vector disagree, this library is wrong. docs/specs/foxio/ transcribes each FoxIO image as numbered rules, and every ruling of this project cites one of them.

Known Limitations

QUIC multi-packet ClientHello reassembly: When a QUIC ClientHello is large enough to span multiple QUIC Initial packets (e.g., with many extensions or a pre_shared_key extension), the CRYPTO frame reassembly may not recover the complete handshake message. This can result in a slightly different extension count and hash compared to the Python reference implementation. In practice this affects a small number of QUIC connections with unusually large ClientHellos. TCP/TLS fingerprinting is unaffected.

Dependencies

  • gopacket for packet capture and dissection
  • golang.org/x/crypto for QUIC HKDF key derivation
  • No cgo required for PCAP file analysis (uses pure Go pcapgo)
  • Every released binary is built with CGO_ENABLED=0. .goreleaser.yaml sets it in the build environment, and release_cgo_test.go guards it. No workflow of this repository sets that variable as a workflow-level, job-level or step-level env key. The release workflow runs go test -race, the race detector needs cgo, and such a setting would stop that step before it reached the build. TestNoWorkflowSetsCgoEnabled holds that property. One run line of .github/workflows/ci.yml still prefixes one go build command with CGO_ENABLED=0. A shell prefix binds one command, so it reaches no other step and the guard permits it. #105 moved the release setting on 2026-08-15 UTC.

Development

git clone https://github.com/Crank-Git/ja4plus-go.git
cd ja4plus-go
go test -v -race ./...

Security

Report a vulnerability privately at https://github.com/Crank-Git/ja4plus-go/security/advisories/new. The report reaches the maintainer, and it reaches no public page. Never open a public issue for a vulnerability.

Every packet this library reads is untrusted input. A fingerprinter parses a header that an attacker writes, so a bounds defect there is reachable from the network. Report a crash, a hang, an out-of-range read, or a value that escapes its documented format.

Private vulnerability reporting is enabled on this repository, measured 2026-08-15 UTC. A repository setting moves without a change to this repository, so that sentence carries its date.

License

The BSD 3-Clause license in LICENSE covers the original Go code, and FoxIO licenses the JA4 method under LICENSE-JA4. FoxIO License 1.1 covers JA4S, JA4H, JA4T, JA4TS, JA4L, JA4LS, JA4X, JA4SSH, JA4D and JA4D6, and it permits non-commercial use only. A commercial user contacts FoxIO for those methods, and NOTICE holds the FoxIO terms.

Acknowledgments

JA4+ was created by John Althouse at FoxIO. This library is an independent implementation of the published specification. For the original spec and reference implementation, see github.com/FoxIO-LLC/ja4.

Also see the Python implementation: github.com/Crank-Git/ja4plus.

Documentation

Overview

Package ja4plus computes JA4+ network fingerprints from the packets that gopacket decodes.

Methods

This package implements eleven methods, and ten fingerprinters carry them. JA4LFingerprinter writes both JA4L and JA4LS, so the count of fingerprinters is one below the count of methods. Read the ten as a count of fingerprinters, and never as a count of methods.

The list below names each method and the input it reads.

  • JA4 reads a TLS client hello. A TCP connection carries one, and a QUIC initial packet carries one.
  • JA4S reads a TLS server hello.
  • JA4H reads an HTTP request.
  • JA4X reads an X.509 certificate.
  • JA4SSH reads a window of SSH packets.
  • JA4T reads a TCP SYN packet.
  • JA4TS reads a TCP SYN-ACK packet.
  • JA4L reads the client timing of a TCP handshake, or of a QUIC exchange.
  • JA4LS reads the server timing of the same exchange.
  • JA4D reads a DHCP packet.
  • JA4D6 reads a DHCPv6 packet.

The list above names what this package implements, and it names no FoxIO list. Three FoxIO records at commit 27f0cbf9fd3000c072f82a0f7d0361dc99acf6c8 name three different sets of methods, so no single FoxIO record states the set above. testdata/foxio.pin holds that commit.

Processor holds the ten fingerprinters, and it gives every packet to each one. The list above holds eleven rows, because JA4LFingerprinter carries two of them. A caller that wants one method alone builds the fingerprinter that carries it. A fingerprinter returns a non-fatal error, and it returns no panic, because every packet is untrusted input.

Build toolchain

The language version and the build toolchain answer two different questions, and this section states both. A language version decides which consumer compiles the module. A build toolchain decides which standard library a built binary links.

The go.mod file declares the Go 1.25 language version. A language version is not a toolchain, so a consumer on a Go 1.25 toolchain compiles this module.

The maintainer moved the version from 1.24 to 1.25 on 2026-08-15, and issue #725 holds the ruling. The gopacket v1.7.1 dependency declares go 1.25.0 in its own go.mod, and it repairs a decoder panic on untrusted input. A Go 1.24 consumer no longer compiles this module, and the ruling lands in v1.1.0.

The minimum build toolchain is go1.25.13. It is the oldest toolchain this project measured at zero called vulnerabilities of the standard library. On 2026-08-14, govulncheck v1.6.0 reported 13 for go1.24.13, 0 for go1.25.13, 4 for go1.26.5 and 0 for go1.26.6. So a later toolchain is not a clean toolchain by itself, and a user on the Go 1.26 line takes go1.26.6 or later.

Every count above moves without a change to this repository, because the vulnerability database is live. The README states the measurement, and it names the command that re-takes it.

Network

This package performs no network input and no network output. It imports no HTTP client, so no call of it reaches the network. LookupFingerprint reads the embedded table or the cache file, and it makes no request.

One function of this library reaches the network, and it lives in another package: LookupFingerprintRemote of github.com/Crank-Git/ja4plus-go/ja4db. A program that imports the core package alone links no HTTP client.

The maintainer ruled that boundary on 2026-08-14, and docs/audit/network-boundary.md holds the record, the three options and the reason.

Concurrency

One Processor serves one goroutine, and one fingerprinter serves one goroutine. Every fingerprinter holds state that no lock guards. Two goroutines that share one instance write a data race. The race detector reports the race. The library does not detect it at run time.

A caller who wants more than one goroutine takes one of two patterns.

  • Route each packet with Processor.GetShardKey, and give each goroutine its own Processor. The key holds the sorted five-tuple, so a packet and its reply reach one Processor.
  • Share one SyncProcessor, which serializes every call with one mutex.

The first pattern gives higher throughput, because the per-packet path acquires no lock. The second pattern costs one mutex acquisition for each packet. The README shows both patterns as code.

License

This repository holds material under two licenses, and NOTICE at the repository root states which license covers which material.

The BSD 3-Clause license covers the original Go code, and LICENSE at the repository root holds that text. FoxIO licenses the JA4 method under BSD 3-Clause, and it publishes that text as LICENSE-JA4.

FoxIO License 1.1 covers the other methods that this package implements: JA4S, JA4H, JA4T, JA4TS, JA4L, JA4LS, JA4X, JA4SSH, JA4D and JA4D6. FoxIO License 1.1 permits non-commercial use only. A commercial user contacts FoxIO for those methods, and this project gives no legal advice.

This package embeds the file data/ja4plus-mapping.csv, and that file comes from FoxIO. FoxIO License 1.1 covers it, so every program that links this package carries FoxIO material. data/README.md names the source of the file.

FoxIO publishes FoxIO License 1.1 at https://github.com/FoxIO-LLC/ja4/blob/main/LICENSE.

Example

Example reads one TCP SYN packet through a Processor and prints each fingerprint.

`go doc` prints the package comment, and pkg.go.dev renders this example under it. So this function is the runnable example of the package documentation, which FR-release-10 requires.

The packet carries a window of 65535 and no TCP option. So the option list, the maximum segment size and the window scale of the JA4T value each read `00`.

processor := ja4plus.NewProcessor()

results, err := processor.ProcessPacket(concurrencySYNPacket(40000))
if err != nil {
	fmt.Println("error:", err)

	return
}

for _, result := range results {
	fmt.Printf("%s %s\n", result.Type, result.Fingerprint)
}
Output:
ja4t 65535_00_00_00

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrNoSecret = errors.New("ja4plus: no TLS secret for the connection")

ErrNoSecret reports that no secret is available for the connection. A caller who supplies no key log reads this error, and a caller whose key log holds no secret for the connection reads it too.

Functions

func CachedDatabasePath

func CachedDatabasePath() (string, error)

CachedDatabasePath returns the path where a cached ja4plus-mapping.csv downloaded by `ja4plus db update` is stored. The directory is created if it does not already exist.

func CalculateDistance

func CalculateDistance(latencyUS int, propagationFactor float64) float64

CalculateDistance estimates physical distance in miles from one-way latency. Uses speed of light in fiber optic cable (0.128 miles/us). propagationFactor accounts for non-direct routing (default 1.6).

func CalculateDistanceKm

func CalculateDistanceKm(latencyUS int, propagationFactor float64) float64

CalculateDistanceKm estimates physical distance in kilometers from one-way latency. Uses speed of light in fiber optic cable (0.206 km/us).

func ComputeJA4

func ComputeJA4(packet gopacket.Packet) string

ComputeJA4 is a one-shot function that extracts a JA4 fingerprint from a packet. Returns an empty string if the packet is not a TLS ClientHello.

func ComputeJA4D

func ComputeJA4D(packet gopacket.Packet) string

ComputeJA4D is a one-shot function that computes the JA4D fingerprint for a single packet.

func ComputeJA4D6

func ComputeJA4D6(packet gopacket.Packet) string

ComputeJA4D6 is a one-shot function that computes the JA4D6 fingerprint for a single packet.

func ComputeJA4H

func ComputeJA4H(packet gopacket.Packet) string

ComputeJA4H extracts the TCP payload from a packet, parses it as an HTTP request, and returns the JA4H fingerprint string. Returns "" if the packet does not contain an HTTP request. It returns "" for a request whose body the packet does not complete, because the ruling of #455 states that such a request reaches no value. One rule governs every path that produces a JA4H value.

func ComputeJA4S

func ComputeJA4S(packet gopacket.Packet) string

ComputeJA4S is a one-shot function that extracts a JA4S fingerprint from a packet. Returns an empty string if the packet is not a TLS ServerHello.

func ComputeJA4T

func ComputeJA4T(packet gopacket.Packet) string

ComputeJA4T is a one-shot function that computes the JA4T fingerprint for a single packet.

func ComputeJA4TS

func ComputeJA4TS(packet gopacket.Packet) string

ComputeJA4TS is a one-shot function that computes the JA4TS fingerprint for a single packet.

It reads one packet through a new fingerprinter, so that packet is always the first SYN-ACK of its connection. The value therefore carries no part e. A caller that needs part e keeps one JA4TSFingerprinter across the packets of the connection.

func ComputeJA4XFromDER

func ComputeJA4XFromDER(certDER []byte) string

ComputeJA4XFromDER computes a JA4X fingerprint from DER-encoded certificate bytes. Returns an empty string if the certificate cannot be parsed.

Format: {issuer_hash}_{subject_hash}_{extension_hash}

func ComputeJA4XFromPEM

func ComputeJA4XFromPEM(pemData []byte) string

ComputeJA4XFromPEM computes a JA4X fingerprint from PEM-encoded certificate bytes. Returns an empty string if the certificate cannot be parsed.

func ComputeJA4XFromPacket

func ComputeJA4XFromPacket(packet gopacket.Packet) string

ComputeJA4XFromPacket is a one-shot function that extracts JA4X fingerprints from a single packet. It creates a temporary fingerprinter, so it does not support stream reassembly. For multi-packet streams, use JA4XFingerprinter.

func DecryptQUICPacket

func DecryptQUICPacket(payload, secret []byte, connectionIDLength int) ([]byte, error)

DecryptQUICPacket returns the frame bytes of one QUIC packet, which the secret protects. The secret is one TLS traffic secret, and KeyLog.Secret returns one. connectionIDLength states the Destination Connection ID length of a short header packet. A long header packet carries its own lengths, so it ignores that argument. It returns ErrNoSecret when the caller supplies no secret, and it produces no fingerprint in that case. It returns a non-fatal error for a packet it cannot read, and it never panics. RFC 9001 Section 5.1 states the key derivation, and Section 5.4.1 states the header protection.

func EstimateHopCount

func EstimateHopCount(ttl uint8) int

EstimateHopCount estimates the number of network hops based on observed TTL.

func EstimateOS

func EstimateOS(ttl uint8) string

EstimateOS estimates the operating system based on observed TTL value.

func LookupHASSH

func LookupHASSH(hassh string) string

LookupHASSH returns a human-readable name for a known HASSH fingerprint, or "" if the fingerprint is not in the built-in lookup table.

Types

type ConnectionWindowCloser

type ConnectionWindowCloser interface {
	// CloseConnectionWindow returns the value of the window that one connection holds
	// open, and it then removes the connection. It names the connection by the same key
	// CleanupConnection accepts. The maintainer ruled the method on 2026-08-12, and issue
	// #216 records the ruling.
	CloseConnectionWindow(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string) []FingerprintResult
}

ConnectionWindowCloser is the interface that a fingerprinter implements when one named connection holds a window open. JA4SSH holds such a window, and no other method of this library does.

The interface sits beside Fingerprinter and beside WindowCloser. Each one declares one capability, as `http.Flusher` and `http.Hijacker` each do, so a type that implements one capability keeps the dispatch of that capability. The maintainer ruled the split on 2026-08-12, and issue #268 records the ruling.

type DatabaseInfo

type DatabaseInfo struct {
	// Source is "embedded" or "cache".
	Source string
	// Path is the cache path when Source == "cache", empty otherwise.
	Path string
	// Entries is the number of fingerprints loaded.
	Entries int
	// ModTime is the modification time of the cache file (zero for embedded).
	ModTime time.Time
}

DatabaseInfo describes the active lookup database.

func GetDatabaseInfo

func GetDatabaseInfo() DatabaseInfo

GetDatabaseInfo returns metadata about the currently-active database. It reads one snapshot of the table, so the source, the path and the record count describe one state.

type FingerprintResult

type FingerprintResult struct {
	// Fingerprint holds the value of the method.
	Fingerprint string
	// Raw holds the unhashed form of the value.
	Raw string
	// OriginalOrder holds `JA4_o`, which hashes each list of the wire-order raw form.
	// `RawOriginalOrder` holds the same two lists unhashed, so the two fields read one
	// input. `testdata/foxio/reference/python/ja4.py:291` states the rule, and issue #277
	// records the field.
	OriginalOrder string
	// RawOriginalOrder holds the wire-order form unhashed, so it reads the same input as
	// OriginalOrder. The form keeps the wire order, it sorts no list, and it preserves the
	// SNI value and the ALPN value.
	RawOriginalOrder string
	// Type names the method that produced the value.
	Type string
	// SrcIP is the source address of the packet.
	SrcIP string
	// DstIP is the destination address of the packet.
	DstIP string
	// SrcPort is the source port of the packet.
	SrcPort uint16
	// DstPort is the destination port of the packet.
	DstPort uint16
	// Timestamp is the capture time of the packet.
	Timestamp time.Time
}

FingerprintResult holds a single fingerprint and its metadata.

Four fields carry one method value each, and the FoxIO key suffix names each one. `Fingerprint` carries the bare key, `Raw` carries `_r`, `OriginalOrder` carries `_o` and `RawOriginalOrder` carries `_ro`. A fingerprinter that produces no value for one of the four leaves that field empty. The vector set that the fingerprinter reads states the reason. The FoxIO per-stream vector set publishes `JA4H_ro` and no `JA4H_r` value, and the FoxIO per-packet vector set publishes `ja4.ja4h_r` on 126 records. `JA4H` therefore fills both fields, because the per-packet set states the raw sorted value that the per-stream set omits. Issue #290 recorded that the two sets differ, and issue #310 filled `Raw`.

type Fingerprinter

type Fingerprinter interface {
	// ProcessPacket reads one packet and returns each fingerprint of it, with any
	// non-fatal error. An implementation returns an error and never a panic, because
	// every packet is untrusted input.
	ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, error)
	// Reset clears every state table of the fingerprinter. A caller reuses the
	// fingerprinter on a second packet source after this call.
	Reset()
	// CleanupConnection removes internal state associated with a connection
	// identified by the given 5-tuple. Each fingerprinter normalizes the tuple
	// to its own internal key format. This prevents state leaks in long-running
	// monitors. For QUIC-keyed fingerprinters (JA4, JA4S), this also cleans up
	// any DCID-to-tuple mappings.
	CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)
}

Fingerprinter is the interface that all JA4+ fingerprinters implement.

type HASSHResult

type HASSHResult struct {
	// Fingerprint holds the HASSH value.
	Fingerprint string
	// Banner holds the SSH identification string of the side.
	Banner string
	// Type is `client` or `server`.
	Type string
	// ConnKey names the connection that produced the value.
	ConnKey string
}

HASSHResult holds a HASSH fingerprint and associated metadata.

type JA4D6Fingerprinter

type JA4D6Fingerprinter struct {
}

JA4D6Fingerprinter generates JA4D6 DHCPv6 fingerprints.

Format: {type:5}{size:4}{ip:1}{fqdn:1}_{options}_{request_list}

  • type: 5-char DHCPv6 message type abbreviation
  • size: 4-digit length of the DUID inside option 1 (Client Identifier), capped 9999, default "0000" if no option 1.
  • ip: 'i' if IATA option (option 4) present, else 'n'
  • fqdn: 'd' if Client FQDN (option 39) present, else 'n'
  • options: dash-joined option type codes in PRESENCE ORDER (no exclusions), including nested sub-options. Default "00".
  • request_list: dash-joined items of the Option Request option (option 6, ORO). Default "00".

One JA4D6Fingerprinter serves one goroutine. It holds state that no lock guards. Give each goroutine its own instance, or share one SyncProcessor.

func NewJA4D6

func NewJA4D6() *JA4D6Fingerprinter

NewJA4D6 creates a new JA4D6 DHCPv6 fingerprinter.

func (*JA4D6Fingerprinter) CleanupConnection

func (f *JA4D6Fingerprinter) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection is a no-op for JA4D6 (stateless per-packet fingerprinter).

func (*JA4D6Fingerprinter) ProcessPacket

func (f *JA4D6Fingerprinter) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, error)

ProcessPacket processes a packet and returns JA4D6 fingerprint results for DHCPv6 messages.

func (*JA4D6Fingerprinter) Reset

func (f *JA4D6Fingerprinter) Reset()

Reset clears the state of the fingerprinter. JA4D6 holds no state, so this method changes nothing. It keeps the Fingerprinter interface whole. Issue #25 removed the results slice, which grew without a bound.

type JA4DFingerprinter

type JA4DFingerprinter struct {
}

JA4DFingerprinter generates JA4D DHCP fingerprints (FoxIO PR #267/#270).

Format: {type:5}{size:4}{ip:1}{fqdn:1}_{options}_{request_list}

  • type: 5-char DHCP message type abbreviation (from option 53)
  • size: 4-digit Maximum DHCP Message Size (option 57), capped 9999, default "0000"
  • ip: 'i' if Requested IP Address (option 50) is present, else 'n'
  • fqdn: 'd' if Client FQDN (option 81) carries a domain name, else 'n'
  • options: dash-joined option type codes in PRESENCE ORDER, excluding Pad (0), MessageType (53), Requested IP (50), FQDN (81). Default "00".
  • request_list: dash-joined items of the Parameter Request List (option 55) in original order. Default "00".

One JA4DFingerprinter serves one goroutine. It holds state that no lock guards. Give each goroutine its own instance, or share one SyncProcessor.

func NewJA4D

func NewJA4D() *JA4DFingerprinter

NewJA4D creates a new JA4D fingerprinter.

func (*JA4DFingerprinter) CleanupConnection

func (f *JA4DFingerprinter) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection is a no-op for JA4D (stateless per-packet fingerprinter).

func (*JA4DFingerprinter) ProcessPacket

func (f *JA4DFingerprinter) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, error)

ProcessPacket processes a packet and returns JA4D fingerprint results for DHCP messages.

func (*JA4DFingerprinter) Reset

func (f *JA4DFingerprinter) Reset()

Reset clears the state of the fingerprinter. JA4D holds no state, so this method changes nothing. It keeps the Fingerprinter interface whole. Issue #25 removed the results slice, which grew without a bound.

type JA4Fingerprinter

type JA4Fingerprinter struct {
	// contains filtered or unexported fields
}

JA4Fingerprinter computes JA4 TLS Client Hello fingerprints.

One JA4Fingerprinter serves one goroutine. It holds state that no lock guards. Give each goroutine its own instance, or share one SyncProcessor.

func NewJA4

func NewJA4() *JA4Fingerprinter

NewJA4 creates a new JA4Fingerprinter.

func (*JA4Fingerprinter) CleanupConnection

func (f *JA4Fingerprinter) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection removes internal state for the given connection. JA4 QUIC state is keyed by DCID hex. This method looks up the DCID via the dcidToTuple reverse map and cleans the corresponding fragments. The caller names the two endpoints in either order, because the reverse map holds the order of the datagram that carried the client hello. The caller names the address pair that a FingerprintResult carries, which is the reported key. JA4L holds the same contract. A tunneled connection groups under the inner address pair, so this method reads the reported key of each connection as well as the grouping key. It falls back to the grouping key, because a caller of GetShardKey holds that key instead. FR-gaps-14e states the fallback, and `ja4plus/fingerprinters/ja4l.py:216` holds it too. Issue #193 records the leak that the absent index caused.

func (*JA4Fingerprinter) ProcessPacket

func (f *JA4Fingerprinter) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, error)

ProcessPacket processes a packet and returns JA4 fingerprint results.

func (*JA4Fingerprinter) Reset

func (f *JA4Fingerprinter) Reset()

Reset clears the QUIC fragment table and the connection identifier table. The fingerprinter keeps no result, because ProcessPacket returns each result to the caller. Issue #25 removed the results slice, which grew without a bound.

type JA4HFingerprinter

type JA4HFingerprinter struct {
	// contains filtered or unexported fields
}

JA4HFingerprinter generates JA4H fingerprints from HTTP request packets. It uses TCP stream reassembly to handle multi-segment HTTP requests.

One JA4HFingerprinter serves one goroutine. It holds state that no lock guards. Give each goroutine its own instance, or share one SyncProcessor.

func NewJA4H

func NewJA4H() *JA4HFingerprinter

NewJA4H creates a new JA4H HTTP fingerprinter.

func (*JA4HFingerprinter) CleanupConnection

func (f *JA4HFingerprinter) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection removes internal state for the given connection. JA4H uses directional arrow keys: srcIP:srcPort->dstIP:dstPort.

func (*JA4HFingerprinter) ProcessPacket

func (f *JA4HFingerprinter) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, error)

ProcessPacket processes a packet and returns JA4H fingerprints if the packet contains an HTTP request (possibly reassembled from multiple segments). It produces the value at the packet that completes the request, and never at the packet that ends the header block. The maintainer ruled that emission frame at #455, and `parser.HTTPMessageIsComplete` holds the rule.

func (*JA4HFingerprinter) Reset

func (f *JA4HFingerprinter) Reset()

Reset clears the TCP stream reassembler. The fingerprinter keeps no result, because ProcessPacket returns each result to the caller. Issue #25 removed the results slice, which grew without a bound.

type JA4LFingerprinter

type JA4LFingerprinter struct {
	// contains filtered or unexported fields
}

JA4LFingerprinter generates JA4L latency fingerprints from TCP handshake timing or QUIC/UDP exchange timing.

One JA4LFingerprinter serves one goroutine. It holds state that no lock guards. Give each goroutine its own instance, or share one SyncProcessor.

func NewJA4L

func NewJA4L() *JA4LFingerprinter

NewJA4L creates a new JA4L latency fingerprinter.

func (*JA4LFingerprinter) CleanupConnection

func (f *JA4LFingerprinter) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection removes internal state for the given connection. The caller names the address pair that a FingerprintResult carries, which is the reported key. A tunneled connection groups under the inner address pair, so this method reads the grouping key from the reported key first. JA4L normalizes keys lexicographically by IP then port.

func (*JA4LFingerprinter) ProcessPacket

func (f *JA4LFingerprinter) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, error)

ProcessPacket processes a packet and returns JA4L fingerprints if a handshake timing measurement can be computed. Supports both TCP and UDP/QUIC.

func (*JA4LFingerprinter) Reset

func (f *JA4LFingerprinter) Reset()

Reset clears the connection table. The fingerprinter keeps no result, because ProcessPacket returns each result to the caller. Issue #25 removed the results slice, which grew without a bound.

type JA4SFingerprinter

type JA4SFingerprinter struct {
	// contains filtered or unexported fields
}

JA4SFingerprinter computes JA4S TLS Server Hello fingerprints.

One JA4SFingerprinter serves one goroutine. It holds state that no lock guards. Give each goroutine its own instance, or share one SyncProcessor.

func NewJA4S

func NewJA4S() *JA4SFingerprinter

NewJA4S creates a new JA4SFingerprinter.

func (*JA4SFingerprinter) CleanupConnection

func (f *JA4SFingerprinter) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection removes internal state for the given connection. JA4S QUIC state is keyed by directional tuple: srcIP:srcPort-dstIP:dstPort. The caller names the address pair that a FingerprintResult carries, which is the reported key. JA4L holds the same contract. The caller names the two endpoints in either order, so this method reads both orders. A tunneled connection groups under the inner address pair, so this method reads the grouping key from the index first. It falls back to the key the caller gave, because a caller of GetShardKey holds the grouping key instead. FR-gaps-14e states the fallback, and `ja4plus/fingerprinters/ja4l.py:216` holds it too. Issue #193 records the leak that the absent index caused.

func (*JA4SFingerprinter) ProcessPacket

func (f *JA4SFingerprinter) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, error)

ProcessPacket processes a packet and returns JA4S fingerprint results.

func (*JA4SFingerprinter) Reset

func (f *JA4SFingerprinter) Reset()

Reset clears the QUIC connection identifier table and the two index tables. The fingerprinter keeps no result, because ProcessPacket returns each result to the caller. Issue #25 removed the results slice, which grew without a bound.

type JA4SSHFingerprinter

type JA4SSHFingerprinter struct {
	// contains filtered or unexported fields
}

JA4SSHFingerprinter generates JA4SSH fingerprints from SSH traffic patterns. It tracks per-connection packet sizes and ACK counts in a rolling window.

Format: c{client_mode}s{server_mode}_c{client_pkts}s{server_pkts}_c{client_acks}s{server_acks}

One JA4SSHFingerprinter serves one goroutine. It holds state that no lock guards. Give each goroutine its own instance, or share one SyncProcessor.

func NewJA4SSH

func NewJA4SSH(packetCount int) *JA4SSHFingerprinter

NewJA4SSH creates a new JA4SSH fingerprinter. If packetCount is 0, the default window of 200 packets is used.

func (*JA4SSHFingerprinter) CleanupConnection

func (f *JA4SSHFingerprinter) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection removes internal state for the given connection. JA4SSH normalizes keys by the three steps of decideEndpoints. It removes the handshake entry of the pair too. That entry would otherwise outlive the connection, because the caller states that the connection ended. The port removes the same entry at `ja4plus/fingerprinters/ja4ssh.py:541-544`. It emits no fingerprint. A caller that wants the open window of the connection calls CloseConnectionWindow instead, and issue #216 records that ruling.

func (*JA4SSHFingerprinter) CloseConnectionWindow

func (f *JA4SSHFingerprinter) CloseConnectionWindow(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string) []FingerprintResult

CloseConnectionWindow returns the value of the window that one connection holds open, and it then removes the connection.

The caller calls the method when it evicts one connection, which is the moment the reference publishes the final window. `rust/ja4/src/ssh.rs:45-55` and `zeek/ja4ssh/main.zeek:160-164` both emit at teardown, and CloseOpenWindows reaches every connection at once, which is the wrong instrument for one connection that just ended. The maintainer ruled the method on 2026-08-12, and issue #216 records the ruling.

It names the connection by the same key CleanupConnection accepts, so the caller names the two endpoints in either order. It returns an empty slice for a connection the state table does not hold, and an empty slice for a window that holds no SSH packet. It removes the connection in both cases, so a second call returns an empty slice. The result names the client of the connection as the source and the server as the destination. CloseOpenWindows names the two endpoints the same way. No packet triggers this emission, so the result reads the endpoints of the connection. The method is opt-in. CleanupConnection still emits nothing, so a caller that only reclaims memory receives no fingerprint it did not ask for.

func (*JA4SSHFingerprinter) CloseOpenWindows

func (f *JA4SSHFingerprinter) CloseOpenWindows() []FingerprintResult

CloseOpenWindows returns the value of the window that each connection holds open, and it starts a new window on each one.

The caller calls the method when the packet source ends. A connection whose last window never reaches the threshold holds that window open, and this method is the one rule that emits it. ProcessPacket emits the open window on a packet that carries the FIN flag and the ACK flag. A connection that sends such a packet therefore holds no window open. `rust/ja4/src/ssh.rs:45-55` and `zeek/ja4ssh/main.zeek:160-164` both emit that window, and the port's issues #105, #199 and #214 hold the ruling.

It returns the values in the order the packet source opened the connections. It returns an empty slice for a window that holds no SSH packet, so a second call returns an empty slice. Each result names the client of the connection as the source and the server as the destination. No packet triggers this emission, so the result reads the endpoints of the connection. A result that ProcessPacket returns names the sender of the packet that filled the window, which is the direction that every other method of this library reports. The method is opt-in. A caller who never calls it loses the open window, and the library forces no flush.

func (*JA4SSHFingerprinter) GetHASSHFingerprints

func (f *JA4SSHFingerprinter) GetHASSHFingerprints() []HASSHResult

GetHASSHFingerprints returns all collected HASSH fingerprints across tracked connections.

func (*JA4SSHFingerprinter) ProcessPacket

func (f *JA4SSHFingerprinter) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, error)

ProcessPacket processes a packet and returns JA4SSH fingerprints when a window fills. It returns the open window of the connection on a packet that carries the FIN flag and the ACK flag. Such a packet closes the connection. Issue #222 holds the readings.

func (*JA4SSHFingerprinter) Reset

func (f *JA4SSHFingerprinter) Reset()

Reset clears the connection table. The fingerprinter keeps no result, because ProcessPacket returns each result to the caller. Issue #25 removed the results slice, which grew without a bound. It keeps the arrival counter. The counter orders the connections that CloseOpenWindows publishes, and a counter that returns to zero would order a new connection against a stale number. It empties the order list too, because the state table and that list name one set of connections. It keeps the packet counter, which schedules the age pass alone. It empties the handshake table and the handshake order list, which step 2 of the client direction reads. `.claude/rules/concurrency.md` states that a new state map reaches CleanupConnection and Reset.

type JA4TFingerprinter

type JA4TFingerprinter struct {
}

JA4TFingerprinter fingerprints TCP SYN packets (client-side).

One JA4TFingerprinter serves one goroutine. It holds state that no lock guards. Give each goroutine its own instance, or share one SyncProcessor.

func NewJA4T

func NewJA4T() *JA4TFingerprinter

NewJA4T creates a new JA4T fingerprinter.

func (*JA4TFingerprinter) CleanupConnection

func (f *JA4TFingerprinter) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection is a no-op for JA4T (stateless per-packet fingerprinter).

func (*JA4TFingerprinter) ProcessPacket

func (f *JA4TFingerprinter) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, error)

ProcessPacket processes a packet and returns JA4T fingerprint results for SYN packets.

func (*JA4TFingerprinter) Reset

func (f *JA4TFingerprinter) Reset()

Reset clears the state of the fingerprinter. JA4T holds no state, so this method changes nothing. It keeps the Fingerprinter interface whole. Issue #25 removed the results slice, which grew without a bound.

type JA4TSFingerprinter

type JA4TSFingerprinter struct {
	// contains filtered or unexported fields
}

JA4TSFingerprinter fingerprints TCP SYN-ACK packets (server-side).

A connection the server answered twice or more carries part e, which holds the delay of each SYN-ACK after the first. A RST that the server sends on such a connection appends `-R` and its own delay.

One JA4TSFingerprinter serves one goroutine. It holds state that no lock guards. Give each goroutine its own instance, or share one SyncProcessor.

func NewJA4TS

func NewJA4TS() *JA4TSFingerprinter

NewJA4TS creates a new JA4TS fingerprinter.

func (*JA4TSFingerprinter) CleanupConnection

func (f *JA4TSFingerprinter) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection removes the stored SYN-ACK times and stored parts of the connection. The key names the server first, because every SYN-ACK travels from the server. The caller names either direction, so this method drops both orderings.

func (*JA4TSFingerprinter) ProcessPacket

func (f *JA4TSFingerprinter) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, error)

ProcessPacket processes a packet and returns JA4TS fingerprint results for SYN-ACK packets. It returns one result for a RST packet of a connection that already sent a SYN-ACK. It returns no result for any other packet.

func (*JA4TSFingerprinter) Reset

func (f *JA4TSFingerprinter) Reset()

Reset clears the state of the fingerprinter. It drops every connection, so a second capture reads none of the SYN-ACK times of the first one.

type JA4XFingerprinter

type JA4XFingerprinter struct {
	// contains filtered or unexported fields
}

JA4XFingerprinter computes JA4X X.509 certificate fingerprints. It is stateful: it tracks TCP streams to reassemble TLS Certificate messages that may span multiple TCP segments.

One JA4XFingerprinter serves one goroutine. It holds state that no lock guards. Give each goroutine its own instance, or share one SyncProcessor.

func NewJA4X

func NewJA4X() *JA4XFingerprinter

NewJA4X creates a new JA4XFingerprinter.

func (*JA4XFingerprinter) CleanupConnection

func (f *JA4XFingerprinter) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection removes internal state for the given connection. JA4X uses directional keys: srcIP:srcPort-dstIP:dstPort. Both directions are cleaned since the certificate may arrive from either side.

func (*JA4XFingerprinter) ProcessPacket

func (f *JA4XFingerprinter) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, error)

ProcessPacket processes a packet and returns JA4X fingerprint results.

func (*JA4XFingerprinter) Reset

func (f *JA4XFingerprinter) Reset()

Reset clears the stream table and the certificate set. The fingerprinter keeps no result, because ProcessPacket returns each result to the caller. Issue #25 removed the results slice, which grew without a bound.

type KeyLog

type KeyLog struct {
	// contains filtered or unexported fields
}

KeyLog holds the TLS secrets of one or more connections, and the client random of the connection identifies each one.

A caller builds a KeyLog with ParseKeyLog or with ReadKeyLogFromCapture, and the value does not change after that. Any number of goroutines read one KeyLog.

The library reads a secret only when the caller supplies one. It reads no key material outside the capture file and outside the reader the caller passes.

func ParseKeyLog

func ParseKeyLog(r io.Reader) (*KeyLog, error)

ParseKeyLog returns the KeyLog of a key log in the NSS key log format, which `draft-ietf-tls-keylogfile` specifies. It ignores a line it cannot read. It reads at most 16 megabytes.

func ReadKeyLogFromCapture

func ReadKeyLogFromCapture(r io.Reader) (*KeyLog, error)

ReadKeyLogFromCapture returns the KeyLog that the Decryption Secrets Blocks of a pcapng capture carry. It returns an empty KeyLog for a capture that holds no such block. It returns an error for a reader that holds no pcapng capture. FR-gaps-15 states this requirement, and `gopacket` v1.1.19 discards the block.

func (*KeyLog) ClientRandoms

func (k *KeyLog) ClientRandoms() [][]byte

ClientRandoms returns the client random of every connection the key log holds, sorted.

func (*KeyLog) Len

func (k *KeyLog) Len() int

Len returns the count of secrets the key log holds.

func (*KeyLog) Secret

func (k *KeyLog) Secret(clientRandom []byte, label string) ([]byte, error)

Secret returns the secret of one label for the connection that the client random identifies. It returns ErrNoSecret when the key log holds no such secret. A Decryption Secrets Block that holds a secret for a connection the capture does not carry reaches no caller, because no packet of the capture states that client random.

type LookupResult

type LookupResult struct {
	// Application names the program that produces the fingerprint.
	Application string
	// Type names the class of the program.
	Type string
	// Notes holds the free text of the record.
	Notes string
}

LookupResult holds the result of a fingerprint database lookup.

func LookupFingerprint

func LookupFingerprint(fingerprint string) *LookupResult

LookupFingerprint looks up a JA4+ fingerprint in the local FoxIO database. If a cached database file is present at the user-cache path (see CachedDatabasePath), it is used; otherwise the embedded database is used. Returns nil if the fingerprint is not found. This function never makes network calls.

The library reads the file time and the size of the cache file at each call, and it rebuilds the table when one of the two changes. A program that updates the database therefore reads the new table at its next lookup, and it needs no restart. A rebuild that cannot parse the cache file leaves the previous table in place. A cache file that the program deleted makes the library read the embedded copy.

This function is safe for concurrent use.

The remote lookup lives in the package github.com/Crank-Git/ja4plus-go/ja4db, which this package does not import.

Example

ExampleLookupFingerprint reads the local database for one fingerprint.

The example prints the result of a value that no database holds, and it does so for a reason. Two databases answer this call: the embedded FoxIO mapping, and the cache file that `ja4plus db update` writes. A machine that holds a cache file answers a hit differently from a machine that holds none. An example compares its printed text exactly. A miss returns nil under every database, so this example states the one result that each machine reproduces.

The lookup reaches no network. github.com/Crank-Git/ja4plus-go/ja4db holds the remote lookup.

package main

import (
	"fmt"

	ja4plus "github.com/Crank-Git/ja4plus-go"
)

func main() {
	// Each part of this value is a placeholder, so no database record carries it.
	result := ja4plus.LookupFingerprint("t00i000000_000000000000_000000000000")

	fmt.Println(result == nil)

}
Output:
true

type Processor

type Processor struct {
	// contains filtered or unexported fields
}

Processor runs all JA4+ fingerprinters on each packet and aggregates results. Errors from individual fingerprinters are non-fatal; they are collected and returned alongside any successful results.

One Processor serves one goroutine. Every fingerprinter holds state that no lock guards. Two goroutines that share one Processor write a data race. The race detector reports the race. The library does not detect it at run time.

A caller who wants more than one goroutine takes one of two patterns.

  • Route each packet with GetShardKey, and give each goroutine its own Processor. GetShardKey returns one key for both directions of one connection.
  • Share one SyncProcessor, which serializes every call with one mutex.

The first pattern gives higher throughput, because the per-packet path acquires no lock. The second pattern costs one mutex acquisition for each packet.

Example

ExampleProcessor reads two connections through one Processor, and it then clears the state of the Processor.

One Processor serves one goroutine, and this example uses one goroutine. A caller that wants more than one goroutine reads the `# Concurrency` section of the package documentation.

A long-running monitor calls CleanupConnection when a connection ends, and it calls Reset when it starts a second packet source. State that neither call removes stays in the Processor for the life of the program.

processor := ja4plus.NewProcessor()

for _, srcPort := range []uint16{40000, 40001} {
	results, err := processor.ProcessPacket(concurrencySYNPacket(srcPort))
	if err != nil {
		fmt.Println("error:", err)

		return
	}

	for _, result := range results {
		fmt.Printf("%s from %s:%d to %s:%d\n",
			result.Type, result.SrcIP, result.SrcPort, result.DstIP, result.DstPort)
	}
}

processor.Reset()
Output:
ja4t from 192.168.1.1:40000 to 10.0.0.1:443
ja4t from 192.168.1.1:40001 to 10.0.0.1:443

func NewProcessor

func NewProcessor(options ...ProcessorOption) *Processor

NewProcessor creates a Processor with all fingerprinters initialized. It applies each option in the order the caller states, so the last option that writes one field decides that field. It ignores a nil option, because a caller that builds an option slice leaves a slot empty.

func (*Processor) CleanupConnection

func (p *Processor) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection removes internal state for the given connection across all fingerprinters. Each fingerprinter normalizes the 5-tuple to its own internal key format. Call this when a connection is evicted from the monitor's tracker to prevent state leaks in long-running processes. The caller names the address pair that a FingerprintResult carries, which is the reported key. One call reaches every fingerprinter, so one contract governs them all.

func (*Processor) CloseConnectionWindow

func (p *Processor) CloseConnectionWindow(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string) []FingerprintResult

CloseConnectionWindow returns the value of the window that one connection holds open, and it then removes the connection from every fingerprinter that holds such a window.

The caller calls the method when it evicts one connection. It reaches each fingerprinter that implements ConnectionWindowCloser and joins the results, in the order the processor runs the fingerprinters. A fingerprinter that implements no ConnectionWindowCloser holds no window open, so the call skips it. The caller names the address pair that a FingerprintResult carries, which is the reported key. CleanupConnection accepts the same key. It removes the connection, so a second call returns an empty slice. CleanupConnection still emits nothing, so a caller that only reclaims memory receives no fingerprint it did not ask for. The maintainer ruled the method on 2026-08-12, and issue #216 records the ruling.

func (*Processor) CloseOpenWindows

func (p *Processor) CloseOpenWindows() []FingerprintResult

CloseOpenWindows returns the value of the window that each fingerprinter holds open.

The caller calls the method when the packet source ends. It reaches each fingerprinter that implements WindowCloser and joins the results, in the order the processor runs the fingerprinters. A fingerprinter that implements no WindowCloser holds no window open, so the call skips it. A second call returns an empty slice, because the first call started a new window. FR-parity-31 states this requirement.

func (*Processor) GetShardKey

func (p *Processor) GetShardKey(packet gopacket.Packet) string

GetShardKey returns a stable key that routes one packet to one Processor shard. The key is the sorted five-tuple, so a packet and its reply return one key. The key reads no QUIC connection identifier, so every packet of one QUIC connection returns one key after the identifier changes. It returns an empty string for a packet that carries neither TCP nor UDP. The caller decides what to do with an empty key. GetShardKey acquires no lock and holds no state.

**`monitorConnectionKey` in `cmd/ja4plus/watch.go` holds a second copy of this grouping rule.** #80 wrote it for the connection table of `ja4plus watch`, and it returns a `connectionKey` where this method returns a formatted string. **A change to the rule below changes that function too.** This method is exported and it decides the rule; that function follows it. The Epic 13 cross-member review found the pair, and issue #614 holds the reading.

Example
package main

import (
	"fmt"
	"hash/fnv"
	"net"
	"sync"
	"time"

	ja4plus "github.com/Crank-Git/ja4plus-go"
	"github.com/gopacket/gopacket"
	"github.com/gopacket/gopacket/layers"
)

// processSharded runs one Processor for each shard and returns every result.
// Each goroutine owns its Processor, so no lock guards the per-packet path.
func processSharded(packets []gopacket.Packet, shards int) []ja4plus.FingerprintResult {
	inputs := make([]chan gopacket.Packet, shards)
	outputs := make(chan []ja4plus.FingerprintResult, shards)

	var wg sync.WaitGroup
	for i := range inputs {
		inputs[i] = make(chan gopacket.Packet, 16)

		wg.Add(1)
		go func(in <-chan gopacket.Packet) {
			defer wg.Done()

			proc := ja4plus.NewProcessor()

			var results []ja4plus.FingerprintResult
			for packet := range in {
				got, _ := proc.ProcessPacket(packet)
				results = append(results, got...)
			}

			outputs <- results
		}(inputs[i])
	}

	router := ja4plus.NewProcessor()
	for _, packet := range packets {
		key := router.GetShardKey(packet)
		if key == "" {

			continue
		}

		inputs[shardIndex(key, shards)] <- packet
	}

	for _, in := range inputs {
		close(in)
	}
	wg.Wait()
	close(outputs)

	var all []ja4plus.FingerprintResult
	for results := range outputs {
		all = append(all, results...)
	}

	return all
}

// shardIndex returns the shard that owns the key. The key holds the sorted five-tuple,
// so a packet and its reply reach one shard and one Processor.
func shardIndex(key string, shards int) int {
	digest := fnv.New32a()
	_, _ = digest.Write([]byte(key))

	return int(digest.Sum32() % uint32(shards))
}

func main() {
	packets := concurrencySYNPackets(8)

	results := processSharded(packets, 4)

	fmt.Println(countJA4T(results))
}

// countJA4T returns the number of JA4T results. One TCP SYN packet produces one of them,
// so the count is stable whatever order the goroutines run in.
func countJA4T(results []ja4plus.FingerprintResult) int {
	count := 0
	for _, result := range results {
		if result.Type == "ja4t" {
			count++
		}
	}

	return count
}

// concurrencySYNPackets returns the requested number of TCP SYN packets. Each packet
// carries its own source port, so the packets reach more than one shard.
func concurrencySYNPackets(count int) []gopacket.Packet {
	packets := make([]gopacket.Packet, 0, count)
	for i := 0; i < count; i++ {
		packets = append(packets, concurrencySYNPacket(uint16(40000+i)))
	}

	return packets
}

// concurrencySYNPacket returns one TCP SYN packet from the source port.
func concurrencySYNPacket(srcPort uint16) gopacket.Packet {
	ip := &layers.IPv4{
		SrcIP:    net.ParseIP("192.168.1.1"),
		DstIP:    net.ParseIP("10.0.0.1"),
		Protocol: layers.IPProtocolTCP,
		Version:  4,
		TTL:      64,
	}
	tcp := &layers.TCP{
		SrcPort: layers.TCPPort(srcPort),
		DstPort: layers.TCPPort(443),
		SYN:     true,
		Window:  65535,
	}
	_ = tcp.SetNetworkLayerForChecksum(ip)

	buf := gopacket.NewSerializeBuffer()
	options := gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true}
	_ = gopacket.SerializeLayers(buf, options, ip, tcp)

	packet := gopacket.NewPacket(buf.Bytes(), layers.LayerTypeIPv4, gopacket.Default)
	packet.Metadata().Timestamp = time.Now()

	return packet
}
Output:
8
Example (ShardedProcessors)

ExampleProcessor_GetShardKey_shardedProcessors routes each packet to one of four Processor goroutines.

It mirrors the first fenced Go block of `docs/concurrency.md`.

`concurrency_doc_test.go` holds `ExampleProcessor_GetShardKey`, which runs the same pattern against packets it builds. This example carries the suffix because the two names share one package.

package main

import (
	"fmt"
	"hash/fnv"
	"os"
	"sync"

	ja4plus "github.com/Crank-Git/ja4plus-go"
	"github.com/gopacket/gopacket"
	"github.com/gopacket/gopacket/pcapgo"
)

func main() {
	const shards = 4

	f, err := os.Open("capture.pcap")
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	// The page teaches the read path, and a close error of a read-only file changes no
	// fingerprint. This comment reaches no comparison, because the matching rule of
	// `docs_go_samples_test.go` drops every comment.
	defer f.Close() //nolint:errcheck // The mirrored page states no error path here.

	reader, err := pcapgo.NewReader(f)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	// This Processor computes routing keys, and it processes no packet. GetShardKey
	// holds no state, so one goroutine may use it while the shards run.
	router := ja4plus.NewProcessor()

	queues := make([]chan gopacket.Packet, shards)
	var wg sync.WaitGroup

	for i := range queues {
		queues[i] = make(chan gopacket.Packet, 1024)

		wg.Add(1)
		go func(in <-chan gopacket.Packet) {
			defer wg.Done()

			// This goroutine owns the Processor, and no other goroutine touches it.
			proc := ja4plus.NewProcessor()
			for pkt := range in {
				results, _ := proc.ProcessPacket(pkt)
				for _, r := range results {
					fmt.Printf("[%s] %s\n", r.Type, r.Fingerprint)
				}
			}
			// The goroutine that owns the Processor closes its windows.
			for _, r := range proc.CloseOpenWindows() {
				fmt.Printf("[%s] %s\n", r.Type, r.Fingerprint)
			}
		}(queues[i])
	}

	for {
		data, ci, err := reader.ReadPacketData()
		if err != nil {
			break
		}
		pkt := gopacket.NewPacket(data, reader.LinkType(), gopacket.Default)
		pkt.Metadata().Timestamp = ci.Timestamp

		// A packet that carries neither TCP nor UDP returns an empty key, and the hash
		// of an empty key sends it to one fixed shard. Every packet reaches one shard.
		h := fnv.New32a()
		_, _ = h.Write([]byte(router.GetShardKey(pkt)))
		queues[h.Sum32()%shards] <- pkt
	}

	for _, q := range queues {
		close(q)
	}
	wg.Wait()
}

func (*Processor) ProcessPacket

func (p *Processor) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, []error)

ProcessPacket runs all fingerprinters on the given packet. It returns all fingerprint results and any non-fatal errors encountered.

Example

ExampleProcessor_ProcessPacket reads every packet of one capture file through one Processor.

It mirrors the fenced Go block of `docs/usage.md`.

package main

import (
	"fmt"
	"os"

	ja4plus "github.com/Crank-Git/ja4plus-go"
	"github.com/gopacket/gopacket"
	"github.com/gopacket/gopacket/pcapgo"
)

func main() {
	f, _ := os.Open("capture.pcap")
	// The page teaches the read path, and a close error of a read-only file changes no
	// fingerprint. This comment reaches no comparison, because the matching rule of
	// `docs_go_samples_test.go` drops every comment.
	defer f.Close() //nolint:errcheck // The mirrored page states no error path here.

	reader, _ := pcapgo.NewReader(f)
	proc := ja4plus.NewProcessor()

	for {
		data, ci, err := reader.ReadPacketData()
		if err != nil {
			break
		}
		pkt := gopacket.NewPacket(data, reader.LinkType(), gopacket.Default)
		pkt.Metadata().Timestamp = ci.Timestamp

		results, _ := proc.ProcessPacket(pkt)
		for _, r := range results {
			fmt.Printf("[%s] %s:%d -> %s:%d  %s\n",
				r.Type, r.SrcIP, r.SrcPort, r.DstIP, r.DstPort, r.Fingerprint)
		}
	}
}

func (*Processor) Reset

func (p *Processor) Reset()

Reset clears all fingerprinter state.

type ProcessorOption

type ProcessorOption func(*Processor)

ProcessorOption sets one option of a Processor at construction.

The maintainer ruled this shape on 2026-08-15 UTC, and comment 5299963400 of issue #649 records the ruling. Epic 10 (#100) freezes the exported surface, so a later option costs one more WithX function against this type rather than a new exported constructor. Issue #649 is the reversal path.

An option runs after the constructor fills every fingerprinter, so an option reads a Processor that is complete.

func WithKeyLog

func WithKeyLog(keyLog *KeyLog) ProcessorOption

WithKeyLog returns the option that gives a Processor the key log.

The Processor holds the pointer the caller supplies, and it writes nothing to the key log. A fingerprinter that needs no secret ignores it. A caller that supplies nil gives the Processor no key log. The doc comment of KeyLog states that the value does not change after construction. So a sharded caller gives one KeyLog to every Processor, and the packet path takes no lock.

type SSHSessionInfo

type SSHSessionInfo struct {
	// SessionType names the class of the session.
	SessionType string
	// Description states the reading in one sentence.
	Description string
	// ClientMode is the mode of the client payload size.
	ClientMode int
	// ServerMode is the mode of the server payload size.
	ServerMode int
	// ClientSSH is the count of SSH packets the client sent.
	ClientSSH int
	// ServerSSH is the count of SSH packets the server sent.
	ServerSSH int
	// ClientACK is the count of bare ACKs the client sent.
	ClientACK int
	// ServerACK is the count of bare ACKs the server sent.
	ServerACK int
}

SSHSessionInfo holds the interpretation of a JA4SSH fingerprint.

func InterpretJA4SSH

func InterpretJA4SSH(fingerprint string) *SSHSessionInfo

InterpretJA4SSH parses a JA4SSH fingerprint and classifies the session type. Returns nil if the fingerprint format is invalid.

type SyncProcessor

type SyncProcessor struct {
	// contains filtered or unexported fields
}

SyncProcessor wraps a Processor and serializes every call with one mutex. Every method is safe to call from any number of goroutines at the same time. One Processor for each shard gives higher throughput, because the per-packet path then acquires no lock. Route packets to the shards with GetShardKey. A SyncProcessor exposes no way to reach the inner Processor, because a caller who reaches it can break the contract.

Example
package main

import (
	"fmt"
	"net"
	"sync"
	"time"

	ja4plus "github.com/Crank-Git/ja4plus-go"
	"github.com/gopacket/gopacket"
	"github.com/gopacket/gopacket/layers"
)

// processShared shares one SyncProcessor between the workers and returns every result.
// The mutex serializes every call, so this pattern gives lower throughput than a shard
// for each goroutine.
func processShared(packets []gopacket.Packet, workers int) []ja4plus.FingerprintResult {
	proc := ja4plus.NewSyncProcessor()

	input := make(chan gopacket.Packet, 16)
	outputs := make(chan []ja4plus.FingerprintResult, workers)

	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()

			var results []ja4plus.FingerprintResult
			for packet := range input {
				got, _ := proc.ProcessPacket(packet)
				results = append(results, got...)
			}

			outputs <- results
		}()
	}

	for _, packet := range packets {
		input <- packet
	}
	close(input)

	wg.Wait()
	close(outputs)

	var all []ja4plus.FingerprintResult
	for results := range outputs {
		all = append(all, results...)
	}

	return all
}

func main() {
	packets := concurrencySYNPackets(8)

	results := processShared(packets, 4)

	fmt.Println(countJA4T(results))
}

// countJA4T returns the number of JA4T results. One TCP SYN packet produces one of them,
// so the count is stable whatever order the goroutines run in.
func countJA4T(results []ja4plus.FingerprintResult) int {
	count := 0
	for _, result := range results {
		if result.Type == "ja4t" {
			count++
		}
	}

	return count
}

// concurrencySYNPackets returns the requested number of TCP SYN packets. Each packet
// carries its own source port, so the packets reach more than one shard.
func concurrencySYNPackets(count int) []gopacket.Packet {
	packets := make([]gopacket.Packet, 0, count)
	for i := 0; i < count; i++ {
		packets = append(packets, concurrencySYNPacket(uint16(40000+i)))
	}

	return packets
}

// concurrencySYNPacket returns one TCP SYN packet from the source port.
func concurrencySYNPacket(srcPort uint16) gopacket.Packet {
	ip := &layers.IPv4{
		SrcIP:    net.ParseIP("192.168.1.1"),
		DstIP:    net.ParseIP("10.0.0.1"),
		Protocol: layers.IPProtocolTCP,
		Version:  4,
		TTL:      64,
	}
	tcp := &layers.TCP{
		SrcPort: layers.TCPPort(srcPort),
		DstPort: layers.TCPPort(443),
		SYN:     true,
		Window:  65535,
	}
	_ = tcp.SetNetworkLayerForChecksum(ip)

	buf := gopacket.NewSerializeBuffer()
	options := gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true}
	_ = gopacket.SerializeLayers(buf, options, ip, tcp)

	packet := gopacket.NewPacket(buf.Bytes(), layers.LayerTypeIPv4, gopacket.Default)
	packet.Metadata().Timestamp = time.Now()

	return packet
}
Output:
8

func NewSyncProcessor

func NewSyncProcessor(options ...ProcessorOption) *SyncProcessor

NewSyncProcessor returns a SyncProcessor that holds a new Processor. It passes each option to NewProcessor, so one option type serves both constructors. A caller that states no option receives a SyncProcessor with no key log.

func (*SyncProcessor) CleanupConnection

func (p *SyncProcessor) CleanupConnection(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string)

CleanupConnection removes the state of the connection from every fingerprinter. Call it when the monitor evicts a connection, because state with no removal path leaks in a long-running monitor.

func (*SyncProcessor) CloseConnectionWindow

func (p *SyncProcessor) CloseConnectionWindow(srcIP string, srcPort uint16, dstIP string, dstPort uint16, proto string) []FingerprintResult

CloseConnectionWindow returns the value of the window that one connection holds open, and it then removes the connection. Call it when the monitor evicts a connection, because CleanupConnection emits nothing and the open window is then lost. The mutex serializes it against a ProcessPacket call, so no result escapes while another goroutine changes the fingerprinter state.

func (*SyncProcessor) CloseOpenWindows

func (p *SyncProcessor) CloseOpenWindows() []FingerprintResult

CloseOpenWindows returns the value of the window that each fingerprinter holds open. Call it when the packet source ends, because a connection whose last window never reaches the threshold holds that window open. The mutex serializes it against a ProcessPacket call, so no result escapes while another goroutine changes the fingerprinter state.

func (*SyncProcessor) GetShardKey

func (p *SyncProcessor) GetShardKey(packet gopacket.Packet) string

GetShardKey returns one routing key for both directions of the connection. It returns an empty string for a packet that carries neither a TCP layer nor a UDP layer. The caller decides what to do with an empty key. It takes the mutex like every other exported method, because FR-concurrency-13 states one rule for the whole type.

func (*SyncProcessor) ProcessPacket

func (p *SyncProcessor) ProcessPacket(packet gopacket.Packet) ([]FingerprintResult, []error)

ProcessPacket runs every fingerprinter on the packet and returns the results with any non-fatal errors. It holds the mutex for the whole call, so a result never escapes while another goroutine changes the fingerprinter state.

Example

ExampleSyncProcessor_ProcessPacket shares one SyncProcessor across four worker goroutines.

It mirrors the second fenced Go block of `docs/concurrency.md`.

package main

import (
	"fmt"
	"os"
	"sync"

	ja4plus "github.com/Crank-Git/ja4plus-go"
	"github.com/gopacket/gopacket"
	"github.com/gopacket/gopacket/pcapgo"
)

func main() {
	f, err := os.Open("capture.pcap")
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	// The page teaches the read path, and a close error of a read-only file changes no
	// fingerprint. This comment reaches no comparison, because the matching rule of
	// `docs_go_samples_test.go` drops every comment.
	defer f.Close() //nolint:errcheck // The mirrored page states no error path here.

	reader, err := pcapgo.NewReader(f)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	// Every worker shares this one SyncProcessor, and the mutex serializes each call.
	proc := ja4plus.NewSyncProcessor()

	queue := make(chan gopacket.Packet, 1024)
	var wg sync.WaitGroup

	for i := 0; i < 4; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for pkt := range queue {
				results, _ := proc.ProcessPacket(pkt)
				for _, r := range results {
					fmt.Printf("[%s] %s\n", r.Type, r.Fingerprint)
				}
			}
		}()
	}

	for {
		data, ci, err := reader.ReadPacketData()
		if err != nil {
			break
		}
		pkt := gopacket.NewPacket(data, reader.LinkType(), gopacket.Default)
		pkt.Metadata().Timestamp = ci.Timestamp
		queue <- pkt
	}
	close(queue)
	wg.Wait()

	// Every worker has stopped, so one call closes every open window.
	for _, r := range proc.CloseOpenWindows() {
		fmt.Printf("[%s] %s\n", r.Type, r.Fingerprint)
	}
}

func (*SyncProcessor) Reset

func (p *SyncProcessor) Reset()

Reset clears the state of every fingerprinter. The mutex serializes it against a ProcessPacket call. It runs before that call or after it, and never during it.

type WindowCloser

type WindowCloser interface {
	// CloseOpenWindows returns the value of the window that each connection holds open,
	// and it starts a new window on each one. A second call returns an empty slice.
	CloseOpenWindows() []FingerprintResult
}

WindowCloser is the interface that a fingerprinter implements when a connection holds a window open at the end of the packet source. JA4SSH holds such a window, and no other method of this library does.

The interface sits beside Fingerprinter and not inside it. Fingerprinter is exported, and a new method on it breaks every third-party implementation, which `v1.0.0` forbids for the whole `v1` series. A caller discovers this interface with a type assertion, as a caller of `io.WriterTo` does. A stateless fingerprinter implements nothing, and Processor skips it. The maintainer ruled this placement on 2026-08-11, and issue #53 records the ruling.

The interface declares one method, and ConnectionWindowCloser declares the other one. A two-method interface skipped a type that implements one of the two methods. That type then lost the dispatch of the method it does implement. The maintainer ruled the split on 2026-08-12, and issue #268 records the ruling.

Directories

Path Synopsis
cmd
ja4plus command
examples
readcapture command
Command readcapture reads a capture file and prints one line for each fingerprint.
Command readcapture reads a capture file and prints one line for each fingerprint.
shardedprocessors command
Command shardedprocessors routes each packet to one of four Processor goroutines.
Command shardedprocessors routes each packet to one of four Processor goroutines.
syncprocessor command
Command syncprocessor shares one SyncProcessor across four worker goroutines.
Command syncprocessor shares one SyncProcessor across four worker goroutines.
internal
capture
Package capture opens a capture handle on one live interface.
Package capture opens a capture handle on one live interface.
dbcache
Package dbcache validates a JA4+ fingerprint database and installs it in the cache file.
Package dbcache validates a JA4+ fingerprint database and installs it in the cache file.
fuzzprop
Package fuzzprop holds the properties that every fuzz target of this repository runs.
Package fuzzprop holds the properties that every fuzz target of this repository runs.
keylog
Package keylog reads the TLS secrets that a capture carries.
Package keylog reads the TLS secrets that a capture carries.
mutationdiff command
Command mutationdiff compares two mutation reports and names every new unsettled mutation.
Command mutationdiff compares two mutation reports and names every new unsettled mutation.
mutationreport command
Command mutationreport renders the JSON output of `gremlins` as a tracked report.
Command mutationreport renders the JSON output of `gremlins` as a tracked report.
Package ja4db asks the ja4db.com service for the record of one JA4+ fingerprint.
Package ja4db asks the ja4db.com service for the record of one JA4+ fingerprint.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL