Skip to content

TLS and certificates

The relay terminates TLS itself and speaks TLS 1.3 only. There is no TLS 1.2, no 0-RTT and no ALPN on normal connections, and the version cannot be configured.

TLS must terminate at the relay. The protocol binds each client’s key proof, and the relay’s identity signature, to the TLS session between the client and the relay. A reverse proxy, load balancer or CDN that terminates TLS in front of the relay makes every connection fail. Three layouts are supported:

LayoutCertificatesWhere
A. Direct, automatic certificates (recommended)Built-in Let’s EncryptThis page, quick start
B. Direct, static certificateYour own files, reloaded on SIGHUPThis page
C. Behind a TCP (layer-4) passthrough with PROXY protocol v2The relay’s own (A or B)Behind a TCP front end

You configure exactly one certificate source:

SourceSectionPort 80Renewal
Built-in Let’s Encrypt (recommended)[server.acme]Not usedAutomatic, no restart
Static certificate from disk[server.tls]Only if your certificate tool needs itYou renew, then systemctl reload

It refuses to start if:

  • the config file is missing, unreadable or invalid;
  • neither [server.acme] (with enabled = true) nor a static certificate ([server.tls] cert_path and key_path) is configured, and --no-tls was not given;
  • both are configured (a [server.tls] table holding only session_tickets beside [server.acme] is fine);
  • only one of cert_path and key_path is set;
  • the static certificate or key cannot be loaded, or the key does not match the certificate;
  • the ACME settings are invalid: no domains, a wildcard or malformed domain, an invalid contact, a relative cache_dir, a cache_dir that is a symlink, or a cache directory it cannot create or set permissions on;
  • tls_handshake_timeout_secs is outside 1–120;
  • --no-tls is given without [server] dev_origin;
  • --no-tls is combined with a non-loopback bind address and no --insecure-public-plaintext.

There is never a silent fallback to plaintext.

The relay orders and renews its certificate itself, answering the TLS-ALPN-01 challenge on its normal listener.

  • A public DNS A or AAAA record for every domain, pointing at the host.
  • Public TCP port 443 reaching the relay directly. Let’s Encrypt validates TLS-ALPN-01 on port 443 only. A plain port forward (iptables DNAT, pf rdr, a TCP-mode load balancer) works. Anything that terminates TLS in front of the relay breaks validation.
  • A persistent, writable cache directory.

If bind_addr is not on port 443, the relay starts but warns:

ACME: listening on port 8443, but Let's Encrypt validates TLS-ALPN-01 on port 443 only. Make sure public :443 is forwarded to this listener without TLS termination in between.
[server]
bind_addr = "0.0.0.0:443"
[server.acme]
enabled = true
domains = ["chat.example.com"]
contacts = ["mailto:[email protected]"]
cache_dir = "/var/lib/rchat/acme"
staging = false
KeyTypeDefaultMeaning
enabledboolfalseTurn ACME on. With false the section is ignored.
domainslist of strings[]Host names on the certificate. Required when enabled. Each must be a lowercase, fully qualified name (letters, digits, hyphens; at most 253 characters). No wildcards, IP addresses, scheme, port or path.
contactslist of strings[]Optional email addresses for the ACME account. mailto: is added if missing. Other schemes are refused.
cache_dirpath/var/lib/rchat/acmeHolds the ACME account key and the certificate private key. Must be absolute, must not be a symlink, and must persist across restarts.
stagingboolfalseUse the Let’s Encrypt staging directory: untrusted certificates with generous rate limits. Use it for first-time setup.

Unknown keys in [server.acme] are refused at startup, so a typo such as email = ... stops the server rather than being ignored. The section also works as a top-level [acme] table.

The cache directory. The relay creates cache_dir with mode 0700 and writes every file 0600 through a temporary file and an atomic rename. If the directory or files already exist with looser permissions, it tightens them and logs ACME: tightening permissions on ... to 0700. The files are account-<hash>.der (the account key) and cert-<hash>.pem (the certificate private key and chain). Staging and production files have different names, so switching between them does not overwrite anything. Losing the cache means ordering again, which counts against rate limits.

With an empty cache, the relay logs:

WARN ACME: no certificate cached yet. TLS handshakes will FAIL until Let's Encrypt issues one (usually under a minute). Issuance needs public DNS for ["chat.example.com"] pointing here and port 443 reaching this listener directly.

