Standard library · Network

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

NameMeant to look like
chrome, edgeA recent Chromium: GREASE values, the hybrid post-quantum key share, ECH GREASE, the ALPS extension, certificate compression, padding to 512 bytes when needed.
chrome_120A 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.
firefoxFirefox: no GREASE, the suites in its own order, a secp256r1 key share next to x25519, delegated credentials, record size limit.
safari, iosSafari and iOS: GREASE, the longer list of older suites, no ECH.
randomizedThe 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 = false to leave the hybrid group out of the hello completely.
  • The hello offers ALPN h2 by default. If the server picks it you are talking to something that expects HTTP/2. For a proxy transport set alpn = "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 Hello owns its buffers: call FreeHello when 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.

net-utls-guide-profiles.salam
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
end
Outputprofiles: chrome,edge,firefox,safari,ios,randomized chrome hello is larger than 400 bytes: true 771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513-65037,29-23-24,0 firefox hello is larger than 400 bytes: true 771,4865-4867-4866-49195-49199-52393-52392-49196-49200-49162-49161-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-34-51-43-13-45-28-65037,29-23-24-25-256-257,0 safari hello is larger than 400 bytes: true 771,4865-4866-4867-49196-49195-52393-49200-49199-52392-49162-49161-49172-49171-157-156-61-60-53-47-49160-49170-10,0-23-65281-10-11-16-5-13-18-51-45-43-27-21,29-23-24-25,0 unknown profile is ok: false

A 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.

net-utls-guide-handshake.salam
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)
end

Limits

  • 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 0x0301 as 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
A byte buffer that this package allocates.
NameTypeDescription
pvoid*The start of the bytes.
lenint = 0How many bytes are used.
capint = 0How many bytes are allocated.

Spec

struct Spec
The description of a client family's ClientHello.
NameTypeDescription
namestr = ""The profile name.
ciphersstr = ""The cipher suites in order, as hex numbers. g0 is a GREASE value.
extensionsstr = ""The extension ids in wire order. g1 and g4 are GREASE extensions and pad is where padding goes.
groupsstr = ""The supported groups in order.
key_sharesstr = ""The key shares to send: 001d x25519, 0017 secp256r1, 11ec X25519MLKEM768, g2 GREASE.
sigalgsstr = ""The signature algorithms in order.
versionsstr = ""The supported versions in order, g3 for a GREASE value.
alpnstr = ""The ALPN protocol names, comma separated.
cert_compressstr = ""The certificate compression algorithms, as hex numbers.
delegatedstr = ""The delegated credential signature algorithms, as hex numbers.
record_size_limitint = 0The value of the record size limit extension, 0 when the profile has none.
pad_toint = 0Pad the hello to this size when it is between 256 bytes and this size. 0 disables padding.
randomizebool = falseShuffle the extensions and the suites on every build.
okbool = falseFalse for an unknown profile name.

Options

struct Options
What changes between two hellos of the same profile.
NameTypeDescription
server_namestr = ""The server name, empty for none.
alpnstr = ""Replaces the ALPN list, comma separated. none removes the extension. Empty keeps the profile's list.
shufflebool = falseShuffle the extension order, keeping the GREASE extensions at both ends and the padding last.
pqbool = trueInclude the hybrid post-quantum group and its key share. False leaves them out.
echbool = trueInclude the ECH GREASE extension if the profile has it.
paddingbool = trueAdd the padding extension if the profile has one.

Hello

struct Hello
A built ClientHello and the key pair that goes with it.
NameTypeDescription
rawBytesThe handshake message, from the type byte on, without the record header.
privBytesThe x25519 private key of the key share.
pub_keyBytesThe x25519 public key of the key share.
pq_seedBytesThe 64 byte ML-KEM seed behind the X25519MLKEM768 share. Empty when the hello has no such share.
pq_ekBytesThe ML-KEM-768 encapsulation key that went into the share.
okbool = falseThe hello was built.
errstr = ""Why it was not built.

Functions

JA3

func JA3(b: Bytes): str
The JA3 text of a hello: version, suites, extensions, groups and point formats, GREASE removed.

FreeBytes

func FreeBytes(b &: Bytes)
Releases a buffer.

FreeHello

func FreeHello(h &: Hello)
Releases the buffers of a hello.

ToHex

func ToHex(b: Bytes): str
The bytes as lowercase hex.

SessionIdOffset

func SessionIdOffset(): int
Where the 32 byte session id starts inside raw.

SetSessionId

func SetSessionId(h &: Hello, sid: void*)
Overwrites the 32 byte session id of a built hello.

Build

func Build(sp: Spec, o: Options): Hello
Builds a hello from a spec and options, with a fresh random, key pair and session id.

Profile

func Profile(name: str): Spec
The spec of a named profile. Check ok.

ProfileNames

func ProfileNames(): str
The names of the profiles that work with current servers, comma separated. randomized is built on chrome, so it has the hybrid key share too.

LegacyProfileNames

func LegacyProfileNames(): str
The names of the legacy profiles, comma separated (chrome_120, which has no hybrid key share and is refused by Xray-core 26.9.9 and later).

Record

func Record(h: Hello): Bytes
The hello wrapped in a TLS record (type 22, version 0x0301).

PqSeed

func PqSeed(h: Hello): void*
The ML-KEM seed of a hello, or null when it has none. tls.WrapClientRawPq takes it.

WrapClient

func WrapClient(fd: i64, sp: Spec, o: Options, cfg: tls.Config): tls.Conn
Builds a hello and runs a TLS 1.3 client handshake with it on a connected socket.