Skip to main content
Docs navigation

Signed dispatch

By default a runner trusts the control plane to tell it what to run. Signed dispatch removes that trust. A customer-authorized bridge signs each request with a key the control plane never holds. The runner checks that signature before it runs anything, so the control plane can only relay — it cannot forge a signed action, alter one, or widen its targets.

Signed dispatch is opt-in per runner, and it is a trade: while it is on, only a signed MCP call can dispatch to that host — the portal Run button, runbooks, and API keys are refused. Turn it on for hosts where you need proof that a dispatch came from an authorized bridge.

How it works#

A signed dispatch takes this path:

  1. The MCP bridge signs. For run_action, the bridge signs the origin, action, exact pack, exact arguments, exact runner set, reason, operation, nonce, and time — plus digests of the evidence and expected result an approver sees. After the bridge signs, nothing can be added to the claim.
  2. The signing key stays on your machine. The bridge signs with your private key and attaches its CA-signed certificate. It signs the argument bytes exactly as sent — it does not decode and rebuild them, so large integers, object order, and escapes stay exact.
  3. The control plane relays. It forwards the signed envelope with the exact argument bytes. It cannot change it and cannot sign a new claim, since it does not hold a private key.
  4. The runner verifies, then runs. It checks the certificate, the scope, the signed fields, the time, and the nonce, and it needs exactly one matching runner reference. The pack hash on disk stays a separate trust gate. Nothing runs until every check passes.
The trust anchor is an offline certificate authority.
A runner trusts one CA public key, not every operator key. Your offline CA signs short-lived certificates that vouch for an operator's key and, optionally, a scope — a group or labels the runner checks against its own config. Every signature also names its exact targets as name~sha256-prefix refs derived from each runner's identity (its hostname, or the runner.id you declared): a replacement host that keeps the identity keeps the ref, and a new identity makes a new ref.

Turn it on#

One command mints a CA, an operator key, and a certificate, and prints everything you need to wire up. Everything is generated offline; the private keys print locally and are not sent anywhere.

1. Install the CLI on an operator machine. The signing commands are part of the emisar binary. Install it binary-only: --no-service skips the service and needs no enrollment key, so this machine does not become a runner.

shell
$ curl -fsSL https://emisar.dev/install.sh | sudo bash -s -- --no-service

2. Mint the CA, key, and certificate. 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.)

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

3. Install the CA certificate in /etc/emisar/config.yaml on every runner. It is safe to commit — ship it the way you ship the rest of your config.

config
# /etc/emisar/config.yaml
signing:
  enforce_signatures: true
  max_attestation_age: 24h
  trusted_cas:
    - name: acme-2026
      pem: |
        -----BEGIN CERTIFICATE-----
        <the CA certificate signing init printed>
        -----END CERTIFICATE-----

4. Give the leaf key + cert to your MCP bridge as environment variables. Never on the portal, never in version control (see connect a CLI agent). Store the CA private key offline. You re-sign certs with it as they expire.

config
# on the machine running your MCP bridge — not the portal, not git
EMISAR_SIGNING_KEY=<private key from signing init>
EMISAR_SIGNING_CERT=<certificate chain from signing init>

5. Turn it on with a restart. The first time you enable enforcement, restart the runner. It opens the durable replay journal at startup and then advertises that it verifies signatures. A reload cannot make that first switch. A runner that booted un-enforcing holds an in-memory replay store, so reload leaves enforcement off and unsigned calls keep flowing. (Later CA rotations and trusted-key swaps on an already-enforcing runner do apply live with a reload, no dropped runs.)

shell
$ sudo systemctl restart emisar

Then confirm it landed before you rely on it: Runners shows the Signed dispatch only badge and the signed-only chip on the row, the Run button is disabled, a signed MCP call succeeds, and operator and runbook dispatches are refused. If there is no badge, the runner still accepts unsigned calls — recheck the config and the restart.

Distributing the CA across a fleet#

The CA model keeps distribution simple: one public key per fleet, not one per operator, so onboarding a person does not touch any runner.

  • The CA public key is config, not a secret. Ship it the way you already ship config.yaml — cloud-init, Packer, Ansible, a golden image, or your infrastructure repository. Operator certificates then change without editing a runner.
  • The control plane is deliberately not the distributor. emisar never holds or hands out your CA or signing keys — route the CA public key through whatever already provisions the host, never through the portal.
  • Scope each cert to match how you operate. Scope is a per-cert decision. A cert scoped group=prod runs only on prod runners, a label scope (env=prod,region=us) narrows it further, and an empty scope is valid on any runner that trusts the CA. The runner checks scope against its own local group and labels.
  • The CA private key mints authority. Whoever holds it can sign a certificate that every runner trusting the CA accepts. Keep it offline in a secrets store, and never let it reach a runner, the portal, or git. The signing key in EMISAR_SIGNING_KEY is still a secret, but its reach is bounded by its cert's scope and short TTL.

If you already run your own PKI, it can issue these certificates: they are X.509, so Vault PKI, AD CS, step-ca, or an HSM-backed CA works, and the anchor is an ordinary CA certificate. Issue against the profile below.

Issue from your own PKI#

A runner accepts a certificate when it chains to one of its trusted CAs and matches this profile. Anything else is refused as cert_profile.

