Skip to content

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. Enroll hard-refuses unless a CA-fingerprint pin is present and the Hub URL is https:// — 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 as ca.crt is 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.crt against 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 pre hook aborts the renewal; deploy/post failures 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_target refuses 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.