Skip to main content

Signed dispatch

By default a runner trusts the control plane to tell it what to run. Signed dispatch removes that trust: with enforcement on, a runner executes an action only when it carries a valid Ed25519 signature a real person produced in their own MCP client. The control plane relays that signature — it holds no private key, so it can't forge, alter, redirect, or replay one, and it can't originate a run at all. The signature, not the cloud, is the authority.

It's opt-in per runner and a deliberate trade: while it's on, the portal Run button, runbooks, scheduled runs, and API keys can't dispatch to that host — only a signed MCP call runs. Turn it on where "a real human asked for this" must be cryptographically true, not just policy-checked.

How it works

Three parties, one of them untrusted. Here is the path a signed dispatch takes:

  1. The MCP client signs and presents its cert. When you call an action, the client builds a canonical message — the action id, a hash of the arguments, a one-time nonce, and a timestamp — signs it with an Ed25519 leaf key that never leaves your machine, and attaches the CA-signed certificate that vouches for that key.
  2. The control plane relays. The portal stores and forwards the signature and cert verbatim inside the dispatch. It can't change the action or its arguments without invalidating the signature, and it holds neither the leaf key nor the CA key to mint a new one.
  3. The runner verifies, then runs. It verifies the cert against a CA it trusts, checks the cert is still valid and its scope matches this host, checks the leaf signature under the cert's key, checks the timestamp is inside its freshness window, and checks the nonce hasn't been seen before. Only then does it execute. Anything else comes back as a failed run with a reason.
The trust anchor is an offline certificate authority. A runner trusts a CA's public key, not every operator key — so onboarding someone is one signature and zero runner-config edits. The offline CA signs short-lived certificates that vouch for an operator's leaf key and, optionally, a scope (a group or labels). The runner-target binding is that scope, asserted by the CA and matched only against the runner's own local identity — so a scoped cert can't be redirected to a host the CA didn't scope it to. The CA private key stays offline and never touches a runner or the portal.

Turn it on

One command mints a CA, an operator key, and a certificate, and prints everything to wire up — the runner config block (the CA public key), the CA private key to store offline, and the two env vars for your MCP client. Everything is generated offline; the private keys are printed locally and sent nowhere.

1. Bootstrap on an operator machine. signing init mints the CA + leaf + cert in one shot. (Already have a CA? Use emisar signing new-ca once, then emisar signing new-cert per operator.)

$ emisar signing init --ca-id acme-2026 --scope group=prod --ttl 24h

2. Install the CA public key in /etc/emisar/config.yaml on every runner. The CA public key is safe to commit; ship it the way you ship the rest of your config.

# /etc/emisar/config.yaml
signing:
  enforce_signatures: true
  max_attestation_age: 24h
  trusted_cas:
    - ca_id: acme-2026
      public_key: <CA public key from signing init>

3. Give the leaf key + cert to your MCP client as environment variables — never on the portal, never in version control (see connect an LLM). Store the CA private key offline; you re-sign certs with it as they expire.

# on the machine running your MCP client — not the portal, not git
EMISAR_SIGNING_KEY=<leaf seed from signing init>
EMISAR_SIGNING_CERT=<cert JSON from signing init>

4. Apply it. Reload the runner — it rebuilds the verifier from config and re-advertises, so enforcement (and every later rotation) takes effect live, no restart and no dropped runs.

$ sudo systemctl reload emisar

Then check it landed: the Runner page shows Signed dispatch only and the Run button is disabled, an MCP tools/call runs, and an operator or runbook dispatch is refused with a clear message.

Distributing the CA across a fleet

The hard part of any signing scheme isn't the math — it's getting the right trust anchor onto every host and keeping it current. The CA model keeps that boring: it's one public key per fleet, not one per operator, so onboarding someone never touches a runner.

  • The CA public key is config, not a secret. Ship it the way you already ship the rest of config.yaml — cloud-init, Packer, Ansible, a golden image, your infra repo. This is exactly the SSH certificate-authority model: one public CA file, pinned on the host, distributed through a channel you already trust — and operator certs flow without ever editing a runner.
  • The control plane is deliberately not the distributor. emisar never holds or hands out your CA or signing keys — that's the whole point. Don't route them through the portal; route the CA public key through whatever already provisions the host. The one step you can't avoid is getting that CA public key onto the box through a trusted channel, which is the same channel that writes its config.
  • Scope each cert to match how you operate. Scope is a per-cert decision, not a per-runner one. A cert scoped group=prod can only run on prod runners; a label scope (env=prod,region=us) narrows it further; an empty scope is valid on any runner that trusts the CA (the simple, weaker mode). The runner matches scope only against its own local group/labels, so a scoped cert can never be redirected to a host it wasn't issued for.
  • The CA private key is the crown jewel. The CA seed is what mints authority — whoever holds it can sign a cert for any leaf key on every runner that trusts the CA. Keep it offline in a secrets store or password manager, and never let it reach a runner, the portal, or git. The leaf key in EMISAR_SIGNING_KEY is still secret, but its blast radius is bounded by its cert's scope and short TTL.