What the certificate needs Why
Exactly one URI in the subject alternative name, starting emisar://dispatch/v1 This is what marks a certificate as issued for emisar. A TLS server certificate from the same CA does not carry it, so sharing a corporate root does not turn your server certificates into signing authority.
An Ed25519 or ECDSA P-256 key P-256 is there because several KMS and HSM products still do not offer Ed25519. RSA is not accepted. A Windows CA issues RSA unless you tell it otherwise, so this certificate needs a template set to ECDSA P-256 — the CA above it can stay on RSA.
Not a CA certificate A certificate that can issue others should not also sign dispatches.
Key usage, if set, allows digital signature An issuer that states the usage has to state one that permits signing.
A chain of the leaf plus at most one intermediate Deeper chains are refused even when they verify. Trust the deeper issuer directly instead.

No extended key usage is required: we do not own an OID for it, and reusing serverAuth would accept the TLS certificates the scope URI exists to keep out. If you want a second belt, name-constrain the issuing CA to emisar:// URIs — then even a shared root cannot issue anything else that a runner would accept.

The scope rides in that URI's query, and there is one spelling per scope: parameters sorted, and anything outside letters, digits, and -._~ percent-encoded with uppercase hex. A certificate spelled another way is refused rather than corrected, so your issuer and the runner can never disagree about what was authorized.

scope URIs
# any runner that trusts the CA
emisar://dispatch/v1

# one group
emisar://dispatch/v1?group=prod

# a group and two labels, sorted
emisar://dispatch/v1?group=prod&label.env=prod&label.region=us

In Vault PKI that is a role with allowed_uri_sans set to the scopes you issue; in step-ca it is a certificate template with the same URI. Give the bridge the key and certificate through EMISAR_SIGNING_KEY and EMISAR_SIGNING_CERT — a base64 PKCS#8 private key and a base64 PEM chain, each on one line.

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.

  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 bridge to the new EMISAR_SIGNING_KEY and EMISAR_SIGNING_CERT. No runner reload is required because the trusted CA vouches for the new certificate.
  3. Keep the cert TTL above max_attestation_age plus your approval turnaround, so a run that waited on approval is still inside both windows when it dispatches.

Revoking#

Two facts decide how you revoke:

  • Short TTLs are the everyday revocation. There is no certificate revocation list. A leaked signing key keeps working until its cert expires — keep TTLs short (24h is a good default) so a leak expires on its own within hours.
  • 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 beside the old, re-issue operator certs under it, then remove the old CA and reload every runner. Each reload applies live without dropping a run. Done means every runner reloaded — the control plane cannot do the reload for you.

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. Keep the CA private key offline in your secrets store.
  • Set max_attestation_age above your approval turnaround. A run that waited on approval dispatches with its original signature, so a window shorter than your approvals refuses slow ones as stale. A wider window also means a longer replay exposure — choose deliberately. See policies and approvals.
  • Scope certificates unless you need fleet-wide reach. An empty-scope cert can target any runner that trusts the CA. The per-call targets are still signed, but a narrow group or label scope limits the damage when discovery metadata is misleading.

What it does and does not guarantee#

  • Integrity, not availability. A compromised control plane can still refuse to relay your dispatch — signing prevents forgery, not withholding.
  • Names are discovery metadata. The signature binds suffixes derived from durable runner identities — it does not make the display name truthful, and a compromised portal can lie about that mapping before signing. Narrow cert scopes limit what the lie can reach; verifying suffixes out of band removes it.
  • Replay protection survives a restart. Every accepted nonce is written and synced before dispatch, in a journal that outlives the process. When the journal is full — 100,000 entries or 16 MiB — it refuses new work rather than dropping a fresh nonce, and raising max_attestation_age above what the journal was opened with is refused.
  • 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 shows the cause:

The error shows What it means What to do
signature_required The dispatch carried no signed certificate — it came from the portal, a runbook, an API key, or a bridge that does not sign. Use a bridge with EMISAR_SIGNING_KEY and EMISAR_SIGNING_CERT set.
target_mismatch This runner is absent from the signed target set, or appears in it twice. Refresh discovery and send a fresh call with the exact returned runner refs.
cert_profile The certificate does not match the profile below — most often it carries no emisar:// scope, so it was not issued for signing dispatches. Re-issue it against the profile below.
cert_untrusted The certificate does not chain to any CA in this runner's trusted_cas. Add the CA certificate and reload, or re-issue from a CA the runner trusts.
cert_expired The certificate is outside its validity window — there is no skew tolerance. Mint a fresh certificate with emisar signing new-cert and check the signing host's clock against NTP.
cert_scope The cert is scoped to a group or labels this runner does not have. Use a cert scoped to this runner, or an empty scope.
stale The timestamp is outside the freshness window — clock skew, a long queue, or a slow approval. Re-issue the run and check host clocks. Raising max_attestation_age above the window the replay journal was created with needs a CA rotation, a stop, a replay reset, and a restart.
bad_signature The signature does not verify — the key is wrong, or the client and runner disagree about the signed value. Re-mint and re-distribute the key and cert.
replayed This nonce was already used — the client double-sent. Send a fresh dispatch.
intent_mismatch A delivered field differs from the signed one, or the call carried execution-limit overrides, which are never part of the claim. Re-send without the overrides.
nonce_store_unavailable The runner could not record the nonce, so it refused rather than risk a replay. The failure latches until restart. Check the data directory's disk space and permissions, then restart the runner.

Last reviewed August 23, 2026