net.utls
TLS ClientHello fingerprints: build the first message of a TLS 1.3 handshake in the byte layout of Chrome, Firefox or Safari, compute JA3 strings, and run a handshake from it.
import net.utls
A firewall that cannot read your traffic can still read your first message. The ClientHello of a TLS connection lists the cipher suites, the extensions and their order, the curves and the signature algorithms the client supports, and every TLS library lists them in its own way. That list is a fingerprint (JA3 is the best known way to write it down), and a proxy client that uses the default list of its TLS library has a fingerprint no browser has. This package builds the ClientHello itself, from a description that you can read and change, so that it has the layout of a browser.
Profile returns a Spec for a client family. Build turns a spec and some Options (the server name, the ALPN list, whether to shuffle the extensions) into a Hello: the message bytes, with a fresh random, a fresh x25519 key pair and a random session id. WrapClient then runs a TLS 1.3 handshake on a connected socket with that hello and returns an ordinary tls.Conn. JA3 writes down the fingerprint of any hello, and Record wraps it into a TLS record, for example to hand it to the fragmenting helpers of net.censor. net.reality uses this package for the hello it seals its proof into.
The profiles
| Name | Meant to look like |
|---|---|
chrome, edge | A recent Chromium: GREASE values, the hybrid post-quantum key share, ECH GREASE, the ALPS extension, certificate compression, padding to 512 bytes when needed. |
chrome_120 | A legacy profile: a Chromium from before the hybrid key share, with no post-quantum group. Xray-core 26.9.9 and later (REALITY since 2026-09-08) drop a hello without X25519MLKEM768, so use it only against older servers (it authenticates against Xray 26.7.28). It is not in ProfileNames; LegacyProfileNames lists it. |
firefox | Firefox: no GREASE, the suites in its own order, a secp256r1 key share next to x25519, delegated credentials, record size limit. |
safari, ios | Safari and iOS: GREASE, the longer list of older suites, no ECH. |
randomized | The Chrome layout with the extensions and the suites shuffled, so that no two hellos have the same JA3. |
These are approximations written from memory of what these clients send, not captures. A comparison with a capture of the real browser was not possible here. The structure was checked in two ways: the hellos parse with net.censor.ParseClientHello, and they complete a real TLS 1.3 handshake with the tls server. Browsers change their hello with each release, so treat a profile as a starting point and edit the Spec (it is plain text lists) to match the browser you want to look like.
How a spec is written
The lists in a Spec are text of hexadecimal numbers separated by spaces. g0 to g4 are GREASE slots that become a random reserved value (0a0a, 1a1a and so on); pad marks where the padding extension goes. extensions is the order on the wire; 0000 is the server name, 0010 ALPN, 0033 the key share, fe0d ECH GREASE and so on. key_shares says which shares to send: 001d is x25519 with the key pair of the hello, 0017 is a secp256r1 share with random bytes, 11ec is X25519MLKEM768.
What trips people up
- The key shares other than x25519 have no key behind them. The secp256r1 share is random bytes, and the ML-KEM part of the hybrid share is a well formed random encapsulation key. A server that picks one of those groups cannot finish the handshake with you; it can for x25519. Pass
pq = falseto leave the hybrid group out of the hello completely. - The hello offers ALPN
h2by default. If the server picks it you are talking to something that expects HTTP/2. For a proxy transport setalpn = "http/1.1", or"none"to leave the extension out. - Some bytes are random on purpose. GREASE values, the ECH length, the random, the session id and the key shares change with every
Build. JA3 ignores GREASE and key bytes, so the JA3 of a profile is stable. - Only TLS 1.3, and no HelloRetryRequest. The handshake from a custom hello fails with a clear message if the server wants a different group or only speaks TLS 1.2.
- Free the hello. A
Helloowns its buffers: callFreeHellowhen you are done.
Building a hello and reading its JA3
The example builds one hello for each of three profiles with the hybrid share left out, and prints the JA3 strings. A JA3 string is the version, the cipher suites, the extension ids, the curves and the point formats, each as decimal numbers, with GREASE removed. The order of the extension ids is the clearest difference between the profiles.
import net.utls
func main:
println "profiles:", utls.ProfileNames()
mut o := utls.Options {}
o.server_name = "www.example.com"
o.pq = false
each name in ["chrome", "firefox", "safari"]:
mut h := utls.Build(utls.Profile(name), o)
println name, "hello is larger than 400 bytes:", h.raw.len > 400
println utls.JA3(h.raw)
utls.FreeHello(h)
end
println "unknown profile is ok:", utls.Profile("netscape").ok
endA handshake from a profile
WrapClient takes a connected socket, a spec, the options and a tls.Config. The certificate is checked the normal way. The result is a tls.Conn, so every tls function and the TLS support of net.transport work with it.
import net.tcp
import net.utls
import tls
func main:
c := tcp.Dial("www.example.com", 443)
mut o := utls.Options {}
o.server_name = "www.example.com"
o.alpn = "http/1.1"
cfg := tls.NewConfig("www.example.com")
mut t := utls.WrapClient(c.fd, utls.Profile("chrome"), o, cfg)
if not tls.ConnOk(t):
println "failed:", tls.ConnError(t)
ret 1
end
tls.Write(t, "GET / HTTP/1.1\r\nHost: www.example.com\r\nConnection: close\r\n\r\n")
println tls.Read(t, 200)
tls.Close(t)
endLimits
- The profiles are approximations, not captures. They were not compared with a live browser, and the real clients change from release to release.
- There is no ML-KEM. The hybrid share is a placeholder (see above), and a server that selects it breaks the handshake.
- No session resumption and no pre-shared key extension. The hello is always a first connection.
- The ECH extension is GREASE. It has the shape of a real one and nothing is encrypted in it.
- The record layer and the handshake are the library's. The first record has the legacy version
0x0301as browsers do, but the lengths and timing of what comes after the hello are those of the tls package, not of a browser. - TLS 1.3 only. A server that answers with TLS 1.2 fails the handshake.
Types
Bytes
struct Bytes| Name | Type | Description |
|---|---|---|
p | void* | The start of the bytes. |
len | int = 0 | How many bytes are used. |
cap | int = 0 | How many bytes are allocated. |
Spec
struct Spec| Name | Type | Description |
|---|---|---|
name | str = "" | The profile name. |
ciphers | str = "" | The cipher suites in order, as hex numbers. g0 is a GREASE value. |
extensions | str = "" | The extension ids in wire order. g1 and g4 are GREASE extensions and pad is where padding goes. |
groups | str = "" | The supported groups in order. |
key_shares | str = "" | The key shares to send: 001d x25519, 0017 secp256r1, 11ec X25519MLKEM768, g2 GREASE. |
sigalgs | str = "" | The signature algorithms in order. |
versions | str = "" | The supported versions in order, g3 for a GREASE value. |
alpn | str = "" | The ALPN protocol names, comma separated. |
cert_compress | str = "" | The certificate compression algorithms, as hex numbers. |
delegated | str = "" | The delegated credential signature algorithms, as hex numbers. |
record_size_limit | int = 0 | The value of the record size limit extension, 0 when the profile has none. |
pad_to | int = 0 | Pad the hello to this size when it is between 256 bytes and this size. 0 disables padding. |
randomize | bool = false | Shuffle the extensions and the suites on every build. |
ok | bool = false | False for an unknown profile name. |
Options
struct Options| Name | Type | Description |
|---|---|---|
server_name | str = "" | The server name, empty for none. |
alpn | str = "" | Replaces the ALPN list, comma separated. none removes the extension. Empty keeps the profile's list. |
shuffle | bool = false | Shuffle the extension order, keeping the GREASE extensions at both ends and the padding last. |
pq | bool = true | Include the hybrid post-quantum group and its key share. False leaves them out. |
ech | bool = true | Include the ECH GREASE extension if the profile has it. |
padding | bool = true | Add the padding extension if the profile has one. |
Hello
struct Hello| Name | Type | Description |
|---|---|---|
raw | Bytes | The handshake message, from the type byte on, without the record header. |
priv | Bytes | The x25519 private key of the key share. |
pub_key | Bytes | The x25519 public key of the key share. |
pq_seed | Bytes | The 64 byte ML-KEM seed behind the X25519MLKEM768 share. Empty when the hello has no such share. |
pq_ek | Bytes | The ML-KEM-768 encapsulation key that went into the share. |
ok | bool = false | The hello was built. |
err | str = "" | Why it was not built. |
Functions
JA3
func JA3(b: Bytes): strFreeBytes
func FreeBytes(b &: Bytes)FreeHello
func FreeHello(h &: Hello)ToHex
func ToHex(b: Bytes): strSessionIdOffset
func SessionIdOffset(): intraw.SetSessionId
func SetSessionId(h &: Hello, sid: void*)Build
func Build(sp: Spec, o: Options): HelloProfile
func Profile(name: str): Specok.ProfileNames
func ProfileNames(): strrandomized is built on chrome, so it has the hybrid key share too.LegacyProfileNames
func LegacyProfileNames(): strchrome_120, which has no hybrid key share and is refused by Xray-core 26.9.9 and later).Record
func Record(h: Hello): BytesPqSeed
func PqSeed(h: Hello): void*tls.WrapClientRawPq takes it.WrapClient
func WrapClient(fd: i64, sp: Spec, o: Options, cfg: tls.Config): tls.Conn