TLS Encrypted ClientHello (ECH)¶
EXPERIMENTAL
Development Reference
TLS Encrypted Client Hello (current IETF draft)
https://www.ietf.org/archive/id/draft-ietf-tls-esni-22.html
"Although TLS 1.3 (RFC8446) encrypts most of the handshake, including the server certificate, there are several ways in which an on-path attacker can learn private information about the connection. The plaintext Server Name Indication (SNI) extension in ClientHello messages, which leaks the target domain for a given connection, is perhaps the most sensitive information left unencrypted in TLS 1.3."
"... specifies a new TLS extension, called Encrypted Client Hello (ECH), that allows clients to encrypt their ClientHello to such a deployment. This protects the SNI and other potentially sensitive fields, such as the ALPN list (RFC7301)."
While the ECH specification has not yet been finalized, ECH has been in development for many years. ECH is supported by modern Firefox and Chrome browsers, as well as by cURL, when cURL is built with TLS libraries supporting ECH.
lighttpd 1.4.77 mod_openssl supports ECH when lighttpd mod_openssl is built with TLS libraries supporting ECH on the server-side, currently BoringSSL or OpenSSL (feature/ech development branch). (See instructions below.) Since the ECH specification has not yet been finalized, and TLS library support for ECH is still under development, lighttpd mod_openssl support for ECH is EXPERIMENTAL. Please help provide feedback in the lighttpd forums (lighttpd TLS modules depending on other TLS libraries do not currently support ECH. GnuTLS and mbedTLS do not currently support ECH. NSS and WolfSSL currently have limited or incomplete support for ECH on the server-side.)
For TLS ECH to work, client and server must use TLSv1.3, support ECH (duh!), and the client must be able to find the ECHConfig containing the public key to use to encrypt the ClientHello. (See "Publishing ECHConfigs" below.)
A group of virtual hosts with the same ECHConfig is known as an ECH anonymity set. For each ECH anonymity set there is a public_name, which is the clear-text SNI to be used when making TLS connections employing Encrypted Client Hello.
TLS ECH options¶
ssl.ech-opts¶
ssl.ech-opts = ( "keydir" => "/path/to/echkeys", )
"keydir"
path to directory containing *.ech
PEM files of ECH keys and ECHConfigs (required to enable ECH)"refresh"
keydir refresh interval to re-read ECH keys and ECHConfigs (default 300 seconds (5 mins)) (refresh possibly delayed up to 64 seconds)"public-names"
list of public hosts. If defined, then all other hosts are implicitly private and reachable by clients only by using ECH."trial-decrypt"
disable/enable trial decryption (default: enabled) Option may also be overridden by ssl.openssl.ssl-conf-cmd += ("Options" => "-ECHTrialDecrypt")
ssl.ech-public-name¶
ssl.ech-public-name
defines a public_name for the $HTTP["host"]
containing this option. Its purpose and effect is to mark the $HTTP["host"]
as private and reachable by clients only by using ECH. This directive allows selectively marking a host as ECH-only. To implicitly mark all non-listed hosts as ECH-only, use ssl.ech-opts
@"public-names". Note: a host can have a different ECH public_name as defined by the ECHConfig and this directive does not change the public_name defined by the ECHConfig.
Important: for ECH-only hosts to remain ECH-only, they must not be accessible via HTTP. In addition to marking ECH-only hosts above, define ECH-only hosts with $HTTP["host"]
inside $SERVER["socket"]
containing ssl.engine = "enable"
, and not in global configuration scope, unless global scope contains ssl.engine = "enable"
and there are no $SERVER["socket"]
configurations with ssl.engine = "disable"
Trial decryption is part of the ECH specification, but not required. During ECH specification discussions, some CDNs have pushed to be able to avoid trial decryption to reduce the impact of DoS attacks abusing trial decryption. Similarly, some CDNs have pushed for the confid_id which is part of ECHConfig, and which could theoretically be used as a tracking vector. bssl generate_ech
can be used to generate ECHConfig with a specific config_id, and if an admin chooses to generate all keys using the same config_id, then trial decryption must be enabled to support multiple keys. However, unless you have followed the debates over these ECH features or have specific security needs and understand how this affects those needs, it is recommended to use a random config_id, and in that case you might choose to disable trial decryption to have a similar level of ECH support as do numerous CDNs.
Managing ECH keys¶
https://datatracker.ietf.org/doc/html/draft-farrell-tls-pemesni
lighttpd reads ECH keys and ECHConfigs from files named *.ech
from the configured ssl.ech-opts
"keydir"
. The *.ech
file list is sorted (case-insensitive) and then files are parsed in order. While there is no required naming besides ending in *.ech
, lighttpd suggests naming files <prefix>@<public_name>.ech
, e.g. 0@example.com.ech, where the prefix is used to sort files, and the public_name is used to identify keys for the same public_name.
ECH keys should be rotated frequently, e.g. every 30 minutes, and published in DNS with short TTLs, e.g. 3600 seconds (60 minutes). If these timeframes are used, then there might be 3 different keys active at any one time, so lighttpd supports multiple ECH keys for each public_name. For a given public_name, only the ECHConfig from the most recently modified file -- i.e. with the most recently generated key -- is sent to clients in a TLS HelloRetryRequest (HRR). If files for the same public_name all have the same modification time, lighttpd sends only the ECHConfig from the file sorted first for that public_name. lighttpd re-reads the ECH keys and ECHConfigs every 900 seconds (15 mins) by default, and this can be configured with ssl.ech-opts
"refresh"
as low as every 64 seconds.
A sample script to run once every 30 minutes which rotates keys and then generates a new key:
#!/bin/sh set -e cd /path/to/keydir for i in 1@*.ech; do mv $i 2@${i#1@}; done for i in 0@*.ech; do mv $i 1@${i#0@}; done # Then, generate new key(s) as 0@*.ech, # e.g. using 'openssl ech ...' or 'bssl generate_ech ...'
Publishing ECHConfigs¶
The Service Bindings framework (SVCB) is leveraged to publish ECHConfigs.
https://datatracker.ietf.org/doc/html/draft-ietf-tls-svcb-ech
https://datatracker.ietf.org/doc/draft-ietf-tls-wkech/
Extract the base64-encoded ECHConfig from each newly generated file (see "Managing ECH keys" above), and then for each host reachable via ECH (not only the public_name), publish the result into DNS HTTPS resource records, e.g. HTTPS resource record: alpn="h2" ech="......" cat /path/to/0@example.com.ech | perl -e '$/=undef; $f=<>; $f =~ /-----BEGIN ECHCONFIG-----(.*?)-----END ECHCONFIG-----/s && ($echconfig = $1) =~ s/\s//gs; print $echconfig;'
Check your DNS provider's instructions for ways to automate DNS updates. For a stronger security stance, generate ECH keys and ECHConfigs on a protected (non-public-facing) machine and publish to DNS from the protected host. From the protected host, push the generated ECH keys and ECHConfigs to a staging directory on each web host where a script like the one above moves the new ECH keys and ECHConfigs into the keydir after rotating older ECH keys and ECHConfigs.
Building lighttpd with ECH support¶
For lighttpd mod_openssl to support ECH, lighttpd mod_openssl must be built against a TLS libary supporting ECH.
Build a TLS library supporting ECH and then build lighttpd from source (InstallFromSource)
Building BoringSSL with ECH support¶
git clone https://boringssl.googlesource.com/boringssl cd boringssl cmake -DCMAKE_BUILD_TYPE=Release -GNinja -B build -DCMAKE_INSTALL_PREFIX=/usr/local -DBUILD_SHARED_LIBS=1 ninja -C build cmake --install build cd .. # lighttpd ./configure --with-openssl --with-openssl-includes=/usr/local/include --with-openssl-libs=/usr/local/lib64 # (change /usr/local in all above commands to your target installation location)
BoringSSL tool 'bssl' can be used to generate ECH key and ECHConfigbssl generate_ech -out-ech-config-list /path/to/bssl.echconfiglist -out-ech-config /path/to/bssl.echconfig -out-private-key /path/to/bssl.pkey -public-name example.com -config-id $((RANDOM % 255))
However, a raw private key is generated using elliptic curve X25519 with KEM EVP_hpke_x25519_hkdf_sha256() (BoringSSL). Hard-coding such parameters is not user-friendly, so I hope BoringSSL improves this interface in the future. The following script will take the binary contents of the files generated by bssl and will convert them into more descriptive ASN.1 TLV, then base64-encode into a PEM file that can be used with lighttpd mod_openssl. Send the output of the commands to e.g. 0@example.com.ech
bssl_pkey=/path/to/bssl.pkey bssl_echconfig=/path/to/bssl.echconfig echo "-----BEGIN PRIVATE KEY-----" (perl -e 'print STDOUT "\x30\x2E\x02\x01\x00\x30\x05\x06\x03\x2B\x65\x6E\x04\x22\x04\x20"'; cat $bssl_pkey) | base64 echo "-----END PRIVATE KEY-----" echo "-----BEGIN ECHCONFIG-----" cat $bssl_echconfig | perl -e 'sysread(STDIN, $echconfig, 1024); print STDOUT pack('n',length($echconfig)), $echconfig;' | base64 echo "-----END ECHCONFIG-----" # output should be saved in a file 0@example.com.ech and placed in the ssl.ech-opts "keydir" configured in lighttpd.conf
Building OpenSSL with ECH support¶
OpenSSL ECH support is still being developed on the feature/ech branch in the OpenSSL repository.
https://github.com/defo-project/
https://github.com/defo-project/ech-dev-utils
OpenSSL tool 'openssl' can be used to generate ECH key and ECHConfig. -public_name
must be specified for a usable echconfig.openssl ech -public_name example.com -pemout /path/to/0@example.com.ech
OpenSSL tool 'openssl' can be used to print ECHConfig.openssl ech -pemin /path/to/0@example.com.ech
OpenSSL tool 'openssl' can be used to debug ECHConfig structure.openssl asn1parse -inform pem -in /path/to/0@example.com.ech
Testing ECH without DNS¶
If using cURL built with ECH support, cURL can be told to use the ECHConfig $echconfig (See "Publishing ECHConfigs" above)
e.g. to access a server on localhost for an ECH-only host baz.example.com using $echconfigcurl -vvv --connect-to baz.example.com:443:localhost:443 https://baz.example.com/index.html --ech ecl:ecl:$echconfig
(There may be a development bug in the curl I built to test which required me to repeat "ecl:ecl:...")
Updated by gstrauss 8 days ago · 6 revisions