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:
| Layout | Certificates | Where |
|---|---|---|
| A. Direct, automatic certificates (recommended) | Built-in Let’s Encrypt | This page, quick start |
| B. Direct, static certificate | Your own files, reloaded on SIGHUP | This page |
| C. Behind a TCP (layer-4) passthrough with PROXY protocol v2 | The relay’s own (A or B) | Behind a TCP front end |
You configure exactly one certificate source:
| Source | Section | Port 80 | Renewal |
|---|---|---|---|
| Built-in Let’s Encrypt (recommended) | [server.acme] | Not used | Automatic, no restart |
| Static certificate from disk | [server.tls] | Only if your certificate tool needs it | You renew, then systemctl reload |
The server fails closed
Section titled “The server fails closed”It refuses to start if:
- the config file is missing, unreadable or invalid;
- neither
[server.acme](withenabled = true) nor a static certificate ([server.tls]cert_pathandkey_path) is configured, and--no-tlswas not given; - both are configured (a
[server.tls]table holding onlysession_ticketsbeside[server.acme]is fine); - only one of
cert_pathandkey_pathis 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, acache_dirthat is a symlink, or a cache directory it cannot create or set permissions on; tls_handshake_timeout_secsis outside 1–120;--no-tlsis given without[server] dev_origin;--no-tlsis combined with a non-loopback bind address and no--insecure-public-plaintext.
There is never a silent fallback to plaintext.
Built-in Let’s Encrypt (ACME)
Section titled “Built-in Let’s Encrypt (ACME)”The relay orders and renews its certificate itself, answering the TLS-ALPN-01 challenge on its normal listener.
Requirements
Section titled “Requirements”- A public DNS
AorAAAArecord 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, pfrdr, 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.Configuration
Section titled “Configuration”[server]bind_addr = "0.0.0.0:443"
[server.acme]enabled = truedomains = ["chat.example.com"]cache_dir = "/var/lib/rchat/acme"staging = false| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | bool | false | Turn ACME on. With false the section is ignored. |
domains | list 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. |
contacts | list of strings | [] | Optional email addresses for the ACME account. mailto: is added if missing. Other schemes are refused. |
cache_dir | path | /var/lib/rchat/acme | Holds the ACME account key and the certificate private key. Must be absolute, must not be a symlink, and must persist across restarts. |
staging | bool | false | Use 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.
Before the first certificate
Section titled “Before the first certificate”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 readyOn later starts, the cached certificate is loaded from cache_dir:
INFO ACME: cached certificate loaded; TLS is readyStaging and production
Section titled “Staging and production”- 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.
When something fails
Section titled “When something fails”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/Renewal
Section titled “Renewal”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).
Expiry warnings
Section titled “Expiry warnings”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).
Recovering from a corrupt cache
Section titled “Recovering from a corrupt cache”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).
How the challenge shares port 443
Section titled “How the challenge shares port 443”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.
Static certificate
Section titled “Static certificate”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 firstkey_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).
Reloading a renewed certificate
Section titled “Reloading a renewed certificate”The relay re-reads both files on SIGHUP, without dropping connections. The shipped units define ExecReload=/bin/kill -HUP $MAINPID:
sudo systemctl reload rchat-serverOn 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: ...certbot
Section titled “certbot”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:
sudo install -d -m 0750 -o root -g rchat /etc/rchat/tlssudo install -m 0644 /etc/letsencrypt/live/chat.example.com/fullchain.pem /etc/rchat/tls/fullchain.pemsudo install -m 0640 -g rchat /etc/letsencrypt/live/chat.example.com/privkey.pem /etc/rchat/tls/privkey.pemPoint [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/shset -eumask 077install -m 0644 "$RENEWED_LINEAGE/fullchain.pem" /etc/rchat/tls/.fullchain.pem.newinstall -m 0640 -g rchat "$RENEWED_LINEAGE/privkey.pem" /etc/rchat/tls/.privkey.pem.newmv -f /etc/rchat/tls/.fullchain.pem.new /etc/rchat/tls/fullchain.pemmv -f /etc/rchat/tls/.privkey.pem.new /etc/rchat/tls/privkey.pemsystemctl reload rchat-serverBoth 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.
Private CA for testing
Section titled “Private CA for testing”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.
Post-quantum key exchange
Section titled “Post-quantum key exchange”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).
TLS resumption
Section titled “TLS resumption”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 oneWith [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)”[server]bind_addr = "127.0.0.1:18443"dev_origin = "ws://127.0.0.1:18443"rchat-server --config dev.toml --init-relay-identity # oncerchat-server --config dev.toml --no-tls-
It ignores
[server.tls]and[server.acme], with a warning, and logs a largeTLS DISABLEDbanner. -
It requires
[server] dev_origin, the lowercasescheme://host:portclients 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-tlsand no--config, a missing/etc/rchat/server.tomlwould allow built-in defaults, but they have nodev_origin, so the relay refuses to start. An explicit--configfile that does not exist is always an error. -
It refuses to start on a non-loopback address (anything outside
127.0.0.0/8and::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-plaintextoverrides 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
Section titled “TLS handshake timeout”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.
Verify
Section titled “Verify”# TLS 1.3 succeedsopenssl 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 datesopenssl s_client -connect chat.example.com:443 -servername chat.example.com </dev/null 2>/dev/null \ | openssl x509 -noout -issuer -datesTroubleshooting
Section titled “Troubleshooting”| Message | What 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 configured | Keep exactly one. enabled = false in [server.acme] also works. |
[server.acme] is enabled but `domains` is empty | List 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 address | Use [email protected] or mailto:[email protected]. |
[server.acme] cache_dir ... must be an absolute path | Use for example /var/lib/rchat/acme. |
ACME cache_dir ... is a symlink; refusing to follow it | Point cache_dir at the real directory. |
cannot create ACME cache directory / cannot set 0700 on ACME cache directory | The directory belongs to another user, or is outside the unit’s writable paths (/var/lib/rchat). |
[server.tls] `tls13_only` is not a setting | Delete 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_origin | Add 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 443 | Run under the shipped unit, which grants CAP_NET_BIND_SERVICE. |
Handshakes fail and the log shows no certificate cached yet | First 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 Encrypt | Wait for the retry time. Test with staging = true, and keep cache_dir persistent. |
TLS certificate expires in N day(s) / ... has EXPIRED | With ACME, look for earlier ACME: warnings. With a static certificate, renew it and run systemctl reload. |