kruno ff58b17ada Test ECH end to end, and document the distribution
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.
2026-10-10 22:28:39 +02:00

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_name must 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 doctor reports 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.

  1. Put the new key file in place, and list it first, with the old one next. Reload.
  2. Publish the DNS record that has the new ECHConfig (and not the old one).
  3. 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 fips only 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 fsky branch of the rustls fork onto the new release (see its FORK.md), then cargo update -p rustls.
  • Ferron: fast-forward the fsky branch of the Ferron fork, then cargo update -p ferron-entrypoint. The modules here use ferron-core and need to keep compiling against it.
S
Description
FSKY's Ferron distribution
Readme
125 KiB
0 Stars 2 Watchers 0 Forks
Languages
Rust 100%