TlsClient
TlsClient is the client-side TLS wrapper for WaterWall. It is built on the bundled BoringSSL code in the repository.
It receives cleartext from the previous tunnel, encrypts it into TLS records for the next tunnel, and decrypts downstream
TLS records back into cleartext.
This node performs a real TLS client handshake. Its handshake is shaped to stay close to modern Chrome behavior, including
Chrome-like ALPN defaults, ALPS, GREASE, extension permutation, certificate compression support, and the optional
X25519MLKEM768 hybrid group.
Typical Placement
HTTPS-style client chain:
HttpClient -> TlsClient -> TcpConnector
Proxy-protocol client chain:
TrojanClient -> TlsClient -> TcpConnector
VlessClient -> TlsClient -> TcpConnector
TlsClient does not open sockets by itself. Put a transport such as TcpConnector after it. The connector carries the
encrypted TLS bytes to the remote server.
Example
[
{
"name": "tls-client",
"type": "TlsClient",
"settings": {
"sni": "example.com",
"alpns": ["http/1.1"],
"verify": true,
"x25519mlkem768": true,
"verbose": false
},
"next": "server-out"
},
{
"name": "server-out",
"type": "TcpConnector",
"settings": {
"address": "example.com",
"port": 443,
"nodelay": true
}
}
]
Required Fields
Top-level fields:
| Field | Type | Description |
|---|---|---|
name | string | User-chosen node name. Must be unique inside the config file. |
type | string | Must be exactly "TlsClient". |
settings | object | TLS client settings. |
next | string | Stream-facing node that carries encrypted TLS records. Required in normal use. |
Required settings field:
| Field | Type | Description |
|---|---|---|
sni | string | TLS Server Name Indication containing between 1 and 255 bytes. |
sni is also the hostname used by BoringSSL for certificate verification when verify is enabled.
Optional Settings
| Field | Default | Description |
|---|---|---|
alpns | ["h2", "http/1.1"] | Ordered ALPN protocol offer. The JSON order is preserved in the ClientHello. |
verify | true | Enables BoringSSL peer certificate verification. |
x25519mlkem768 | true | Advertises the X25519MLKEM768 hybrid key-agreement group; does not change signature algorithms. |
fragment | absent | Optional private StreamFragmenter settings for outgoing TLS bytes. |
tls13-record-shaping | absent | Experimental sender-side TLS 1.3 padding and delay configuration. |
ech-config-list | absent | Standard Base64 ECHConfigList for required real ECH. |
verbose | false | Enables extra TLS state logging. |
When verify is true, TlsClient loads the built-in CA bundle from utils/cacert.h into the BoringSSL context. When
verify is false, the TLS handshake is still real, but certificate-chain verification is disabled for this tunnel
instance.
Optional defaults apply only when their keys are absent. A present alpns value must be an array of strings. Each name
must contain between 1 and 255 bytes, duplicate names are rejected, and the encoded list may not exceed 65,533 bytes.
Present verify, x25519mlkem768, and verbose values must be JSON booleans. A wrong type is a startup error rather
than a request to use the default.
x25519mlkem768 should usually stay enabled. Disabling it makes the ClientHello smaller, but also makes it less close to
current Chrome behavior.
Configured ECH
ech-config-list accepts a nonempty standard Base64 string containing a serialized ECHConfigList for the destination.
The configuration is fixed for the node's lifetime. Startup rejects malformed Base64, an invalid ECHConfigList, or a
list with no supported configuration. ech-config-list and ech-sni-trick cannot be used together.
Ordinary connections encrypt settings.sni in ClientHelloInner. The visible ClientHelloOuter uses the selected
ECHConfig's public_name; users do not configure a second SNI. With this setting absent, the existing ECH GREASE
behavior remains unchanged.
Configured ECH must be accepted for the connection to succeed. Rejection closes the connection without releasing application data, retrying the transport, or falling back to a cleartext SNI or GREASE. TlsClient does not discover configurations through HTTPS DNS records, refresh them, or apply server retry configurations.
Normal certificate verification still applies to the configured inner SNI. verify: false remains an explicit
server-authentication opt-out; it does not permit ECH rejection or enable an authenticated retry path. 0-RTT remains
disabled. TLS 1.3 resumption can use the existing session cache, with the PSK offer encrypted inside ClientHelloInner;
TLS 1.2 sessions are not offered with real ECH.
The generateTlsHello:<sni> API remains a fresh, arbitrary-hostname GREASE generator. It does not apply the node's
fixed-origin ech-config-list or consume cached sessions. Startup preflight measures the actual configured outer
ClientHello flight so its size checks also cover real ECH.
TLS Stream Fragmentation
An optional fragment object creates a private StreamFragmenter immediately after
TlsClient:
previous -> TlsClient -> internal StreamFragmenter -> configured next
"fragment": {
"mode": "counter",
"count": 1,
"tls-hello-fragment": true,
"tls-hello-timeout-ms": 1000,
"cuts": [[250, 2, 100], [300, 5, 100]]
}
The object uses StreamFragmenter's settings and strict validation: mode,
count or duration-ms, cuts, optional bypass_chance, wait-for-est,
tls-hello-fragment, and tls-hello-timeout-ms. Omit fragment to omit the
helper. A present value must be a valid object; null, booleans, empty objects,
and duplicate keys are rejected.
With tls-hello-fragment absent or false, cuts split selected outgoing TLS
Payload callbacks at raw byte offsets. Every TLS byte, including record headers,
stays unchanged. TlsClient's complete initial TLS flight enters the helper as
one Payload, so count: 1 selects that first flight in this mode.
With tls-hello-fragment: true, cuts count ClientHello handshake bytes, including
the four-byte handshake header and excluding each five-byte TLS record header.
Only the initial ClientHello is eligible. Selected cuts create valid, smaller
handshake records while preserving the complete handshake message, original
record boundaries, and record versions. Record headers change; encrypted records
and downstream TLS input do not. The complete message is capped at 64 KiB.
Non-TLS or unsupported framing, an oversized hello, bypass, and an incomplete
hello reaching tls-hello-timeout-ms pass the original wire bytes unchanged and
end detection for that line. The timeout starts on the first eligible non-empty
input, including time waiting for Est. Finish discards an incomplete
candidate instead of replaying it. The option does not infer SNI-relative cuts
or provide independent TLS and TCP cut profiles.
wait-for-est defaults to true: output waits until transport Est has been
forwarded, then fragment delays start. The TLS assembly deadline may run before
Est or during advisory Pause; a completed rewrite is no longer subject to that
deadline. Pause/Resume pass through transparently, including duplicates, and
ready output does not wait for Resume.
Timed eligibility starts at the first Est by default. Explicit wait-for-est: false permits output scheduling before Est. Later buffering may combine writes
or change spacing on the wire; neither mode promises TCP packet boundaries.
The helper borrows the same line and uses its own bounded FIFO: 8 MiB logical bytes, 8 MiB capacity charge, and 1,024 jobs. Reentry, Finish, and shutdown use StreamFragmenter's normal ordering and cleanup. The helper releases its own pressure at its low watermarks, independently of forwarded Pause/Resume. When shaping and the startup wait have finished and retained work has drained, it destroys its line state and permanently bypasses itself. TlsClient remains active for TLS processing. This budget is separate from TLS plaintext and ciphertext-shaping storage. TlsClient still blocks whole-chain splice eligibility.
tls13-record-shaping can be used with fragment: its record padding and
delays run first, followed by StreamFragmenter. The raw ClientHello-generation
API returns its normal buffer without entering this per-line helper.
Experimental TLS 1.3 Record Shaping
tls13-record-shaping optionally pads and delays the first locally sent TLS 1.3 application records. It is disabled
when absent. It never changes received ciphertext, handshake records, alerts, empty application records, fallback bytes,
or TLS 1.2 records. Configure the local TlsClient and remote TlsServer separately when both sending directions should
be shaped.
Custom form:
"tls13-record-shaping": {
"scope": {"first-application-records": 8},
"outcomes": [
{
"probability": 50,
"padding-bytes": [100, 200],
"delay": {"probability": 75, "ms": [10, 20]}
},
{"probability": 10, "padding-bytes": [400, 600]}
]
}
padding-bytes and delay.ms accept either an integer or an inclusive [minimum, maximum] integer range. There may be
1 through 16 outcomes; first-application-records is 1 through 1024, padding is 1 through 4096 bytes, and delay is 0
through 1000 milliseconds. Outcome probabilities are cumulative, mutually exclusive percentages whose sum may not exceed
100. One roll selects at most one outcome; the unused percentage is an unchanged record. An outcome's delay probability
is rolled only after that outcome is selected. Unknown keys and non-integer or out-of-range values are startup errors.
Every eligible record consumes one scope position even when its roll selects no outcome.
Only the custom scope plus outcomes form is currently accepted. The profile key is rejected until representative
capture, overhead, connection-success, and classifier measurements justify publishing a versioned preset.
Padding is standard zero-valued TLS 1.3 TLSInnerPlaintext padding and adds the selected number of bytes to ciphertext,
subject to the remaining legal TLS record capacity. Delay starts after encryption and adds up to the selected latency.
Records stay in wire order with release_at = max(now + delay, previous_release_at). Queued ciphertext is bounded to 8
MiB per line, with locally originated producer Pause at 6 MiB and Resume at 3 MiB. Incoming Pause and Resume
are relayed transparently, including duplicates. Due ciphertext drains keep their existing timing and FIFO order without
waiting for Resume; Pause does not cancel the release timer. If the cleartext side finishes, the timer is canceled and queued client ciphertext is synchronously
released in FIFO order, including during advisory Pause. Local state and upstream Finish are completed before the
callback returns.
After the configured application-record scope is consumed, the current shaping queue first drains completely so no later TLS record can overtake delayed ciphertext. While that ordering barrier is active, the node retains its local shaping hold even below the normal low watermark; relayed Resume remains advisory. At an empty queue and complete write-BIO boundary, the timer and per-line shaping queue are released; later TLS output is forwarded directly without record parsing or shaping metadata, including during advisory Pause. The 8 MiB limit and 6/3 MiB watermarks therefore bound only the finite active shaping window. TLS record encryption and decryption remain active, so shaping retirement does not permanently bypass the tunnel.
During this synchronous final drain, downstream Payload, Est, Pause and Resume are discarded; none of those callbacks is forwarded toward the finished cleartext owner. A wire-side finish cancels and discards queued output and is propagated only toward cleartext. Timer allocation failure drains immediately in order.
Delay shaping is rejected for internal handshake-takeover users such as RealityClient, because raw takeover cannot
inherit a pending delayed TLS record. Padding-only shaping remains compatible when the takeover boundary is empty.
Record shaping can make early sizes and timings less deterministic, but it cannot remove an inner handshake round trip,
make a burst smaller, or hide every direction and timing feature. Combine it with MuxClient/MuxServer when
multiplexing fits the deployment.
Ordered ALPN And Application-Protocol Responsibility
alpns controls the ordered ALPN offer in the TLS ClientHello. For example:
"alpns": ["http/1.1"]
The array order is preserved. If alpns is absent, the Chrome-like default is:
"alpns": ["h2", "http/1.1"]
An explicit empty array disables ALPN. The singular key alpn is not supported.
The configured order expresses the client's offer, but the TLS server selects the negotiated protocol. TlsClient does
not report that selection to the previous WaterWall node, does not switch that node's HTTP mode, and does not verify that
the cleartext application protocol matches the selected ALPN. The user is responsible for making these configurations
agree.
This matters especially in an HttpClient -> TlsClient chain:
- For HTTP/1.1 WebSocket Upgrade, configure
HttpClientfor HTTP/1.1 and configureTlsClientwith only"alpns": ["http/1.1"]. - For WebSocket over HTTP/2, configure
HttpClientfor HTTP/2 so it uses the HTTP/2 CONNECT path, and configureTlsClientwith only"alpns": ["h2"]. - Offering both
h2andhttp/1.1is valid, commonly used, and closer to Chrome's default ClientHello. When using that offer with a fixed precedingHttpClient, the user must know which protocol the target server will select and configureHttpClientfor that version. If the target's choice is unknown or can vary, offer only the protocol that matchesHttpClient.
As of July 2026, for ordinary HTTPS through a Cloudflare-proxied hostname with HTTP/2
enabled, Cloudflare selects h2 when it is
offered. It is therefore reasonable to keep the Chrome-like "alpns": ["h2", "http/1.1"] offer and configure the
preceding HttpClient for HTTP/2.
This does not apply to WebSocket. Cloudflare's currently documented proxied WebSocket
behavior does not support HTTP/2 WebSocket extended CONNECT
(RFC 8441). For Cloudflare WebSocket, force HTTP/1.1 WebSocket Upgrade by configuring TlsClient with only
"alpns": ["http/1.1"] and configuring HttpClient for HTTP/1.1. Re-check Cloudflare's current capabilities before
depending on either behavior, because provider settings and support can change.
Multiple ALPN offers are appropriate when the target server's selection is known and stable. They are not automatic
application-protocol discovery: the preceding node must already be configured for the protocol the server will select.
Changing alpns also changes a fingerprint-relevant part of the otherwise Chrome-like ClientHello.
When the handshake completes, TlsClient writes the negotiated TLS version, cipher, and ALPN to the debug log. A selected
protocol appears as alpn="..."; a handshake without negotiated ALPN appears as alpn=<none>. This is diagnostic output
only. The selected value is still not passed to the preceding application tunnel.
The TLS context is configured with:
| Behavior | Current value |
|---|---|
| Minimum TLS version | TLS 1.2 |
| Maximum TLS version | TLS 1.3 |
| Session cache | At most two sessions per node and worker |
| TLS 1.2 session timeout | 3600 seconds |
| TLS 1.3 session timeout | BoringSSL session lifetime, capped by the server ticket lifetime |
| GREASE | enabled |
| Extension permutation | enabled |
| Certificate compression | Brotli decompression support |
| Signed certificate timestamps | enabled |
| OCSP stapling request | enabled per line |
Ignoring randomized GREASE entries, the default supported groups are:
X25519MLKEM768:X25519:P-256:P-384
The initial TLS 1.3 key_share offer contains X25519MLKEM768 followed by X25519, in addition to GREASE.
The default supported-group order matches the Chrome 154 reference; this does not imply that the complete handshake
matches Chrome.
The explicit x25519mlkem768: false opt-out remains supported and removes the hybrid group:
X25519:P-256:P-384
In that mode, X25519 is the only non-GREASE initial key share.
The signature_algorithms offer starts with a randomized GREASE value, followed by these algorithms in the order
captured from Chrome 154:
ML-DSA-44
ML-DSA-65
ML-DSA-87
ecdsa_secp256r1_sha256
rsa_pss_rsae_sha256
rsa_pkcs1_sha256
ecdsa_secp384r1_sha384
rsa_pss_rsae_sha384
rsa_pkcs1_sha384
rsa_pss_rsae_sha512
rsa_pkcs1_sha512
Setting x25519mlkem768 to false changes key agreement only; ML-DSA signature schemes remain advertised. Ordinary
TLS connections and the raw ClientHello generator use the same context configuration for this offer. Matching these
fields does not establish complete Chrome fingerprint equivalence. The bundled BoringSSL copy also preserves local
handshake shaping for cipher ordering and ClientHello layout.
ALPS Handling
TlsClient registers ALPS only for h2, and only when h2 is in alpns. The registered client application settings
are empty, matching Chrome's HTTP/2 registration. http/1.1 and custom ALPN protocols have no ALPS registration;
when h2 is absent, the ClientHello has no ALPS extension. ALPS uses extension codepoint 17613 (0x44cd).
The bytes 02 68 32 encode the length-prefixed protocol name h2; they are not application settings.
SSL_add_application_settings takes the protocol name and its application settings separately, and BoringSSL serializes
the ClientHello ALPS protocol list as 00 03 02 68 32. If the server negotiates ALPS for h2, the client sends an empty
application-settings value in the encrypted handshake. Do not substitute a protocol-name encoding or an HTTP/2
SETTINGS frame for that empty value.
TlsClient does not parse, apply, or forward received server application settings to the preceding node. Its ALPS
configuration shapes the TLS exchange visible to a passive observer; it does not implement Chrome's HTTP/2 response
to server settings or make the connection indistinguishable from Chrome to the server.
Trust Anchor IDs
TlsClient advertises the 28 Trust Anchor IDs captured from Chrome 154.0.8037.97, using Chrome Root Store 39.
BoringSSL encodes them in extension 0xca34. The IDs identify 13 roots already present in WaterWall's built-in CA
bundle and 15 intermediate certificates. Their source paths and certificate fingerprints are recorded in
tests/unittests/fixtures/tlsclient_chrome/trust_anchors.json.
With verification enabled, each SSL context owns a cache of those 15 intermediates. When the server reports a matching
Trust Anchor ID, the verifier adds the cached certificates as untrusted chain-building candidates so the server can
omit them from its certificate message. They do not become trusted roots. Normal certificate signatures, validity,
hostname, purpose, path-length and name constraints still apply, and the chain must reach an existing trusted root.
The ID advertisement also remains present with verify: false; that setting still disables server authentication.
The ID list and intermediate cache are a paired, pinned profile. Update them together against their source mappings and the verifier's supported roots.
ClientHello Reference Comparison
waterwall.tlsclient_alpn_unit compares the complete initial ClientHello with the checked-in capture of Google Chrome
154.0.8037.97 on Linux x86-64 with AES hardware. The reference case uses a fresh TCP connection, SNI
tls.integration.test, default ALPN and groups, and ordinary ECH GREASE without custom overrides or fragmentation.
The comparison covers record and handshake framing, versions, cipher order, compression, the complete extension set, and extension payloads, including Trust Anchor IDs. It normalizes random bytes, GREASE values and interior extension permutation, while preserving the leading empty and trailing one-byte GREASE extension positions and payloads. Key-share lengths and the permitted ECH GREASE framing and payload sizes are also checked. This is an initial-ClientHello comparison for that profile; it does not establish resumption, real ECH, later record sizes or timing, or application behavior matching Chrome.
Session Resumption
Each TlsClient node keeps a separate, two-session cache in each worker's SSL context. The context fixes the configured
SNI, ALPN and verification policy. Runtime connections check their SNI and verification mode before using the cache;
constructor preflight, raw ClientHello generation and ECH-inner generation always remain fresh and neither consume
nor add cached sessions. The cache belongs to the SSL context and is released with it.
Ticket size is capped using the worst-case fresh ClientHello preflight, with space reserved for PSK framing and, with real ECH, up to 31 bytes for inner padding. Oversized tickets are discarded, keeping later ClientHellos within their size limit even with large custom ALPN or ECH settings. If no usable session remains, the next connection starts with a fresh handshake.
TLS 1.3 tickets are single-use: selecting one for a connection removes it from the cache, even if that connection fails before sending or the server declines resumption. TLS 1.2 ECDHE sessions can be reused. Expiry follows each stored session's creation time and timeout. TLS 1.2 sessions have a one-hour timeout; TLS 1.3 follows BoringSSL's session lifetime and the server's ticket lifetime. A server can always decline an offered session and perform a full handshake.
With verify: true, the cached peer certificates are revalidated before a session is offered, using the current
connection's roots, hostname and verification parameters. The pinned TAI intermediates remain untrusted candidates
when rebuilding an elided chain. An invalid or expired cached identity is discarded; if no usable session remains,
the connection performs a full handshake. verify: false retains its explicit server-authentication opt-out.
This cache is partitioned by node and worker. Chrome's browser-wide port, proxy and privacy partitioning is outside that scope. Static-RSA TLS 1.2 sessions are excluded because Chrome binds them to the peer IP, and this generic TLS transform cannot establish that endpoint from mutable routing metadata.
Tickets are collected as TLS input is processed, including tickets encountered during a Reality TLS 1.3 takeover drain. Takeover does not wait for a ticket, so resumption is opportunistic. 0-RTT remains disabled and application data waits for the handshake to complete.
Bundled TLS Library
The bundled BoringSSL revision is ac39ea6853833c1f18fd23614091d11855e71752, selected by Chromium 154.0.8037.97.
This dependency pin does not imply complete Chrome fingerprint or application behavior equivalence. Local patches
preserve fixed cipher ordering, ECH GREASE overrides, TLS 1.3 record padding, and direct record output buffers.
WaterWall builds static BoringSSL libraries with the WW_BSSL symbol prefix, separately from ordinary OpenSSL.
Upstream's checked-in prefix headers cover public, internal, and architecture-specific assembly symbols; a local
prefix-header patch adds the custom SSL APIs. Normal builds do not need Go or Python for prefix generation.
When updating the dependency, port the record-padding patch before the direct-output patch, apply the public prefix
patch after all API patches, and retain the TLS/Reality and buffer-ownership regression coverage.
Runtime Behavior
On upstream Init, TlsClient:
- initializes per-line BoringSSL state
- creates owned-buffer ciphertext BIOs backed by the line's buffer pool
- switches the SSL object into client mode
- sets the configured SNI
- forwards upstream
Initto the next tunnel - calls
SSL_connect()to generate the first ClientHello flight - forwards generated TLS handshake bytes upstream
This is why TlsClient works naturally before TcpConnector: the connector can buffer the ClientHello until the real
socket connection completes.
Establishment Semantics
WaterWall Est is transport establishment, not TLS readiness.
Downstream transport Est is forwarded immediately and once in both ordinary and takeover modes. Payload may arrive
after adjacent Init, before either transport Est or TLS completion. Plaintext waiting for TLS readiness or older reentrant
output is bounded to 2 MiB and 1,024 buffers, including its active retained head. Equality is accepted; transactional
admission failure closes the borrowed line through its owner and releases retained buffers. Handshake completion
releases this FIFO through SSL_write() without waiting for wire Resume; genuine local shaping and ordering barriers
still apply. New input cannot overtake the retained head. Ciphertext-shaping limits remain separate.
The first retained plaintext buffer is published before notifying the cleartext source with Pause. Locally originated
protocol/order and ciphertext-shaping pressure share one source notification latch and release it when their work settles.
Incoming Pause and Resume are relayed independently, including duplicates. This source pressure does not pause incoming
TLS handshake records.
Takeover registers a dedicated owner completion callback with tlsclientTunnelEnableHandshakeTakeover(t, owner, callback).
TlsClient frames takeover input one TLS record at a time and stops at the exact record that completes the handshake,
leaving later records outside the SSL object. It publishes completion before invoking the callback, retains the exact line,
and rechecks life and TLS state afterwards. Neither completion nor later Reality activation emits another Est.
Payload Flow
Upstream cleartext:
- before handshake completion, payload is queued
- after handshake completion, payload is passed to
SSL_write() - generated TLS records are taken from the write BIO as owned buffers and sent to the next node after the SSL call returns
Downstream TLS records:
- encrypted input buffers transfer ownership to the read BIO
SSL_connect()advances the handshake while needed- generated protocol bytes are flushed upstream
- decrypted application bytes from
SSL_read()are forwarded to the previous node
Certificate verification failures are logged with the BoringSSL verify result and reason.
Eligible nonempty TLS 1.2/1.3 application writes use a local BoringSSL hook to encrypt directly into pooled output buffers. Handshakes, pending control flights, write retries and declined reservations use the ordinary copying path. This is automatic and adds no JSON setting. Buffer ownership and the reservation contract are described in Developer Guide Part 3.
Tunnel API
TlsClient exposes a small API for generating a raw ClientHello buffer:
generateTlsHello:<sni>
The generated ClientHello follows the configured alpns and x25519mlkem768 settings. The API SNI must contain between
1 and 255 bytes. It always starts fresh, does not consume cached sessions, and retains GREASE behavior rather than
applying the fixed-origin ech-config-list.
Handshake Takeover API
Internal WaterWall code can use TlsClient in handshake-takeover mode:
| API | Purpose |
|---|---|
tlsclientTunnelEnableHandshakeTakeover() | Registers the owner and its handshake-ready callback and enables takeover. |
tlsclientTunnelIsHandshakeCompleted() | Checks whether a line has completed TLS handshake. |
tlsclientTunnelGetHandshakeBinding() | Captures the negotiated version, cipher, randoms, and TLS 1.2 sequence state while BoringSSL is retained. The client random is the emitted ClientHelloOuter random, including with accepted ECH. |
tlsclientTunnelDeinitAfterHandshake() | TLS 1.2-only immediate release into raw pass-through. It rejects TLS 1.3. |
tlsclientTunnelBeginTakeoverDrain() | Starts TLS 1.3 external post-handshake dispatch and returns accumulated raw bytes while retaining SSL/BIO state. |
tlsclientTunnelConsumePostHandshakeRecord() | Consumes exactly one complete TLS 1.3 record, discards cover plaintext, and flushes generated protocol output upstream. |
tlsclientTunnelCompleteTakeover() | Releases retained TLS state after the owner authenticates its handoff boundary and enters raw pass-through. |
The phased TLS 1.3 API allows legal NewSessionTicket and KeyUpdate records to remain genuine TLS until an external
authenticated handoff completes. The record that finishes the handshake must leave both the read BIO and BoringSSL's
internal TLS read buffer empty; otherwise takeover fails. Owner-formatted raw controls and BoringSSL-generated protocol
output share the same upstream transport path and retain callback order.
This is an internal integration path with no JSON setting. In normal configuration usage, TlsClient remains a TLS
wrapper for the whole line.
Finish Behavior
TlsClient destroys its own per-line TLS state before propagating Finish.
For ordinary directional finishes, it propagates finish in the corresponding direction. For fatal BoringSSL read/write
errors, it destroys local state and closes both directions. It does not own normal external lines and does not call
lineDestroy() for them.
TlsClient uses direct transport close semantics:
This normal-close choice is intended to mimic the Chrome behavior targeted by this tunnel: when the user or application
closes the connection in this situation, Chrome closes the transport without first sending TLS close_notify. This is not
a claim that Chrome never sends close_notify in every TLS shutdown context.
- normal upstream
Finishfrees TLS state and forwardsFinishonly to the next node - raw downstream transport
Finishfrees TLS state and forwardsFinishonly to the previous node - no normal close path calls
SSL_shutdown()or emits TLSclose_notify - an authenticated peer
close_notifyis consumed, not answered, and closes both directions immediately - fatal TLS, certificate, record authentication,
SSL_write(), or BIO failures close every initialized direction immediately - the tunnel does not wait for a peer shutdown response, perform a TLS half-close, or run a shutdown timer
There is no JSON setting for this policy. A peer that requires a full TLS shutdown handshake may classify this direct EOF as a truncated TLS shutdown.
In handshake-takeover mode, tlsclientTunnelDeinitAfterHandshake() releases TLS state without closing the WaterWall line
and the line then continues as raw passthrough.
Padding
TlsClient does not prepend WaterWall payload bytes directly, so it advertises:
required_padding_left = 0
TLS record construction uses the owned output BIO's pooled buffers, preserving the chain's reserved left padding.
Unsupported Features
This implementation does not support:
- server-side TLS termination
- dynamic certificate selection
- TLS over packet lines
- non-TLS transport wrapping such as WebSocket or HTTP by itself
Use separate WaterWall nodes for those layers.
Common Mistakes
- Do not expect
TlsClientto connect to the remote server by itself; put a connector after it. - Do not assume WaterWall
Estmeans the TLS handshake has completed. - Do not use the singular
alpnkey; configure the orderedalpnsarray. - When offering multiple application protocols, know the target server's stable selection and configure the preceding
node for it.
TlsClientdoes not communicate the server's ALPN choice back to that node. - Do not disable
verifyfor public connections unless you intentionally want to skip certificate validation. - Do not disable
x25519mlkem768if your goal is the closest current Chrome-like handshake.
Advanced: ECH SNI Trick
This feature is not part of ordinary TLS configuration or the normal responsibility of TlsClient. Use it only when a
deployment deliberately coordinates TLS ClientHello construction with compatible packet-level manipulation.
The optional ech-sni-trick setting is incompatible with ech-config-list. It accepts a hostname string containing
between 1 and 255 bytes:
"ech-sni-trick": "example.net"
When configured, TlsClient creates a second, fake ClientHello using that hostname and embeds its bytes as the GREASE
encrypted_client_hello payload of the real outer ClientHello. The outer cleartext SNI remains settings.sni.
The embedded fake ClientHello uses the configured alpns list in the same order, but disables x25519mlkem768 to keep
the payload smaller. This behavior is intended to coordinate with packet-side mechanisms such as IpManipulator's
packet-splitting trick, keeping the ClientHello bytes hashed by BoringSSL consistent with the bytes placed on the wire.
The value must be a JSON string containing between 1 and 255 bytes; a value outside that range or another JSON type is a
startup error. The generateTlsHello:<sni> tunnel API also applies this setting when it is configured.
Node Metadata
Source-backed metadata:
This node explicitly disables splice for the entire chain through
kNodeFlagBlocksSplice, even if kNodeFlagSupportsSplice is also set.
| Property | Value |
|---|---|
| node flags | kNodeFlagBlocksSplice |
can_have_prev | true |
can_have_next | true |
layer_group | kNodeLayer4 |
layer_group_prev_node | kNodeLayer4 |
layer_group_next_node | kNodeLayer4 |
required_padding_left | 0 bytes |
Acknowledgements
WaterWall's fragmentation features draw inspiration from:
- GFW-knocker — for practical work on splitting TLS ClientHello traffic into smaller TCP stream writes with delays, which inspired our stream fragmentation approach.
- patterniha — for explaining and exploring TLS record fragmentation and its distinction from TCP fragmentation, which informed our TLS ClientHello record fragmentation feature.
We thank both developers for sharing their work with the community.