tests/ech_e2e.rs runs the ferron binary with three hosts that share one ECH public name, over TCP (HTTP/1.1) and QUIC, with rustls' ECH client: * each host is served with its own certificate and content, picked by the real (inner) server name, on both transports; * clients that do not use ECH are unaffected; * a stale ECHConfig is rejected with the current ECHConfig (and not a previous one) to retry with; * rotating, retiring and removing keys take effect on reload, and a reload with an unusable key file is reported while the old configuration keeps serving; * `ferron validate`, `doctor` and `directives` know the ech_file directive. README.md describes the distribution, the ECH configuration and key rotation, and the limits.
ferron-distro
A Ferron 3 distribution: the stock set of Ferron modules, plus modules that live outside of Ferron, on a rustls with server-side Encrypted Client Hello.
Nothing of Ferron is patched. The ferron binary here is Ferron's own entry point, started with
its default module list and our extra module loaders (src/main.rs).
| Part | Where |
|---|---|
| Ferron | git dependency, branch fsky of gitfield.org:fsky/ferron (upstream 3.0.0-rc.9) |
| rustls | [patch.crates-io], branch fsky of gitfield.org:fsky/rustls (0.23.45 + server ECH, see its FORK.md) |
ferron-tls-ech |
modules/tls-ech: the ech_file directive |
The [patch.crates-io] entry replaces every use of rustls in the build, in tokio-rustls, quinn,
hyper-rustls and Ferron's own crates, so the TCP and the HTTP/3 (QUIC) listeners both get ECH.
Building
cargo build --release # target/release/ferron
The git dependencies are fetched over ssh (.cargo/config.toml makes cargo use the git CLI, so
your ssh-agent and ~/.ssh/config apply). cargo build --features fips is the FIPS build of
Ferron; see "Limits" for what that means for ECH.
To build against a local checkout of the rustls fork, without touching Cargo.toml (the path is
relative to this directory):
cargo build --config 'patch.crates-io.rustls.path="../rustls/rustls"'
Encrypted Client Hello
ECH hides the real server name of a TLS connection from on-path observers. A client takes the
public key of the site (an ECHConfig) from the ech= parameter of the site's DNS HTTPS record,
and encrypts its ClientHello to it. What goes on the wire in the clear is the public_name of the
ECHConfig, shared by all sites that use the same key, and not the name of the site.
This distribution only does the server side of that: it does not generate keys, and it does not publish DNS records.
Configuration
List the key files in the global block:
{
ech_file /etc/ferron/ech/current.pem
ech_file /etc/ferron/ech/previous.pem
}
# The public name of the ECHConfig: a host with a certificate that is valid for it.
ech.example.net {
tls {
provider acme
}
}
www.example.com {
tls {
provider acme
}
root /var/www/html
}
A key file is in the format of RFC 9934: a PEM
encoded PKCS #8 private key, followed by the PEM encoded ECHCONFIG (a base64 encoded
ECHConfigList). X25519, P-256, P-384 and P-521 keys are supported.
- The first file is the current key. Its ECHConfig is sent to clients that offered an ECHConfig that cannot be used (for instance a stale one from a DNS cache), so that they can retry.
- Later files are for earlier keys that clients may still use, because DNS records are cached. They are used to decrypt, and are not sent to clients.
- The directive is global only, because the key has to be tried before it is known which host
the client wants. In a host block it is ignored, with an "unknown directive" warning from
ferron validate. Everything else is not changed: once the ClientHello is decrypted, Ferron chooses the host with the real server name, and uses that host's certificate, ALPN, mTLS, OCSP and other settings. - The
public_namemust be a host that Ferron serves, with a certificate that is valid for the name. Clients that cannot decrypt check that certificate before they retry. Ferron logs a warning at startup if no host matches. - Relative paths are relative to the working directory. Paths can use
{{env.NAME}}. - Restrict the permissions of key files (
chmod 600);ferron doctorreports key files that other users can read.
Rotating keys
Keys are read when Ferron starts and when it reloads its configuration (SIGHUP). A file that cannot be used makes the reload fail, and Ferron keeps the configuration that it has.
- Put the new key file in place, and list it first, with the old one next. Reload.
- Publish the DNS record that has the new ECHConfig (and not the old one).
- When the old record has expired from DNS caches, remove the old file from the configuration. Reload.
Publish the new ECHConfig only after Ferron has loaded the key. Stale ECHConfigs are survivable, since the client is told the current one and retries, but it costs a round trip.
If no ech_file is configured, ECH is off; reloading to a configuration without it turns it off.
Limits
- Shared mode only: Ferron decrypts the ClientHello and also terminates the TLS connection. It cannot be a client-facing server that forwards the inner ClientHello to another server.
- TLS 1.3 only, as ECH is. Hosts that are limited to TLS 1.2 cannot be reached with ECH.
- The aws-lc-rs provider of rustls has no X25519 HPKE in FIPS mode, and most browsers offer
only X25519 for ECH. Use
--features fipsonly with clients that use the NIST curves. - A rejected client is sent the ECHConfig of the current key only, not the whole ECHConfigList of the file.
- There is no ECH status in Ferron's logs, metrics or variables. Those would need changes to Ferron.
Tests
cargo test --workspace
tests/ech_e2e.rs starts the ferron binary with three hosts that share one public name, and
connects with rustls' ECH client over TCP (HTTP/1.1) and QUIC. It checks that each host gets its
own certificate and content, that stale ECHConfigs are rejected with the current one to retry
with, and that rotation, retirement and turning ECH off take effect on reload.
Updating
- rustls: rebase the
fskybranch of the rustls fork onto the new release (see itsFORK.md), thencargo update -p rustls. - Ferron: fast-forward the
fskybranch of the Ferron fork, thencargo update -p ferron-entrypoint. The modules here useferron-coreand need to keep compiling against it.