Rotating operators

Rotating an operator's key never touches a single runner: certs are short-lived and the CA already lives in every runner's config. Mint a fresh cert and swap the client's env vars.

  1. emisar signing new-cert --ca-id acme-2026 --ca-key <offline CA key> --scope group=prod --ttl 24h mints a new leaf + cert under the existing CA.
  2. Switch your MCP client to the new EMISAR_SIGNING_KEY and EMISAR_SIGNING_CERT. No runner reload — the CA they already trust vouches for the new cert.
  3. Keep the cert TTL comfortably above your max_attestation_age + approval turnaround, so a parked run re-dispatched with its original signature is still inside both the cert window and the freshness window.

Revoking

The short cert TTL is the everyday revocation lever — a leaked leaf key is dead once its cert expires, with no fleet operation at all. To kill a leaked key now, or to revoke a compromised CA, rotate the CA. Be clear-eyed about two things:

  • Short TTLs are the revocation story. There is no certificate revocation list yet — a leaked leaf key keeps working until its cert expires. Keep TTLs short (24h is a good default) so the blast radius of a leak is bounded by the clock, not by a fleet push. A long TTL (for solo / break-glass) trades that away — choose it deliberately.
  • The CA is the immediate lever. To revoke before a cert expires, rotate the CA: trusted_cas is a list, so add the new CA alongside the old, re-issue operator certs under it, then remove the old CA and reload every runner. A reload applies each step live without dropping a run — but it is a fleet operation, so "every runner reloaded" is the completion bar (the control plane can't do it for you, by design).

Best practices

  • Short cert TTLs, scoped to the target. A 24h cert scoped group=prod bounds both where a leak can run and how long. Reserve long TTLs for solo / break-glass and write down that you did.
  • Keep the CA public key in your infra repo. Let config management own it next to the host's config.yaml; never hand-copy it to one box at a time. A diff and a deploy is your CA-rotation record. The CA private key lives elsewhere — offline, in your secrets store.
  • Set the window against your real SLAs. If you combine signing with approvals, a parked run is re-dispatched with its original signature — so max_attestation_age must comfortably exceed your approval turnaround, or a slow approval is refused as stale. A wider window means a longer replay exposure: choose deliberately (see policies & approvals).
  • Scope, don't share blindly. An empty-scope cert is valid on any runner that trusts the CA — convenient, but it trades away redirect protection. Scope to a group or labels unless you genuinely need the fleet-wide mode, and write down that you did.

What it does and doesn't guarantee

  • Integrity, not availability. A compromised control plane can still withhold or refuse to relay your signed dispatch. Signing stops it from forging one; it can't force it to deliver yours.
  • Replay survives a restart. The nonce cache is persisted to disk and reloaded on startup and on every reload that rebuilds the verifier, so a captured, still-in-window signature can't replay across a restart — and if the runner can't durably record a nonce, it refuses the dispatch rather than risk it. The cache stays bounded by max_attestation_age.
  • Queued while offline. A dispatch that sits queued longer than max_attestation_age (runner offline, or a slow approval) is refused as stale and must be re-issued.

Troubleshooting a refusal

A refused dispatch comes back as a failed run whose error names the cause:

  • signature_required — the dispatch carried no signed certificate (it came from the portal, a runbook, or an API key, or the MCP client isn't configured to sign). Run it from a client with EMISAR_SIGNING_KEY and EMISAR_SIGNING_CERT set.
  • cert_untrusted — the cert's CA isn't in this runner's trusted_cas, or its CA signature doesn't verify. Add the CA public key and reload, or re-issue the cert from a CA the runner trusts.
  • cert_expired — the certificate is outside its validity window. Mint a fresh one with emisar signing new-cert (and check the signing host's clock against NTP — the cert window is absolute, no skew tolerance).
  • cert_scope — the cert is scoped to a group/labels this runner doesn't satisfy. The cert was issued for a different target; use a cert scoped to this runner (or an empty scope).
  • stale — the timestamp is outside the freshness window (clock skew, a long-queued run, or a slow approval). Re-issue the run, check host clocks (NTP), or widen max_attestation_age if approvals are the cause.
  • bad_signature — the signature doesn't verify against the action, args, nonce, and time as relayed. Wrong key, or the client and runner disagree on it — re-mint and re-distribute.
  • replayed — this nonce was already used. The client double-sent; re-issue with a fresh dispatch.