Docs navigation
Get started
AI agents
Connect
The fleet
Operate
Day to day
When it breaks
Govern access
Team & account
Access
Identity concepts
Provider guides
Account
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:
-
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. - 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.
- 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.
- 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.
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.
$ 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.)
$ 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.
# /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.
# 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.)
$ 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=prodruns 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_KEYis 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.
# 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.
-
emisar signing new-cert --ca-id acme-2026 --ca-key <offline CA key> --scope group=prod --ttl 24hmints a new leaf + cert under the existing CA. -
Switch your MCP bridge to the new
EMISAR_SIGNING_KEYandEMISAR_SIGNING_CERT. No runner reload is required because the trusted CA vouches for the new certificate. -
Keep the cert TTL above
max_attestation_ageplus 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_casis 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=prodbounds 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_ageabove 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_ageabove 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. |