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 Scout is back on the current accept-list within one interval of reconnecting. 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). Its age is tracked and reported, never refused — see What enforcement means below.
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, 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 policy
can keep a Scout from connecting; that is the accepted cost of the guarantee.
A merely old policy is not a bad one: age is reported, never refused, so a
Scout offline past the freshness window reconnects and refreshes itself. 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 (and the issuing CA key) co-located with the Hub — is stated as a fact under Limitations → Security residuals; a separate off-box signer with HSM support is planned as a future install-time option.
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.
A wildcard certificate's directory is not named with a literal *.
*.example.com is stored at …/certs/_wildcard.example.com/, not
…/certs/*.example.com/. * is legal in a POSIX filename but is also a
shell-glob metacharacter — an unquoted reference to the certificate directory
in an operator-authored pre/deploy/post hook would silently expand
against whatever else happens to be in the parent directory instead of naming
the certificate. The certificate's own subject and SANs still carry the real
*.example.com name; only the on-disk path is rewritten this way. See
Issue a wildcard certificate (DNS-01) → Where the certificate lives on the
Scout.
Endpoint probing (scan_target)¶
Scout only dials a target authorized on the box — loopback, its own
host, an entry in the served-endpoints file (--target-file; the installer
wires <install-dir>/targets.conf, re-read fresh on every request, so an
appended line authorizes the very next scan with no reload), or a hostname
named in a certificate under a watched cert directory. Since SERVE-DECL-1
the file is also the operator's serving declaration, and the Hub's own
operator flows write to it through the Scout (+ Add endpoint appends,
Delete removes) — a deliberate ruling (2026-09-01) that trades the old
"the Hub can never touch the target list" posture for declarations that
follow provisioning; the trade is real but small, because a Hub that
delivers certificates already gets their SANs self-authorized. A target
the Hub merely NAMES in a scan_target (and no line or certificate
authorizes) is still refused before any network I/O, with a failed ack
naming the file. 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.
The same target file also feeds the scheduled scan cycle — an entry is both an authorization and a standing target (see Install → Changing scan configuration).
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 listed on the
Scout — append it to <install-dir>/targets.conf (no restart needed). The
--target flag is gone: the agent refuses to start with it, naming the file.
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.