Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Secure transport: end-to-end configuration

This page is the full configuration reference for connecting a client to a daemon securely, in three topologies:

  1. Client direct to daemon - mutual-TLS WSS, no relay.
  2. Daemon to relay - the daemon keeps an outbound bridge to a nominated relay so it is reachable from behind NAT/CGNAT.
  3. Client to relay to daemon - the client reaches the daemon through that relay, while the real client<->daemon mTLS still terminates at the daemon.

For the 60-second quickstart see Remote setup (WSS); this page is the deeper, knob-by-knob guide.

Mental model: two envelopes, one trust boundary

There are two TLS layers, and only one of them is the security boundary:

  • Inner mTLS (the real boundary). TLS 1.3 only, mutually authenticated. The client presents a daemon-issued certificate; the daemon presents its server leaf. This is the RPC plane. There is no server-only / unauthenticated path on it - a client certificate is always required. The certificate admits the device; it is not an RPC credential. Every remote initialize must also carry a bearer auth_token that identifies the principal, or the daemon refuses it with AUTH_REQUIRED (-32010). See Bearer token.
  • Outer TLS (a metadata boundary). When a relay is in the path, the relay terminates an outer TLS + WebSocket session and forwards opaque ciphertext. It never holds a key that can read the inner RPC. On the direct topology there is no outer layer.

Default ports (all configurable):

PlaneDefaultConfig
Daemon WSS (inner mTLS RPC)9781[wss].port
Daemon enrollment endpoint9782[enroll].port
Relay (outer TLS + WS)8443relay --bind / [bind]

Throughout, <data_dir> is the daemon’s data directory (typically ~/.zeroclaw) and <config-dir> is the client’s zerocode config directory (--config-dir, typically ~/.zeroclaw). Config files do not expand ~; use absolute paths.


Topology 1: client direct to daemon

zerocode  ===== mutual-TLS WSS (TLS 1.3) =====>  daemon  [wss] :9781

1a. Daemon side

Enable the WSS listener. The secure default is to let the daemon auto-generate its own CA and server certificate on first boot, so you do not hand-manage any TLS material:

[wss]
enabled = true
# bind = "0.0.0.0"   # default
# port = 9781        # default
# Leave cert_path/key_path empty to auto-generate a server cert under
# <data_dir>/tls/ on first boot. Set them only to bring your own server cert.

On first start with [wss].enabled = true the daemon writes, under <data_dir>/tls/ (directory mode 0700):

FilePurposeMode
ca.crtPer-daemon CA certificate (public)default umask
ca.keyCA private key (signs client certs)0600
server.crtWSS server leaf (SANs localhost, 127.0.0.1)default umask
server.keyWSS server private key0600

Private keys are written 0600; the public certificates use the process umask. The tls/ directory itself is 0700.

The CA is never silently rotated: if ca.crt and ca.key exist they are reused. Auto-generated CA lifetime is 10 years; the server leaf is ~27 months; issued client certs are 30 days.

Open the port (sudo ufw allow 9781/tcp) and start the daemon. You should see a log line that the WSS listener is up on 0.0.0.0:9781.

The client now needs a certificate. There are two ways to get one.

Enrollment gives a certless client its first certificate over a pairing-gated, server-authenticated endpoint, with no manual cert handling. Turn it on:

[enroll]
enabled = true
# bind = "0.0.0.0"   # default
# port = 9782        # default
# Requires [wss] enabled and a daemon CA key (auto-generated above, or BYO+key).
# If the CA key is absent the endpoint fails closed and certs must be provisioned
# out of band.

The daemon prints a one-time pairing code and a short-auth-string (SAS) to its console/log on start. The code is single-use and expires 10 minutes after it is minted - it is the only bearer credential for certificate issuance, and it lands in consoles and logs, so a copied code must stop working shortly after the operator has used it. An expired code is refused and cleared; mint a replacement from the gateway’s pairing API when you need a fresh one. Then, on the workstation:

# Interactive: a certless client auto-enrolls on first connect.
zerocode --connect wss://<remote-host>:9781

# Or explicitly / non-interactively:
zerocode --enroll --connect wss://<remote-host>:9781

zerocode prompts for the pairing code, generates a P-256 key and CSR locally (the private key never leaves the device), and shows the SAS. Confirm the SAS matches the one the daemon printed (this catches a man-in-the-middle CA), and it caches, under <config-dir>/tls/:

FilePurposeMode
client.crtIssued client certificatedefault umask
client.keyClient private key (generated locally)0600
ca.crtDaemon CA chain, pinned for the RPC planedefault umask
profile.jsonCached device_id, not_after, relay profiledefault umask

Later runs reuse the cached certificate without enrolling again. The cert auto-renews at ~50% of its lifetime (~15 days) over the live mTLS session; a revoked cert cannot self-renew.

Enrollment issues a certificate only, not a bearer token. Before the daemon accepts a session, pair with the gateway for a zc_... token and give it to zerocode via ZEROCLAW_AUTH_TOKEN, auth_token_file, or auth_token (see Bearer token). With the token in place, zerocode --connect wss://<remote-host>:9781 (or just zerocode if uri is in config) needs no more flags.

Enrollment endpoint defaults: --enroll-host defaults to --connect’s host; --enroll-port defaults to 9782.

The first release intentionally requires a pairing code for every enrollment. The reserved allow_unpaired_enrollment knob is rejected at daemon startup until the client has an explicit no-code trust anchor such as a pinned daemon CA fingerprint. This keeps the provisional enrollment TLS path from turning into blind trust-on-first-use.

1c. Client side - option B: operator-issued certificate

If you would rather mint a cert on the daemon and copy it out:

# On the daemon host. --out-dir also writes a drop-in ca.crt/client.crt/client.key.
zeroclaw security issue-client-cert --name my-laptop --out-dir /tmp/my-laptop-tls
# add --force to overwrite an existing cert for this name

Copy the three files to the client’s <config-dir>/tls/ as ca.crt, client.crt, client.key (then zerocode --connect wss://host:9781 finds them automatically), or point at them explicitly:

zerocode --connect wss://<remote-host>:9781 \
  --tls-ca-cert  /path/ca.crt \
  --tls-client-cert /path/client.crt \
  --tls-client-key  /path/client.key

Equivalent config (so plain zerocode works):

[connection.wss]
uri = "wss://<remote-host>:9781"

[connection.wss.tls]
ca_cert_path     = "/abs/path/ca.crt"
client_cert_path = "/abs/path/client.crt"
client_key_path  = "/abs/path/client.key"

Plain zerocode still needs a bearer token, from ZEROCLAW_AUTH_TOKEN or from auth_token_file / auth_token under [connection.wss].

A certless client that reaches the WSS plane without enrolling gets an actionable “enroll first” message (and the daemon logs the rejected un-migrated client) - never a silent hang. --tls-skip-verify only relaxes server verification for a self-signed dev daemon; the client certificate is still required.


Topology 2: daemon to relay

daemon  ====== outbound: register + bridge ======>  relay  :8443
[relay]                                             (blind forwarder)

The daemon dials the relay, proves a stable Ed25519 identity, and registers a node-id. Clients later dial that node-id (Topology 3). The relay only ever forwards ciphertext.

2a. Run the relay (zerorelay)

Configure with relay.toml (see apps/zerorelay/relay.example.toml); every CLI flag overrides the matching file value. The [admission] section hot-reloads on SIGHUP; a reload that would turn a public relay into open, tokenless admission without the explicit opt-in is refused, and the previous policy stays live.

# relay.toml
bind = "0.0.0.0:8443"

[tls]
# Omit cert/key to SELF-PROVISION an outer TLS cert into dir on first run (no
# openssl). Set sans to the relay's public hostname(s)/IP(s).
dir  = "/data/tls"
sans = ["relay.example.com"]
# Or bring your own (e.g. a public-CA cert):
# cert = "/etc/zerorelay/fullchain.pem"
# key  = "/etc/zerorelay/privkey.pem"

[admission]
# "open" admits any signed daemon (subject to the deny list); "allowlist" admits
# only listed daemon pubkey fingerprints. Deny always wins.
mode = "open"
allow = []
deny  = []
# A public (non-loopback) relay MUST gate registration: set a shared secret here
# (each daemon presents it via [relay] token) or use mode = "allowlist".
# A relay that serves daemons enrolled with `zeroclaw relay claim` must use
# mode = "allowlist" and leave relay_token unset (see 2d).
# Otherwise an OPEN, tokenless relay on a public bind refuses to start, because
# any daemon on the internet could register and squat unclaimed node-ids. (A
# loopback bind for local development is exempt; a deliberate open public relay
# can override with allow_public_open = true.)
relay_token = "change-me-to-a-long-random-secret"

[limits]
max_conns_per_node     = 256
idle_timeout_secs      = 300
lease_ttl_secs         = 300
accept_burst_per_ip    = 30
accept_rate_per_ip     = 10.0
connect_burst_per_node = 60
connect_rate_per_node  = 20.0

Run it:

zerorelay --config /etc/zerorelay/relay.toml
# equivalently, flags only (a public bind needs a token or an allowlist, else the
# relay refuses to start):
zerorelay --bind 0.0.0.0:8443 --tls-san relay.example.com \
  --relay-token change-me-to-a-long-random-secret

When --tls-cert/--tls-key are omitted the relay self-provisions a CA + server cert under the TLS dir (resolution order: $ZERORELAY_DATA_DIR/tls, else $HOME/.zerorelay/tls, else ./zerorelay/tls); localhost and 127.0.0.1 are always in the SANs. The self-provisioned ca.crt is what a daemon/client trusts for the relay’s outer TLS.

Admission. open mode plus an optional relay_token is the simplest access control. allowlist mode keys on the daemon’s registration pubkey fingerprint (SHA-256 hex of the Ed25519 key at the daemon’s <data_dir>/relay/registration.key); add fingerprints to allow (and reload with kill -HUP <pid>). A node-id is bound to its first registrant’s pubkey, so a different key cannot hijack a live node-id (it gets node_taken).

The relay checks relay_token before the allow list, in every mode. A relay that serves self-serve claims (2d) must therefore use allowlist with relay_token unset: a claim never delivers the shared token, so a claimed daemon would be refused with forbidden: bad relay token. The allow list already gates a public relay on its own, so the token adds nothing there.

Docker. apps/zerorelay/Dockerfile runs distroless with CMD ["--config", "/etc/zerorelay/relay.toml"] and a shell-less zerorelay healthcheck --addr HEALTHCHECK; compose.yaml mounts a volume at /data so the self-provisioned TLS persists. Expose 8443.

2b. Point the daemon at the relay

[wss]
enabled = true          # REQUIRED: the relay forwards to the local WSS listener

[relay]
enabled = true
url = "relay.example.com:8443"
# node_id: leave EMPTY (recommended) to auto-mint + persist a random 128-bit
# capability at <data_dir>/relay/node_id. Set it only to pin a specific id.
# token = "change-me"   # must match the relay's [admission].relay_token, if set

# Trust for the relay's OUTER certificate - pick ONE:
relay_ca_path = "/path/to/relay/ca.crt"   # trust the relay's (self-signed) CA
# tofu = true                              # OR pin the relay leaf on first use
# relay_insecure = true                    # OR skip outer verification (dev only)
# (leave all three unset to use built-in public roots, for a public-CA relay)

[relay] requires [wss] enabled (the relay bridges to 127.0.0.1:<wss.port>), and fails closed if url is empty. On start the daemon logs the node-id with the hint “give clients this as –relay-node”; you can also read it from <data_dir>/relay/node_id. The daemon’s stable registration key is created at <data_dir>/relay/registration.key (0600).

Outer-cert trust precedence (highest first): relay_insecure > relay_ca_path > a stored pin at <data_dir>/relay/relay_pin (explicit or TOFU) > tofu > public roots. Configuring a CA therefore replaces a stale pin without deleting it. With tofu = true the observed relay leaf fingerprint is pinned to <data_dir>/relay/relay_pin, and enrollment hands that same pin to clients so they pin the identical leaf.

2c. (optional) node-id rotation and outer mTLS

[relay]
node_id_rotation_days = 30   # auto-rotate the auto-minted id every N days (0 = never)

Rotation mints a fresh id, runs it alongside the old one for a 10-minute grace window so in-flight clients are not cut off, then retires the old id; the new id reaches clients in-band on their next certificate renewal. Force one now with zeroclaw security relay-rotate-node-id (auto-mint mode only; a pinned node_id is never rotated).

For a relay that authenticates daemons on the outer layer too, set the relay’s [admission].outer_client_auth = "required" + outer_client_ca, and on the daemon [relay].outer_client_cert / outer_client_key. This is additive on the outer TLS and never touches the inner mTLS.

2d. Self-serve enrollment with zeroclaw relay claim

When the relay is run alongside a ZeroRelay control plane, an operator can enroll a daemon with a one-time claim token instead of editing the relay’s allow list by hand. The control plane issues the token and prints the command to run on the daemon host:

zeroclaw relay claim <TOKEN> --control https://control.example.com

The daemon signs the claim with its existing registration key, so the fingerprint the control plane allow-lists is exactly the key the daemon registers with. On a verified success the control plane adds that fingerprint to the relay’s allow list and reloads the relay, and the daemon writes [relay] enabled, url, node_id, and relay_host. Start or restart the daemon to register.

Before claiming:

  • The relay must admit by fingerprint alone. Use mode = "allowlist" and leave relay_token unset (see 2a). A claim does not deliver the shared token.
  • Enable [wss]. The relay refuses registration until the WSS listener is enabled. A claim made without it is still saved, and takes effect on the next start after [wss] is enabled.
  • Configure trust for the relay’s outer certificate first, as in 2b (relay_ca_path, tofu, or public roots). The claim writes only the four fields above and leaves the rest of [relay] alone.
  • Do not set the claim-managed fields through environment overrides. If relay.enabled, relay.url, relay.node_id, or relay.relay_host is overridden, the claim refuses before sending anything, because the override would discard the written values.

--control must be https, or http on a loopback address. Redirects and proxies are disabled so the token cannot reach another origin. Every refusal says no config was written. A token is single-use: once the control plane accepts it, it is spent, even if the daemon then refuses the response (for example an unusable relay address). In that case, fix the cause and mint a new token.


Topology 3: client to relay to daemon

zerocode  ==outer TLS+WS==>  relay  ==forwards ciphertext==>  daemon
   \________________ inner mutual-TLS (TLS 1.3) terminates here _______________/

This composes Topologies 1 and 2: the client needs an inner client certificate (enroll, as in 1b) and the relay coordinates (address, node-id, and trust for the relay’s outer cert).

3a. The easy path: enrollment carries the relay profile

When the daemon has [relay] configured, its enrollment response includes a relay profile (relay_url, node_id, and the relay’s leaf relay_cert_pin). So a single enrollment provisions everything:

zerocode --enroll --connect wss://<daemon-host>:9781

zerocode caches the inner cert and the relay profile in <config-dir>/tls/profile.json. Later, plain zerocode reaches the daemon through the relay with no flags - it already knows the relay address, the node-id, and the pin.

3b. The manual path

Give the client the relay coordinates explicitly. The inner cert still comes from enrollment or --tls-* (Topology 1):

zerocode \
  --relay      relay.example.com:8443 \
  --relay-node <node-id-from-daemon-log> \
  --relay-ca   /path/to/relay/ca.crt
# inner mTLS material: from <config-dir>/tls (after enrolling), or pass --tls-* flags

Choose exactly one trust mode for the relay’s outer cert, mirroring the daemon:

FlagMeaning
--relay-ca <pem>Trust the relay’s (self-signed) CA
--relay-pin <sha256>Pin the relay’s outer leaf (usually delivered at enrollment)
--relay-tofuTrust on first use; persist the pin to <config-dir>/relay/relay_pin
--relay-insecureSkip outer verification (dev/self-signed only)
(none)Use built-in public roots (public-CA relay)
--relay-host <name>Override the expected outer-cert SAN (defaults to the --relay host)

Config equivalent (so plain zerocode works):

[connection.wss]
relay_url  = "relay.example.com:8443"
relay_node = "<node-id>"

The relay’s outer trust (--relay-ca / --relay-pin / --relay-tofu / --relay-insecure) is supplied by flags or the cached enrollment pin, not by [connection.wss] keys.

3c. Direct-first with relay fallback

If you give the client both a direct address and a relay, it prefers the direct path and falls back to the relay, then re-probes and migrates back:

zerocode --connect wss://<daemon-host>:9781 \
         --relay relay.example.com:8443 --relay-node <node-id>

Tuning (in [connection.wss]):

KeyDefaultMeaning
direct_attempts2Direct attempts before falling back to the relay
direct_timeout_secs3Per-attempt direct-connect timeout
reprobe_secs30Re-probe cadence to migrate back to direct (0 disables)

In relay-only mode (no --connect/uri), the inner WSS URL defaults to wss://127.0.0.1:9781 because the inner mTLS terminates at the daemon’s loopback listener; the relay address is only the TCP dial target.

3d. Browser enrollment through the relay frontdoor (opt-in, enrollment only)

The relay can serve a browser enrollment page (the “frontdoor”): open the relay URL, enter the node id and a pairing code, compare the short-auth-string against the daemon console, and the browser is issued a client certificate.

It enrolls only. There is no browser session tier: once enrolled, the page hands you the certificate and private key and points you at zerocode. The daemon’s RPC plane requires mTLS, which a browser cannot present through the native WebSocket API, so a browser session would need either a TLS implementation in page JavaScript (deliberately not shipped, see below) or a relay-terminated session mode on the daemon. That is an open design question, not a missing feature.

It is off by default, because it narrows the blind-forwarder guarantee for the browsers that use it, in two distinct ways:

  • The relay is a trusted code origin. A browser that enrolls here runs enrollment JavaScript served by the relay. A compromised relay could substitute code that leaks what the page handles.
  • The relay is a principal in the enrollment. The daemon’s enrollment endpoint speaks TLS, and the relay forwards those bytes without decrypting them, so a browser cannot perform the exchange itself. The relay therefore performs it on the browser’s behalf: it fetches the daemon CA, then makes the CA-pinned POST /enroll carrying the pairing code. The relay sees the pairing code and the issued certificate, and could use an observed pairing code to enrol a client of its own.

What the relay does not get is the private key: the browser generates the keypair with WebCrypto and transmits only a CSR.

The short-auth-string still works as a detector - a relay that substitutes a different daemon CA produces a SAS that does not match the daemon console, so stop if it differs. It cannot protect you from a relay you have chosen to trust: browser enrollment through the frontdoor is relay-terminated, not end-to-end.

Earlier iterations of this frontdoor shipped a hand-written TLS 1.3 and X.509 client in relay-served JavaScript so the browser could speak to the daemon itself. That is deleted, not ported. An unaudited TLS stack in served JavaScript is a worse trust story than an honestly disclosed relay-terminated one, and it did not remove the trusted-code-origin problem anyway.

The narrowing is bounded: the RPC plane stays end-to-end mTLS past the relay in every mode, and zerocode/native enrollment never executes relay-served code and is never relay-terminated - use those (sections 1b/1c/3a) when the relay must stay fully untrusted.

# relay.toml
[frontdoor]
enabled = true   # default false; read the trust note above first

When disabled, plain HTTP requests to the relay get a 404 and browsers cannot enroll through it; daemon registration, zerocode clients, and the tunneled RPC plane are unaffected. The enrollment page itself carries the same trust note, and zerorelay logs a warning at startup while the frontdoor is on.

To spare a phone user from typing a long node id, a link can carry the node id and the pairing code, and the page fills both fields when it loads:

https://relay.example.com/#node=<node-id>&code=<pairing-code>
https://relay.example.com/?node=<node-id>&code=<pairing-code>

Prefer the # form. A URL fragment is never sent to any server, so the pairing code stays out of reverse-proxy and CDN access logs; the ? form is accepted too. The relay itself drops the query string before routing and never logs or reflects it, and serves the page with cache-control: no-store and referrer-policy: no-referrer.

The page only fills the fields. It does not fetch the agent CA or submit anything: the user still presses Fetch the agent CA and confirms the short-auth-string. Values that do not match the node-id or pairing-code shape are ignored, and both parameters are removed from the address bar and the current history entry as soon as the page reads them, so a copied or bookmarked URL does not carry the code.

The browser can still record the link as it was first opened in its own history database (Chrome does, for both the ? and # forms); no page can rewrite that. What makes that copy harmless is the code itself: it is consumed by the first successful enrollment and expires ten minutes after it is minted, so the stored link is spent or dead. Treat a link that carries an unused code like the code itself, and prefer enrolling promptly after minting.


Configuration reference

Daemon [wss]

KeyDefaultDescription
enabledfalseEnable the mutual-TLS WSS listener
bind0.0.0.0Bind address
port9781Listen port
cert_path(empty)BYO server cert; empty auto-generates under <data_dir>/tls/
key_path(empty)BYO server key; empty auto-generates

Daemon [wss.client_auth] (optional; mTLS is always on either way)

KeyDefaultDescription
enabledfalseUse a BYO CA; when false the daemon uses its auto-generated CA
ca_cert_path(empty)PEM CA used to verify client certs (BYO mode)
pinned_certs[]If non-empty, only client certs matching these SHA-256 fingerprints are accepted
crl_path(empty)Revoked-fingerprint file; empty uses the ledger-materialized <data_dir>/tls/revoked

Daemon [enroll]

KeyDefaultDescription
enabledfalseEnable the enrollment endpoint (requires [wss] + a CA key)
bind0.0.0.0Bind address
port9782Listen port
allow_unpaired_enrollment(empty)Reserved; non-empty values are rejected until a no-code client trust anchor exists

Daemon [relay]

KeyDefaultDescription
enabledfalseEnable the relay bridge (requires [wss])
url(empty)Relay address host:port; required when enabled
node_id(empty)Empty auto-mints + persists a 128-bit id; set to pin one
token(empty, secret)Shared-secret presented at registration
relay_ca_path(empty)PEM CA for the relay’s outer cert; empty uses public roots
relay_host(empty)Expected outer-cert SAN; empty derives from url
relay_insecurefalseSkip outer-cert verification (dev only)
tofufalsePin the relay leaf on first use to <data_dir>/relay/relay_pin
outer_client_cert(empty)Daemon’s outer-mTLS client cert for relay admission
outer_client_key(empty)Key for outer_client_cert
node_id_rotation_days0Auto-rotate the auto-minted node-id every N days (0 = never)

Relay relay.toml

Section.keyDefaultDescription
bind0.0.0.0:8443Listen address (daemon + client)
[tls].cert / .key(self-provision)Outer TLS identity; omit both to self-provision
[tls].dirdata dir /tlsWhere the self-provisioned cert is written
[tls].sans[]Extra SANs (localhost, 127.0.0.1 always included)
[admission].modeopenopen or allowlist
[admission].allow / .deny[]Daemon pubkey fingerprints (deny wins)
[admission].relay_token(none)Optional shared-secret gate
[admission].outer_client_authoffoff / optional / required (outer mTLS)
[admission].outer_client_ca(none)PEM CA for outer client certs
[admission].route_by_client_certfalseRoute by the outer cert CN’s node-id
[limits].max_conns_per_node256Simultaneous client conns per node-id
[limits].idle_timeout_secs300Drop idle client conns after N seconds
[limits].lease_ttl_secs300Lease TTL advertised at registration (advisory in v1: WebSocket liveness is the real cleanup rule)
[limits].accept_burst_per_ip / accept_rate_per_ip30 / 10.0Per-IP handshake token bucket
[limits].connect_burst_per_node / connect_rate_per_node60 / 20.0Per-node connect token bucket
[frontdoor].enabledfalseServe the browser enrollment page (see 3d: makes the relay a trusted code origin AND a principal in browser enrollment)
[limits].max_pending_handshakes256Sockets past accept but not yet classified
[limits].handshake_timeout_secs10One deadline over TLS, WS upgrade and signed registration
[limits].max_registered_nodes1024Simultaneously registered daemons (N+1 gets registry_full)

zerorelay CLI (overrides relay.toml)

--config --bind --tls-cert --tls-key --tls-dir --tls-san (repeatable) --registration-mode --allow (repeatable) --deny (repeatable) --relay-token --max-conns-per-node --idle-timeout-secs --lease-ttl-secs --status-file --frontdoor (opt-in; see 3d for the trust implications). Subcommands: healthcheck [--addr 127.0.0.1:8443], status --file <path>.

zerocode [connection.wss] and CLI

[connection.wss] keyDefaultCLI override
uri(none)--connect
relay_url(none)--relay (requires --relay-node)
relay_node(none)--relay-node (requires --relay)
direct_attempts2-
direct_timeout_secs3-
reprobe_secs30-
auth_token(none)ZEROCLAW_AUTH_TOKEN env (wins over both token keys)
auth_token_file(none)- (wins over auth_token; must be owner-only)
auth_providernative (pairing token)- (e.g. oidc.<alias>)
[connection.wss.tls] keyDefaultCLI override
ca_cert_path<config-dir>/tls/ca.crt--tls-ca-cert
client_cert_path<config-dir>/tls/client.crt--tls-client-cert (requires key)
client_key_path<config-dir>/tls/client.key--tls-client-key (requires cert)
skip_verifyfalse--tls-skip-verify

Relay outer-trust and enrollment are CLI/cache only: --relay-ca --relay-host --relay-insecure --relay-pin --relay-tofu --relay-client-cert --relay-client-key --enroll --enroll-host --enroll-port.

File layout

Daemon <data_dir>/:

tls/ca.crt  tls/ca.key            per-daemon CA (key 0600)
tls/server.crt  tls/server.key    WSS server leaf (key 0600)
tls/ledger.db                     issued-cert ledger (SQLite)
tls/revoked                       revoked fingerprints (handshake-checked)
relay/registration.key            Ed25519 relay identity (0600)
relay/node_id                     auto-minted node-id
relay/relay_pin                   pinned relay outer-leaf fingerprint (TOFU)

Client <config-dir>/:

tls/client.crt  tls/client.key    client identity (key 0600)
tls/ca.crt                         pinned daemon CA
tls/profile.json                   device_id, not_after, cached relay profile
relay/relay_pin                    relay outer-leaf pin (--relay-tofu)

Relay <tls-dir>/: ca.crt, server.crt, server.key (self-provisioned).

Upgrading an existing issued-cert ledger

tls/ledger.db carries a schema version. A ledger written by an earlier revision is rebuilt in place the first time the new daemon opens it, preserving every issued and revoked certificate along with its device id, validity and audit actor. The rebuild runs in a single transaction: if it cannot complete, the daemon refuses to start and leaves the original ledger untouched rather than half-migrated, and the error names the file. No operator action is required, and enrolled devices do not need to re-enroll.

Undelivered certificates

The daemon records an issuance in the ledger before it hands the certificate over - to the enrolling client, to a renewing client, or to the operator’s issue-client-cert files. That order is deliberate: the alternative can leave a live CA-signed certificate in someone’s hands with no ledger row, and a certificate the ledger has never heard of cannot be listed or revoked.

The cost is that a failure in between - a client that disconnects mid-response, a failed rename - leaves an active row for a certificate nobody received. Such a row is tracked separately as undelivered and is revoked once it is older than one hour, at the first of:

  • any new certificate issuance or renewal,
  • any enrollment connection,
  • any daemon restart or other ledger open.

It is not a background timer. A daemon doing no certificate work at all defers the sweep until its next such activity; until then the certificate stays out of tls/revoked and would still pass a WSS handshake, though it cannot renew. Any enrollment or renewal traffic - including a connection that fails to authenticate - is enough to reconcile it.

Reconciled certificates are revoked, never deleted: the row stays in the ledger and the fingerprint lands in tls/revoked exactly like an operator revocation, with the audit actor reconcile:undelivered so the trail distinguishes them. security list-client-certs shows ACTIVE certificates only, so a reconciled certificate disappears from that listing - during incident response, read tls/revoked or the audit log for revocation history. If a device reports that a certificate it did receive stopped working, look for that actor - it means the delivery record was lost, and the fix is to re-enroll or re-issue.

Verifying and troubleshooting

  • Daemon up: look for the WSS listener log on 0.0.0.0:9781 and, with a relay, the node_id log line.
  • Certless connect refused: expected on the mTLS plane - enroll first (Topology 1b). The message is actionable, not a hang.
  • Relay reachable: zerorelay healthcheck --addr <host>:8443 exits 0.
  • Relay metrics: run with --status-file <path>, then zerorelay status --file <path> (counts only, never payloads).
  • Revoke a lost device: revoking in the daemon ledger materializes <data_dir>/tls/revoked; the cert is refused at its next handshake and cannot self-renew.
  • SAS mismatch at enrollment: the client refuses to persist the cert and aborts. A mismatch means the CA you received is not the daemon’s - investigate a possible man-in-the-middle before retrying.

There is a self-contained end-to-end harness at scripts/dev/mtls-relay-testbed.sh that boots a daemon + relay, issues a cert, enrolls over the wire, and exercises all three topologies; read it as a worked example.