Until issuance completes, every TLS handshake fails. There is no temporary or self-signed certificate. When the certificate arrives:

INFO ACME: certificate issued and deployed; TLS is ready

On later starts, the cached certificate is loaded from cache_dir:

INFO ACME: cached certificate loaded; TLS is ready
  • Staging (staging = true) issues certificates that clients do not trust, but rate limits are no practical concern. The relay warns: ACME: staging certificates are NOT trusted by clients; switch staging off once issuance works. Failed attempts are retried with the ACME library’s fast exponential backoff.
  • Production (staging = false). On a first production run with an empty cache, the relay warns that Let’s Encrypt allows only 5 failed validations per host name per hour. Failed attempts are never closer together than 5 minutes, so a mistake cannot burn through that allowance in seconds.

Start with staging. Once a certificate is issued (its issuer contains STAGING), set staging = false and restart.

Failures never stop the server. Each failed attempt logs one line, and the relay retries:

WARN ACME: certificate issuance failed, will retry with backoff (not before 300 s): <error from Let's Encrypt>

On a renewal the line says renewal instead of issuance. In staging there is no (not before …) part.

The first rate-limit response is logged once, and repeats are logged at debug until the next success:

WARN ACME: rate-limited by Let's Encrypt (retry after <time>). Retrying automatically with backoff; further rate-limit errors are logged at debug. See https://letsencrypt.org/docs/rate-limits/

The ACME client renews once two thirds of the certificate’s lifetime has passed. The new certificate is swapped in memory: new handshakes get it straight away, existing connections continue, and no restart or reload is needed.

INFO ACME: certificate renewed and deployed (no restart needed)

systemctl reload is harmless in ACME mode. The relay logs SIGHUP: nothing to reload (no static certificate in use).

The expiry date of the certificate being served is checked at load, at every deployment or reload, and every 6 hours. This applies to ACME and static certificates alike.

  • Within 7 days of expiry: WARN TLS certificate expires in N day(s). .... With ACME, this means renewal has been failing for weeks.
  • After expiry: ERROR TLS certificate being served has EXPIRED; clients will reject it. ...

The value is also exported as the Prometheus gauge rchat_tls_cert_not_after_seconds. Alert on rchat_tls_cert_not_after_seconds - time() < 7 * 86400 (see Metrics).

If a cache file is damaged, the failure line is followed by a hint:

  • ACME: the cached ACME account key looks corrupt. Stop the server, delete account-*.der in cache_dir and start again; a new account is registered automatically.
  • ACME: the cached certificate looks corrupt. Stop the server, delete cert-*.pem in cache_dir and start again; a new certificate is ordered (mind production rate limits).

The relay reads each TLS ClientHello before answering it. A hello that offers exactly one ALPN protocol, acme-tls/1 (RFC 8737), is a validation connection from Let’s Encrypt. It is answered with the challenge certificate and closed. Every other hello, from the app or a browser, gets the normal TLS 1.3 configuration. Both paths are TLS 1.3 only.

Use this if you already manage certificates: a corporate CA, certbot, or a private CA for testing.

[server]
bind_addr = "0.0.0.0:443"
[server.tls]
cert_path = "/etc/rchat/tls/cert.pem" # PEM, full chain, leaf first
key_path = "/etc/rchat/tls/key.pem" # PEM private key: PKCS#8, PKCS#1 or SEC1

[server.tls] accepts cert_path, key_path and session_tickets. Older templates had min_version and tls13_only. Those settings were never read and are now refused at startup:

[server.tls] `tls13_only` is not a setting: TLS 1.3 is mandatory and cannot be changed. Remove `tls13_only` from the config file.

The files must be readable by the rchat user. On startup the relay logs TLS 1.3 enabled with static certificate (SIGHUP reloads it).

The relay re-reads both files on SIGHUP, without dropping connections. The shipped units define ExecReload=/bin/kill -HUP $MAINPID:

Terminal window
sudo systemctl reload rchat-server

On success it logs SIGHUP: TLS certificate reloaded. If the new files are unreadable or malformed, or the key does not match, the old certificate stays in use and it logs:

ERROR SIGHUP: TLS certificate reload failed, keeping the old one: ...

