Skip to content

Repository files navigation

browserfp

License

Browser network fingerprints — build them, identify them, and prove they're right.

A C library (with Lua and Go bindings) that reproduces what a real browser looks like on the wire: the TLS 1.3 ClientHello and the HTTP/2 opening frames. Given a User-Agent string, it emits the exact bytes that browser would send.

The second half of that sentence is the point. Fingerprint work fails in a specific way: JA4 matches, tests are green, and the bytes on the wire still differ from a real browser in one place that matters. Every claim in this repo has to be backed by something that can fail.

中文说明见 README.zh-CN.md

❤️ Sponsor

Want to appear here?

Augmunt Augmunt is a stable, high-performance Agentic LLM gateway platform, covering mainstream coding agents and leading international model ecosystems, offering unified access for developers and enterprises. It supports enterprise procurement workflows and corporate payment, with dependable service standards.

Contents

Coverage

Supported browsers and versions:

Browser Versions
Chrome (desktop & mobile) 70 – 153
Edge 79 – 153
Firefox (desktop & mobile) 78 – 153
Opera (desktop & mobile) 70 – 153
Safari (desktop & mobile) 15 – 27

That's 570 (brand, version) pairs. They collapse to far fewer distinct byte forms, because consecutive releases usually share a byte-identical ClientHello:

Unique fingerprints 82 (deduped from 321 target names by 13 deterministic fields)

The table is generated by asking the library itself which pairs Select() accepts: "present in the data file" is not the same as "usable", since a pair needs both a TLS profile and an HTTP/2 fingerprint. Safari's range is sparse because WebKit changes its wire form far less often than Chromium does.

Chromium-derived browsers (Edge, Opera) are keyed by the Chrome version in their UA, not their own — Opera 110 ships Chrome/125 in its UA, 15 major versions apart. ParseUA already handles this.

| Distinct byte forms confirmed by a third party | 44 / 44 | | Key exchange | X25519, P-256, P-384, X25519MLKEM768, X25519Kyber768Draft00 | | Layers | TLS 1.3 (incl. HRR, RFC 8879 cert compression), HTTP/2 preface + Akamai fingerprint |

Not covered: QUIC/HTTP-3 construction (identify-only — see Scope).

Installation

Build the shared library:

cd csrc && make            # produces libbrowserfp.so

Requires a C99 compiler and Python 3 (used to generate the profile tables at build time). There is no link-time OpenSSL dependency: SHA-256 and the EVP key-exchange functions are resolved at runtime via dlsym, preferring whatever libcrypto the host process already loaded. Two consequences, both deliberate:

  • Cross-compiles cleanly. zig cc -target x86_64-linux-gnu produces a Linux .so from macOS with no sysroot setup.
  • No second OpenSSL in the process. Linking one in would mean two copies inside an OpenResty worker, which already bundles its own.

The Go binding compiles the C sources directly via cgo — go build is enough, no prebuilt .so required (but csrc/profiles.inc must exist; run make -C csrc profiles.inc once).

The Lua binding tries libbrowserfp.so, ./libbrowserfp.so, csrc/libbrowserfp.so and /usr/local/lib/libbrowserfp.so in that order. To load it from somewhere else, call browserfp.load("/path/to/libbrowserfp.so") explicitly.

Usage

Neither binding falls back to a different browser. When the UA is unrecognised, or that version has no profile, you get an error. Sending Safari's UA with Chrome's TLS fingerprint is worse than not impersonating at all — that mismatch is exactly what fingerprint checks look for.

Go

import browserfp "github.com/fizzgate/browserfp/go"

p, err := browserfp.SelectUA(userAgent)   // unknown UA → error, never a guess
if err != nil { return err }

keys, err := p.Keygen()                   // private keys stay in the handle
defer keys.Close()

hello, _ := p.ClientHello("example.com", keys)  // complete TLS record
preface, pseudoOrder, _ := p.H2Preface()

Select failures carry a Reason from a fixed enum (no_ua, unknown_ua, no_profile, no_h2) so callers can aggregate logs and decide what to do — the error string is for humans, don't branch on it.

Lua

The Lua binding is older and still module-level rather than handle-based; aligning it with the Go shape is planned but hasn't happened yet. Real, working usage:

local bfp = require("browserfp")
bfp.load("/path/to/libbrowserfp.so")

local brand, version = bfp.parse_ua(ua)
if not brand then return end                    -- unknown UA → don't guess
local profile = bfp.by_ua(brand, version)
if not profile then return end                  -- no profile for that version

local keys  = bfp.gen_key_shares(brand, version)      -- {shares, derive, free}
local hello = bfp.client_hello(brand, version, "example.com", keys.shares)
local preface, pseudo_order = bfp.h2_preface(brand, version)

Note bfp.h2_akamai(brand, version) returns nil for a handful of versions that have a TLS profile but no HTTP/2 fingerprint — check it before you rely on the pair, or you'll finish a handshake and then have nothing to say. The Go binding checks both layers inside Select for you.

How it's verified

This is the part worth reading.

Third-party echo, with a ledger. The (brand, version) pairs collapse to 44 distinct byte forms. Each one has been sent to a public fingerprint-echo service and confirmed against 8 axes (JA4 both halves, JA3, extension order, ALPN, supported_versions, PSK modes, cert compression, HTTP/2 Akamai). The ledger records when each was last confirmed; an offline gate fails if any form has never been confirmed, has gone stale, or no longer exists.

