Skip to main content

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:

FieldTypeDescription
namestringUser-chosen node name. Must be unique inside the config file.
typestringMust be exactly "TlsClient".
settingsobjectTLS client settings.
nextstringStream-facing node that carries encrypted TLS records. Required in normal use.

Required settings field:

FieldTypeDescription
snistringTLS 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​

FieldDefaultDescription
alpns["h2", "http/1.1"]Ordered ALPN protocol offer. The JSON order is preserved in the ClientHello.
verifytrueEnables BoringSSL peer certificate verification.
x25519mlkem768trueAdvertises the X25519MLKEM768 hybrid key-agreement group; does not change signature algorithms.
fragmentabsentOptional private StreamFragmenter settings for outgoing TLS bytes.
tls13-record-shapingabsentExperimental sender-side TLS 1.3 padding and delay configuration.
ech-config-listabsentStandard Base64 ECHConfigList for required real ECH.
verbosefalseEnables 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 HttpClient for HTTP/1.1 and configure TlsClient with only "alpns": ["http/1.1"].
  • For WebSocket over HTTP/2, configure HttpClient for HTTP/2 so it uses the HTTP/2 CONNECT path, and configure TlsClient with only "alpns": ["h2"].
  • Offering both h2 and http/1.1 is valid, commonly used, and closer to Chrome's default ClientHello. When using that offer with a fixed preceding HttpClient, the user must know which protocol the target server will select and configure HttpClient for that version. If the target's choice is unknown or can vary, offer only the protocol that matches HttpClient.
caution

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:

BehaviorCurrent value
Minimum TLS versionTLS 1.2
Maximum TLS versionTLS 1.3
Session cacheAt most two sessions per node and worker
TLS 1.2 session timeout3600 seconds
TLS 1.3 session timeoutBoringSSL session lifetime, capped by the server ticket lifetime
GREASEenabled
Extension permutationenabled
Certificate compressionBrotli decompression support
Signed certificate timestampsenabled
OCSP stapling requestenabled 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:

  1. initializes per-line BoringSSL state
  2. creates owned-buffer ciphertext BIOs backed by the line's buffer pool
  3. switches the SSL object into client mode
  4. sets the configured SNI
  5. forwards upstream Init to the next tunnel
  6. calls SSL_connect() to generate the first ClientHello flight
  7. 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:

APIPurpose
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 Finish frees TLS state and forwards Finish only to the next node
  • raw downstream transport Finish frees TLS state and forwards Finish only to the previous node
  • no normal close path calls SSL_shutdown() or emits TLS close_notify
  • an authenticated peer close_notify is 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 TlsClient to connect to the remote server by itself; put a connector after it.
  • Do not assume WaterWall Est means the TLS handshake has completed.
  • Do not use the singular alpn key; configure the ordered alpns array.
  • When offering multiple application protocols, know the target server's stable selection and configure the preceding node for it. TlsClient does not communicate the server's ALPN choice back to that node.
  • Do not disable verify for public connections unless you intentionally want to skip certificate validation.
  • Do not disable x25519mlkem768 if your goal is the closest current Chrome-like handshake.

Advanced: ECH SNI Trick​

Advanced, specialized behavior

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.

PropertyValue
node flagskNodeFlagBlocksSplice
can_have_prevtrue
can_have_nexttrue
layer_groupkNodeLayer4
layer_group_prev_nodekNodeLayer4
layer_group_next_nodekNodeLayer4
required_padding_left0 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.