If you prefer certbot to the built-in ACME client, issue the certificate with the standalone plugin, which needs port 80 free during issuance, then copy it where the rchat user can read it. Copy rather than symlink, because /etc/letsencrypt/live is readable only by root:

Terminal window
sudo certbot certonly --standalone -d chat.example.com --email [email protected] --agree-tos
sudo install -d -m 0750 -o root -g rchat /etc/rchat/tls
sudo install -m 0644 /etc/letsencrypt/live/chat.example.com/fullchain.pem /etc/rchat/tls/fullchain.pem
sudo install -m 0640 -g rchat /etc/letsencrypt/live/chat.example.com/privkey.pem /etc/rchat/tls/privkey.pem

Point [server.tls] at /etc/rchat/tls/fullchain.pem and /etc/rchat/tls/privkey.pem. For renewals, add a deploy hook, /etc/letsencrypt/renewal-hooks/deploy/rchat-reload.sh (mode 0755), that copies each renewal into place and reloads the relay:

#!/bin/sh
set -e
umask 077
install -m 0644 "$RENEWED_LINEAGE/fullchain.pem" /etc/rchat/tls/.fullchain.pem.new
install -m 0640 -g rchat "$RENEWED_LINEAGE/privkey.pem" /etc/rchat/tls/.privkey.pem.new
mv -f /etc/rchat/tls/.fullchain.pem.new /etc/rchat/tls/fullchain.pem
mv -f /etc/rchat/tls/.privkey.pem.new /etc/rchat/tls/privkey.pem
systemctl reload rchat-server

Both files are written under temporary names first and renamed back to back, so neither is seen half-written. Check it with sudo certbot renew --dry-run.

For a relay that owns port 443, the built-in ACME client is simpler: no port 80, no certbot, no hook.

For testing and internal networks you can use a certificate from a private CA of your own. Keep the CA’s private key off the relay, and make every client device trust the CA certificate.

Connections between an rChat client and an rChat relay use the hybrid post-quantum key exchange X25519MLKEM768 (IANA group 0x11EC, draft-ietf-tls-ecdhe-mlkem), which combines ML-KEM-768 with X25519 so the TLS session keys stay secret unless both are broken. This applies to full and resumed TLS 1.3 handshakes alike, since every resumption runs a fresh hybrid exchange. It protects what the TLS link carries (the relay-visible metadata such as hello, key-bundle publish, circuit requests and sealed-envelope headers) against an adversary who records traffic today and decrypts it later with a quantum computer. Message content has its own end-to-end protection and does not depend on TLS.

  • Only the key exchange is post-quantum. The relay’s TLS certificate signature, its Ed25519 relay identity and the client’s rk:1: pin of it, and the Ed25519 connection proofs remain classical. That is sufficient against record-now-decrypt-later, because a signature only has to resist forgery at the moment of the handshake and cannot be forged retroactively for a recorded session. It does not protect against an attacker who already has a quantum computer at connection time and could impersonate the relay; moving authentication to post-quantum signatures is separate, future work.
  • Fallback. Clients that do not offer the hybrid group get classical X25519; the relay also accepts P-256 and P-384. rChat clients offer only X25519MLKEM768 and X25519.
  • Nothing to configure. The relay prefers the hybrid group on every listener (ACME or static certificate).

Since 2.0.0 the relay issues TLS 1.3 resumption tickets, so a returning app resumes its session instead of running a full handshake, and a reconnect looks like any other returning client’s. It is on by default, for ACME and static certificates alike:

[server.tls]
session_tickets = true # the default; false = every handshake is a full one

With [server.acme], put session_tickets in a [server.tls] table of its own, without cert_path and key_path.

  • The relay issues 2 tickets per handshake. Their keys are held in memory only, never written to disk, and rotate every 6 hours, so a ticket lives at most 12 hours. There is never 0-RTT (early data).
  • The app keeps at most 2 tickets for the relay it has pinned, in memory only, and uses each once. It drops them when you switch relays, when the relay’s pin changes, at sign-out and when the app restarts.
  • Resumption skips none of rChat’s checks. Every connection, resumed or not, still proves the account’s identity over that connection’s own TLS exporter, and the relay still signs its identity over it. A proof from one connection fails on another.
  • A presented ticket is visible on the network and links two connections of one app (each ticket once, within its 12 hours). Turn tickets off if that matters more to you than the reconnect pattern.

