Envelope; the payload and its security properties depend on the Type.
Endpoints & transport
Timings that keep the link honest:
- CP pings every 30 s (
TypePing); the daemon answersTypePong. - CP read timeout 90 s; daemon read timeout 60 s — a silent link (no message, not even a ping) is treated as dead and torn down.
- Daemon read cap 1 MiB (snapshots can be large).
- Daemon reconnect: exponential backoff, 2 s doubling, capped at 30 s, with jitter so a CP restart doesn’t thundering-herd the fleet. Reset on a successful auth.
The envelope
TraceContext is diagnostic metadata, not authorization or application data. Custos propagates
only W3C traceparent and tracestate—never OpenTelemetry baggage. It is not part of snapshot or
secret-set signing input, so it carries no security meaning; the receiver still independently
verifies the signed Data and Seq fields.
Two trust layers
- WSS / TLS — protects every message hop-by-hop and authenticates the server. May terminate at a proxy in front of the control plane.
- App-level sign / seal — end-to-end CP ↔ host, survives that proxy. Only some messages carry it, by need (below).
snapshot/secret_sets
are signed so authenticity + seq hold even past a TLS-terminating proxy; secret_sets is also
sealed so the secrets stay confidential end-to-end (a proxy sees only the blob).
Message table
grant / revoke types exist in the protocol but aren’t in the live path — bulk revoke re-sends a
full snapshot.
Enrollment (HTTP, one-shot)
The daemon’sEnroll generates a fresh Ed25519 identity keypair and an X25519 encryption keypair
(the private halves never leave the machine), then POSTs them to /enroll:
token— admin-issued, single-use (consumed in the same transaction as host creation).machine_id— a sha256 hash of/etc/machine-id(or fallbacks); empty for app containers.prior_host_id— the previous host id on a re-enroll; dedup fallback whenmachine_idis empty.
machine_id (or an active prior_host_id) already maps to an active host, the enroll is
rejected with 409 — a revoked host frees the machine to re-enroll. Response:
The handshake (challenge / auth)
Authenticates the host to the CP (the CP is trusted via TLS — the daemon dialed a known URL):- CP → daemon:
challenge={ nonce }(32 random bytes, unsigned). - daemon → CP:
auth={ host_id, signature, version }— the daemon signs the nonce with its host identity Ed25519 key under thecustos-host-auth:v1:domain prefix. The CP verifies against the identity public key registered at enroll and rejects hosts whose status isn’tactive.
version is the daemon’s build; the CP persists it as hosts.agent_version and re-pushes an upgrade
to hosts that are still behind desired_version.
Keys
Three keypairs, registered (public halves) at enroll:Signing input & sequence
Signed messages signtag ‖ len16(hostID) ‖ hostID ‖ seq(8B) ‖ Data, each type under its own
domain-separation tag so a signature for one can’t be replayed as another:
snapshot→custos-snapshot:v1, sequenced byhosts.last_access_seq; applied/known seq tracked inhosts.acked_access_seq.secret_sets→custos-sets:v1, sequenced byhosts.last_set_seq(separate counter); applied/known seq tracked inhosts.acked_set_seq.
seq ≤ the last it applied.
secret_sets wire shape
The whole bundle is sealed once — set names and as_user live inside the ciphertext, so nothing
leaks:
ENC_pub) → HKDF-SHA256 (both public keys as salt) →
AES-256-GCM. One ephemeral key per push; its public half is the first 32 bytes of the blob. The GCM tag
proves integrity but not sender — anyone with the host’s public key could forge a valid-looking sealed
blob — which is why the Ed25519 signature (authenticity) and seq (replay) are also required.
Verify-sig-then-decrypt.
Connection lifecycle
Connect. The daemon dials/daemon, runs the challenge/auth handshake, then the CP registers the
connection in the hub and immediately queues, in order:
- a signed
snapshot— the full authoritative key set; the daemon replaces its cache with it; - a sealed
secret_setsbundle (only if the host registered an encryption key) — reconciled on every connect; - an
upgradeifdesired_version ≠ agent_version— so a host that was offline for its upgrade gets it on reconnect.
send channel per connected host. Every message goes through a single
writer goroutine (CP pings + pushed envelopes); daemon-side, pongs and queued access logs funnel
through one writer too. The hub push is non-blocking: a full buffer is dropped, because the daemon
reconciles from the snapshot on its next (re)connect anyway.
Disconnect. The CP writer flushes any still-queued envelopes (e.g. a revoke’s purge) before the
close completes. The daemon reconnects with jittered exponential backoff; on success the backoff resets
and the snapshot reconciliation happens again.
Rejection (revoke). Revoking a host sends an empty snapshot (the daemon’s whole authorized set
becomes nothing) then force-disconnects it. The daemon sees the StatusPolicyViolation close — a
deliberate rejection, not a transient drop — and purges its local access cache, then backs off at
the maximum so it doesn’t hammer a CP that has disowned it. A decommissioned host re-enrolls fresh and
must re-earn its grants.