Real production User-Agents. Coverage numbers describe a set we enumerated. They say nothing about live traffic — users don't hand you a version number, they hand you a UA string, and parse_ua sits in between. A separate gate measures coverage against 60 deduplicated production UAs, splitting two things that must not be averaged together: non-browsers we correctly refuse (scanners, health checks, truncated UAs — impersonating a browser for those would be wrong) versus browsers we can't emit (a real gap).

Mutation testing. Every assertion is checked by breaking the thing it guards and confirming it goes red. Assertions that can't fail have been found here more than once — a padding check that never triggered, a byte-for-byte comparison where both sides used the same default seed, an extension-order metric fooled by rotating GREASE values, and a test server whose "send a HPACK table-size update" switch never actually sent one.

Negative controls. A gate that only ever sees the happy path proves nothing. Certificate decompression is verified against a server that actually compresses — and the gate asserts that it did, because otherwise "the handshake succeeded" only proves the uncompressed path still works.

Cross-binding equality. Go and Lua share the same C implementation and the same profile tables; a test asserts the registry values agree byte for byte. If they drifted, verifying one binding would say nothing about the other.

Running the test suite

pip install -r requirements.txt         # needs cryptography >= 49
python3 spec/verify_all.py              # offline gates, no network

cd go && go test ./...                  # Go binding

A virtualenv is recommended but not required — any Python 3 with the requirements installed works:

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python spec/verify_all.py

cryptography >= 49 is a hard requirement, not a preference: it is the independent cross-check for key exchange, ML-KEM and certificate parsing (spec/test_kx.py). 46.x has no asymmetric.mlkem, so those gates cannot run.

51 offline gates run without network. Network-dependent ones are opt-in (LIVE=1) so a normal test run never touches a public service.

Two things about the first run:

  • Some gates need small Go test servers (a TLS server that forces HelloRetryRequest, a strict HTTP/2 server, one that sends 103 Early Hints). The binaries aren't committed — they're built on demand, so the first run is slower and needs a Go toolchain. Without Go those gates report a labelled SKIP.
  • requirements-dev.txt holds optional packages (curl_cffi, aioquic, wreq) used for corpus collection and a few extra checks. They don't all install everywhere — wreq needs Python ≥3.11, and curl_cffi's macOS wheel fails to load in some environments. Gates that need them SKIP with the exact reason, and the summary counts skips separately from passes, so a machine with nothing installed can never show up as "all green".

Project layout

csrc/      the C library: profile tables, ClientHello builder, key exchange
lua/       Lua binding (LuaJIT FFI)
go/        Go binding (cgo)
spec/      test suite — offline gates, golden vectors, the echo ledger
oracle/    test oracles: Go servers (HRR, strict h2, Early Hints) and collectors
docs/      design notes and the development log

oracle/ is not part of the library and is not built or shipped with it.

Scope

Primarily browsers; runtimes (bun, rust) are being added — the profile registry already carries entries sourced from curl_cffi, utls, tls-client and wreq, though those are where the data came from, not impersonation targets. See NOTICE for the full provenance of the fingerprint data.

QUIC/HTTP-3 is identify-only, deliberately. Emitting it means writing a QUIC transport from scratch (packet and header protection, loss recovery, congestion control, flow control) plus HTTP/3 and QPACK — an order of magnitude more code than the entire TLS stack here, for a handful of profiles versus 570 pairs over HTTP/2. Measured, not assumed: iOS Safari doesn't send UDP at all in the cases checked.

This is not a full browser emulator. It covers TLS and the HTTP/2 opening — not HTTP semantics, not JavaScript, not Canvas or WebGL fingerprints.

Contributing

Issues and pull requests are welcome. Three things specific to this project:

  • A new or changed fingerprint needs a source. Say where the bytes were observed (real browser, or which tool and version) so it can go in the registry with the right source prefix. Fingerprints that can't be traced to an observation can't be verified later.
  • A new assertion has to be able to fail. Break the thing it guards and confirm it goes red before submitting. Every finding listed under How it's verified came from doing this and discovering the assertion was inert.
  • Integer return values are not the C convention. browserfp_parse_ua returns 1 on success; browserfp_kx_keygen, browserfp_kx_derive and browserfp_build_client_hello_ex return the byte length they produced, and -1 on failure. Comparing against 0 turns every success into a failure — this has bitten both bindings.

Run python3 spec/verify_all.py and cd go && go test ./... before opening a PR; both must be green (skips are fine and are counted separately).

License

Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.

Disclaimer

Read DISCLAIMER.md before using this. In short:

  • Provided as is, without warranty. Fingerprints go stale — a green test run means the bytes match what was recorded, not what the current browser sends.
  • No affiliation with Google, Mozilla, Apple, Microsoft, Opera, Cloudflare or Akamai. Browser and fingerprint names are used nominatively, to identify the wire behaviour being reproduced.
  • Impersonating a browser is a dual-use capability. Using it to circumvent access controls you have not been authorised to bypass is out of scope, not supported, and your responsibility alone.

About

Browser network fingerprints — supports all browser version fingerprints

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages