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.
  • 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

Every later run is zero-config (zerocode --connect wss://<remote-host>:9781, or just zerocode if uri is in config). The cert auto-renews at ~50% of its lifetime (~15 days) over the live mTLS session; a revoked cert cannot self-renew.

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"

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] relay_token) or use mode = "allowlist".
# 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).

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.


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.


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
[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. 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-
[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.