Command Palette
Search for a command to run...

mTLS and signed actions

mTLS terminates at the gateway. Every agent presents a CA-signed client certificate. The gateway requires it at the TLS layer (tls.RequireAndVerifyClientCert, TLS 1.3 minimum) and then, per request, enforces a SPIFFE URI SAN check that pins the peer's class plus a fail-closed revocation check against the CRL.

The peer classes are spiffe://power-manage/<class> URI SANs: agent for device agents, gateway and control for the inter-service mTLS the InternalService proxy uses. Each listener requires a specific class, so a leaked cert of one class (an agent cert pulled from a compromised host) cannot reach a listener intended for another class — and the internal listeners additionally reject revoked gateway/control certs at connect time.

Rendering diagram…

Certificate lifecycle

StageWhat happens
EnrolmentThe agent generates a key, sends a CSR through the local enroll.sock Unix socket gated by a registration token. The control server signs a cert valid for 1 year by default.
Steady stateThe agent presents the cert on every gateway connection. The gateway verifies the chain, the SPIFFE SAN, and the revocation list.
RenewalAt 80% of cert lifetime (~292 days in), the agent calls RenewCertificate on the control server. The control server validates the presented cert's fingerprint against the DB, demands proof-of-possession, issues a new cert, and revokes the superseded one.
RevocationA Valkey-backed CRL shared by control (writer) and gateways (cached readers). Deleting a device revokes its cert; renewing a cert revokes the superseded one. The gateway's per-connection check is fail-closed.

The issued cert's validity defaults to 8760 hours (1 year, the -cert-validity flag on the control server). The CA signs only the CSR's public key — the private key never leaves the agent — and refuses any CSR that requests Subject Alternative Names, so a malicious agent can't mint a cert carrying internal hostnames. The device ID lands in the Subject CN, and the CA stamps the agent SPIFFE peer-class URI SAN itself.

Renewal, in detail

The agent drives its own renewal: a background loop computes 80% of the cert's lifetime and calls RenewCertificate at that point, retrying hourly on failure (with escalating log volume so a stalled rotation is visible in journalctl).

Server-side, the renewal is not just "any valid cert gets a new one": the presented cert's fingerprint is compared against the device's stored fingerprint in constant time (a superseded or revoked cert is refused), and the CSR must carry the same public key as the current cert (proof-of-possession — certs are public material, so without this check anyone holding a device's cert PEM could renew it onto a key they control). Concurrent renewals for one device are serialized under a per-device advisory lock.

RenewCertificate is a ControlService RPC — it travels over the control server's public HTTPS endpoint (typically Traefik with a Let's Encrypt cert), not over the agent↔gateway mTLS channel. The agent's identity proof is the current certificate in the request body plus the matching-key CSR, not the transport.

Revocation

Per-fingerprint revocation is implemented as a Valkey-backed CRL. Two paths write to it: DeleteDevice revokes the deleted device's cert (otherwise it would keep connecting until its 1-year expiry), and every renewal revokes the superseded cert. Entries carry the revoked cert's own expiry and age out on their own. There is no standalone RevokeCertificate RPC — revocation rides device deletion and renewal.

The gateway caches the CRL in memory and checks each connection's cert fingerprint against the snapshot — no per-connection RPC. The cache is fail-closed: the gateway refuses to boot if the initial CRL load fails, and a not-yet-loaded cache rejects connections ("cannot prove this cert is unrevoked") rather than admitting them. A refresh error keeps the previous snapshot (fail-static), so a transient Valkey outage never silently drops enforcement.

One CA root

Today there is one CA root in play, configured through CONTROL_CA_CERT / CONTROL_CA_KEY. The same CA signs the agent client certs and the gateway / control server certs used for the inter-service InternalService mTLS (separated by SPIFFE peer class, not by CA). The HTTPS cert on the Traefik edge is independent (your own issuer or Let's Encrypt). For rotation, CONTROL_CA_TRUST_BUNDLE can point the control server's verification pool at a PEM containing multiple CA certs while CONTROL_CA_KEY keeps signing — see CA rotation.

Signed actions

On top of mTLS, every dispatched action carries a CA signature over the full action envelope — not just an ID/type/params digest. The signed message is the deterministic protobuf wire encoding of SignedActionEnvelope:

message SignedActionEnvelope {
  // Execution id this envelope authorizes (the wire ActionId.value).
  ActionId action_id = 1;
  ActionType action_type = 2;
  DesiredState desired_state = 3;
  int32 timeout_seconds = 4;
  ActionSchedule schedule = 5;
  // The single device this envelope is authorized to run on.
  string target_device_id = 6;

  // Type-specific parameters — the same param message types as Action,
  // re-declared here so the signed bytes carry exactly what executes.
  oneof params {
    PackageParams package = 7;
    AppInstallParams app = 8;
    ShellParams shell = 9;
    ServiceParams service = 10;
    FileParams file = 11;
    UpdateParams update = 12;
    RepositoryParams repository = 13;
    FlatpakParams flatpak = 14;
    DirectoryParams directory = 15;
    UserParams user = 16;
    SshParams ssh = 17;
    SshdParams sshd = 18;
    AdminPolicyParams admin_policy = 19;
    LpsParams lps = 20;
    GroupParams group = 21;
    EncryptionParams encryption = 22;
    WifiParams wifi = 23;
    AgentUpdateParams agent_update = 24;
  }
}

Every dispatch path funnels through a single signing site on the control server, and every execution path on the agent funnels through a single verify-then-unmarshal seam: the agent verifies the CA signature over the exact bytes it received and, on success, unmarshals those same bytes to execute — so the executed message is byte-for-byte the signed message. Because the whole envelope is bound, a compromised gateway or Valkey relay cannot flip desired_state, swap params, change the timeout or schedule, lift the type onto SYNC, or retarget the device under a still-valid signature.

The signature pre-image is SHA-256(len32(domain) || domain || envelopeBytes) with the domain tag power-manage-action. Non-action stream RPCs that also run as root on the agent — osquery, log queries, LUKS key revocation, inventory requests, and the LPS sealing public key — are signed under their own domain tags, so a signature minted for one surface can never be replayed against another.

Algorithm is ECDSA (ASN.1) or RSA PKCS#1 v1.5, both with SHA-256, picked from the CA key's type; Ed25519 (and anything else) is explicitly refused by the verifier, and the control server refuses to boot with a signer-incompatible CA key rather than silently produce unverifiable dispatches.

Instant actions (REBOOT, SYNC) are signed too — the type is bound inside the verified envelope, so a signature minted for a non-SYNC action cannot be lifted onto a SYNC dispatch. If the signature doesn't verify, nothing is stored, synced, or executed: the agent returns a FAILED result carrying the verification error ("refusing to execute unsigned/tampered action"), which lands in the audit log as an ExecutionFailed event — a forgery attempt leaves a trace.

  • Terminal session start is not an action and is not CA-signed. It rides the agent's stream as a separate message; the gateway validates the session's bearer token against the control server, and the agent's local TTY enable flag is what gates it — see Remote terminal access.

The checks on a dispatch, in order

For every dispatch on the path control → Valkey → gateway → agent:

  1. TLS, both ways. The agent verifies the gateway's server cert strictly against the internal CA it enrolled with — system roots are not consulted, so a cert signed by any public CA cannot impersonate the gateway even with a matching SNI. The gateway requires and verifies the agent's client cert.
  2. Peer class + revocation at the gateway. The agent cert must carry the agent SPIFFE class and must not be on the CRL (fail-closed if the CRL hasn't loaded).
  3. Envelope HMAC at the gateway worker. The Asynq task-signing key check (see Asynq task signing) catches Valkey tampering before the gateway forwards anything to the stream.
  4. Action signature at the agent. The full-envelope CA signature, verified fail-closed before any side effect.
  5. Typed params. What executes is read exclusively off the verified envelope's typed oneof — there is no separate advisory params field to desync.

A failure at any layer ends the dispatch and surfaces as an event or a log line. No silent drops.

Trust-bundle reloads

SetTrustBundle accepts a PEM with multiple CA certs, which is the mechanism behind the documented root CA rotation flow: during the window, agent certs signed by either root verify. Picking up a new bundle requires docker compose restart control gateway so both processes re-read the on-disk material — there is no SIGHUP-style live reload.

Agents adopt a new CA only through renewal, and only when the new CA chains to the one they enrolled with: the renewal response's CA must be byte-identical to the enrolled CA or cross-signed by it. An unrelated root is refused as a trust-anchor swap and the agent keeps its existing cert + CA — a hard CA swap requires re-enrolment, by design. There is also no admin-initiated force-renew RPC; the agent's 80%-lifetime tick is the only renewal driver.