Scout — security¶
Scout is a high-trust agent: it holds a private key, carries an mTLS identity, runs as root, and executes commands the Hub sends it. (It does not run code the Hub supplies — lifecycle hooks are operator-local files; see below.) Its entire safety model therefore reduces to one question:
Can this Scout trust that it is really talking to the legitimate Hub?
Cyphers answers that in layers, from first contact to every ongoing connection.
CA-fingerprint pinning¶
Everything Scout trusts flows from the Hub CA it adopts at enrollment
(ca.crt), and that CA is only as trustworthy as the handshake it was captured
from. Scout pins it:
- Enrollment requires a pin.
Enrollhard-refuses unless a CA-fingerprint pin is present and the Hub URL ishttps://— it generates no key and sends nothing otherwise.--ca-fingerprint sha256:…(the hash of the Hub CA, not the certificate) is therefore required, not optional: it is checked against the live Hub before the CSR is sent, and the CA Scout keeps asca.crtis the one captured from that same verified handshake — never taken on faith from the reply. There is no unpinned enrollment path. - Every subsequent start re-verifies the on-disk
ca.crtagainst the pinned fingerprint. A mismatch — an attacker swapping the CA on disk, or a different Hub answering — is fatal: Scout refuses to start (fail-closed). - The pin is of the intermediate CA — the certificate that directly issues Scout's own cert — so a rogue or rotated intermediate under the same root cannot hijack the channel.
The one place a not-previously-pinned CA is pinned for the first time is during rotation, for an agent that enrolled before pinning existed; a new enrollment always requires an explicit pin. Full detail: Enrollment & identity.
Scout re-checks its own identity certificate for rotation every 6 hours,
inside a window of clamp(lifetime ÷ 4, 7 days, 30 days) — a 47-day cert
rotates in its last ~12 days, a 365-day cert in its last 30. Each rotation
re-verifies the live Hub identity against the stored pin before writing
anything.
mTLS identity & per-agent NATS ACLs¶
Enrollment gives Scout a client certificate signed by the Hub CA. That cert is its identity for two things: the NATS connection to the Hub, and the client cert it presents when probing mTLS endpoints.
On the NATS channel the Hub maps each verified client cert to a per-agent
ACL (nats_acl): a Scout may publish only on cyphers.scout.{id}.> and
subscribe only to cyphers.hub.{id}.>. So even a compromised Scout cannot
impersonate another agent's reports or read another agent's commands.
Independent verification of the Hub's identity (SPIFFE checks)¶
The baseline channel to the Hub is always mutually-authenticated TLS, pinned to the Hub's intermediate CA and enforced fail-closed on every connection (see above). That proves the Hub holds a certificate the pinned CA issued — but a compromised Hub still holds a valid certificate. The acceptance policy adds a second, independent check of which Hub identity is allowed, so a compromised control plane cannot silently widen the trust boundary.
What an acceptance policy is. A small document, signed by the Hub and
distributed to the Scout, that lists exactly which Hub identities this Scout may
accept on its NATS connection. An identity here is a SPIFFE URI SAN on the
Hub's certificate — for example spiffe://<trust-domain>/hub/nats. The policy
is keyed by the Scout's own resource URI — its SPIFFE identity, assigned by
the Hub at enrollment and stored in state.json (see
Resource SPIFFE URI). The verifier looks up
this Scout's entry and accepts only the Hub identities that entry names.
Who signs it, and how the Scout trusts the signature. The Hub signs each
policy with an ES256 key, self-provisioned on first boot — no operator
configuration required. The Scout pins that key's fingerprint at install,
via --policy-signer-fp sha256:… (required alongside --ca-fingerprint), then
fetches the signer's actual public key from the enroll reply, verifies its
fingerprint against the pin, and persists the verified key locally. The Scout
trusts a policy because of the pinned signer key — not because of who
delivered it.
How it reaches the Scout. The first policy is delivered in the pin-verified enrollment reply, so the cache is warm before the Scout's first NATS connect; identity rotation re-delivers it, and steady-state updates arrive over the NATS channel. The Hub re-signs and republishes on a fixed cadence (every 10 min) so a connected Scout's cache never ages past its 1 h staleness tolerance. The Scout caches the current policy and treats the cache as anti-rollback (its version can never regress, so an attacker cannot replay an older, more permissive policy) and staleness-bounded (a policy older than the tolerance is refused).
What enforcement means. The acceptance policy is mandatory — there is
no ratchet, no observe-only or baseline-only mode, and no fleet-wide
enforcement flip to opt into it. A Scout never dials NATS without a verified
signed policy already in hand, from its very first connection: enrollment
always delivers the signer's public key and a signed policy in the same reply,
verified and applied before the first NATS connect, so a fresh install has no
bootstrap gap. Every connection runs the verifier in enforce mode, and a
missing, stale, unaccepted, or revoked verdict blocks the handshake — the
connection is refused, not merely recorded (the decision is still published to
the Hub so it can show why). Because enforcement is fail-closed, a bad or stale
policy can keep a Scout from connecting; that is the accepted cost of the
guarantee. If a Scout's local policy cache is ever wiped, it re-enrolls
automatically to re-seed a fresh policy before connecting — using the
re-enrollment token persisted in state.json, or a fresh --token (and
--policy-signer-fp) from the Hub UI if that token is also gone.
The payoff: if the Hub's control plane (API / DB / NATS) is compromised but the policy signing key is isolated from it, the attacker cannot push a policy that makes Scouts accept a rogue identity — the trust boundary cannot be widened from a compromised Hub. This is the differentiator for regulated / FIPS deployments. The present-day bound on that guarantee — the signing key co-located with the Hub — is stated as a fact under Limitations → Security residuals.
Mechanism and the per-connection decision flow are on Transport & health → Acceptance-policy verification.
Peer certificate revocation¶
When a policy sets revocation_check, the verifier also confirms the peer's cert
isn't revoked — via the Hub's stapled OCSP response (verified against the
pinned issuer; a delegated responder must carry id-kp-OCSPSigning), falling
back to a CRL (issuer-signature-verified, cached). required fails closed
(revoked or unverifiable ⇒ reject) and is the Hub's generated default;
soft-fail fails open and is an explicit opt-out. Every error path is
fail-safe undetermined — a revoked or unverifiable cert can never read as good.
Hub-side stapling is mandatory (ca_url is required at startup; the NATS conf
always staples), so a staple is present on every handshake. Full mechanism:
Transport & health → Peer certificate revocation.
Lifecycle hooks — operator-local¶
Hooks are executables the operator places on the Scout host, at
<data-dir>/hooks/{pre,deploy,post} — on a standard install that is
/opt/cyphers/data/hooks/{pre,deploy,post}. At each lifecycle point Scout runs
the matching local file. There is deliberately no mechanism by which the Hub can
supply, select, or run code on a Scout.
- No command string, no shell, no arguments — the file itself is the hook, executed directly. There is nothing to inject.
- Scout refuses a group/world-writable hook file (a non-root user could otherwise drop code that runs as root), and skips a non-executable one.
- Output is bounded and the run is timed out. A failing
prehook aborts the renewal;deploy/postfailures are logged but don't roll back an already-installed cert. - Scout reports its hook inventory (event, path, mode, SHA-256, state) to the Hub for read-only display on the agent page. The Hub cannot create or change hooks — edit them on the Scout host.
File permissions¶
| Path | Mode | Why |
|---|---|---|
/opt/cyphers/data/ |
0700 |
enforced by both installer and Scout |
…/agent.key |
0600 |
the private key |
…/state.json |
0600 |
pinned fingerprint, re-enroll token |
/var/log/cyphers-scout/ |
0700 / files 0600 |
logs — structured JSON, secrets never logged |
Tokens, private keys, and passphrases never reach either log sink at any level — a build-failing lint test enforces it; see Logging → What is never logged.
Endpoint probing (scan_target)¶
Scout only dials a target the operator authorized on the box — loopback, its
own host, or an explicit --target. scan_target's hostname/port are entirely
Hub-controlled, so a target the Hub merely names (and the operator never listed)
is refused before any network I/O, with a failed ack that names --target as
the way to authorize it. This stops a compromised or MITM'd Hub from using the
Scout as an internal-recon pivot, and also means the Scout's identity cert is
never offered to a host the operator didn't trust.
Consequence to know: a Hub-driven scan of a new endpoint (e.g. adding a host
under a wildcard in the dashboard) only runs once that host is in the Scout's
--target list — the Hub cannot authorize a new dial target on its own.
Threat model & residual gaps¶
The design closes the highest-impact risks:
- No Hub-shipped code execution. Hooks are operator-local and the Hub ships none, so there is no fleet-wide remote-code-execution surface.
- Hub CA pinned fail-closed after first use, to the intermediate and enforced at connection time, so a swapped intermediate cannot hijack the channel.
scan_targetrefuses unauthorized dial targets, so a compromised or MITM'd Hub cannot use a Scout as an internal-recon pivot.
The present limitations to be aware of are stated as facts under Limitations → Security residuals: Scout runs as root inside a systemd sandbox rather than as a dedicated non-root user; command delivery is at-most-once (see Commands); and the policy signing key resides with the Hub, so policy verification is defense-in-depth over the pinned-mTLS baseline rather than resistance to a full Hub compromise.