Plaintext for local development (--no-tls)

Section titled “Plaintext for local development (--no-tls)”
dev.toml
[server]
bind_addr = "127.0.0.1:18443"
dev_origin = "ws://127.0.0.1:18443"
Terminal window
rchat-server --config dev.toml --init-relay-identity # once
rchat-server --config dev.toml --no-tls
  • It ignores [server.tls] and [server.acme], with a warning, and logs a large TLS DISABLED banner.

  • It requires [server] dev_origin, the lowercase scheme://host:port clients dial. Without TLS there is no channel binding, so client proofs are bound to a hash of this origin instead. That only stops a proof being replayed to a relay with a different origin.

  • With --no-tls and no --config, a missing /etc/rchat/server.toml would allow built-in defaults, but they have no dev_origin, so the relay refuses to start. An explicit --config file that does not exist is always an error.

  • It refuses to start on a non-loopback address (anything outside 127.0.0.0/8 and ::1):

    Error: --no-tls with non-loopback bind address 0.0.0.0:8443: refusing to serve plaintext (including circuit tokens) on the network. ...

    --insecure-public-plaintext overrides this, exposes all traffic to the network and logs an error at startup. It has no legitimate production use.

--no-tls has no production use. A relay behind a TLS-terminating proxy cannot work; behind a TCP passthrough the relay keeps its own TLS (see Behind a TCP front end). Release builds of the iOS app refuse ws:// except to a relay on the same device.

tls_handshake_timeout_secs (default 10, allowed 1–120) limits how long a client may take to finish the TLS handshake. Stalled handshakes are dropped.

Terminal window
# TLS 1.3 succeeds
openssl s_client -connect chat.example.com:443 -tls1_3 </dev/null 2>&1 | grep -E '^ *Protocol|New, TLS'
# TLS 1.2 is refused with "alert protocol version"
openssl s_client -connect chat.example.com:443 -tls1_2 </dev/null 2>&1 | grep -i alert
# Issuer and validity dates
openssl s_client -connect chat.example.com:443 -servername chat.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -dates
MessageWhat to do
no TLS configured. Add [server.acme] ... or [server.tls] ...Add one certificate source, or pass --no-tls for local development.
both [server.tls] (static certificate) and [server.acme] (automatic certificates) are configuredKeep exactly one. enabled = false in [server.acme] also works.
[server.acme] is enabled but `domains` is emptyList at least one host name.
[server.acme] domain "..." is invalid: ...Use a lowercase DNS name with no wildcard, scheme, port or path.
[server.acme] contact "..." is not an email addressUse [email protected] or mailto:[email protected].
[server.acme] cache_dir ... must be an absolute pathUse for example /var/lib/rchat/acme.
ACME cache_dir ... is a symlink; refusing to follow itPoint cache_dir at the real directory.
cannot create ACME cache directory / cannot set 0700 on ACME cache directoryThe directory belongs to another user, or is outside the unit’s writable paths (/var/lib/rchat).
[server.tls] `tls13_only` is not a settingDelete min_version and tls13_only.
failed to load TLS certificate chain / failed to load TLS private key / TLS key ... does not match certificate ...Check the paths, the PEM contents and that the rchat user can read both files.
--no-tls with non-loopback bind address--no-tls is for local development: bind 127.0.0.1, or configure TLS.
--no-tls requires [server] dev_originAdd dev_origin = "ws://127.0.0.1:<port>" under [server] (development only).
App: “This server couldn’t prove its identity”Something in front of the relay terminates TLS. Use a TCP passthrough or let the relay own port 443.
Permission denied binding port 443Run under the shipped unit, which grants CAP_NET_BIND_SERVICE.
Handshakes fail and the log shows no certificate cached yetFirst issuance has not finished. Follow the ACME: lines.
ACME: certificate issuance failed, will retry ...Usually DNS not pointing at this host, or port 443 not reaching the relay directly (firewall, proxy, wrong forward). The error text from Let’s Encrypt follows the colon.
ACME: rate-limited by Let's EncryptWait for the retry time. Test with staging = true, and keep cache_dir persistent.
TLS certificate expires in N day(s) / ... has EXPIREDWith ACME, look for earlier ACME: warnings. With a static certificate, renew it and run systemctl reload.