stado ships three layers of supply-chain protection:
- Reproducible builds —
-trimpath -buildvcs=true -buildid=with a pinnedmod_timestampproduce bit-for-bit identical binaries from the same source tree. Independent rebuilders can confirm published releases weren't tampered with. - Cosign keyless signing — every release asset is signed by a
GitHub Actions OIDC-issued certificate via Fulcio, with the
signature + cert uploaded alongside the artefact. Verifiable with
cosign verify-blob. Implicit Rekor transparency-log entry. - Minisign Ed25519 signing —
checksums.txtadditionally signed with a long-lived project key, offline-held. The corresponding public key is compiled into release builds, sostado self-updatecan verify release manifests offline andstado verify --show-builtin-keyscan expose the embedded trust roots. Airgap-safe by construction.
This document covers the operational procedures for the minisign half. Cosign keyless is fully automated via GitHub Actions and has no human-in-the-loop.
Run once on an airgapped machine. The private key must never touch an online host again.
# Requires the reference minisign tool (https://jedisct1.github.io/minisign/)
# — available via apt/brew/cargo/zig install. Any Ed25519 minisign key
# works with stado's verifier; the tool is just a key-management
# convenience.
minisign -G -p stado.pub -s stado.keyStore stado.key on encrypted offline media (hardware token, encrypted
USB, paper backup). The password prompted during -G is the only
protection on the key file itself — pick a real passphrase.
stado.pub is the file distributors read. Its trailing base64 line is
the 32-byte Ed25519 public key encoded as minisign expects.
stado reads the pinned pubkey from audit.EmbeddedMinisignPubkey.
It is empty by default, so local/dev builds do not carry a release
trust root and stado self-update refuses to run. Release builds seed
the key via -ldflags:
# Extract the raw base64 from stado.pub (skip the comment line):
PUBKEY=$(tail -n 1 stado.pub)
# Seed both the pubkey and the key id. Key id is the 64-bit signer id
# that minisign embeds in each signature — lets stado reject signatures
# from the wrong signer even if someone substitutes a different key.
KEYID=$(head -c 10 stado.pub | tail -c 8 | xxd -p -c 8) # simplified
go build \
-ldflags "\
-X github.com/foobarto/stado/internal/audit.EmbeddedMinisignPubkey=$PUBKEY \
-X github.com/foobarto/stado/internal/audit.EmbeddedMinisignKeyID=$KEYID \
" \
-o stado ./cmd/stadoFor goreleaser-driven releases, put these -X fragments in
.goreleaser.yaml's builds[].ldflags (the values come from
repository secrets / CI variables, never checked into git).
On every tagged release, sign checksums.txt with the offline key:
# 1. Let goreleaser / CI produce checksums.txt in the usual way.
# 2. Transfer checksums.txt to the airgapped machine (sneakernet).
# 3. Sign it:
minisign -Sm checksums.txt -s stado.key -t "stado <version> signed $(date -u +%Y-%m-%dT%H:%M:%SZ)"
# → produces checksums.txt.minisig alongside.
# 4. Transfer the .minisig back and upload as a release asset.stado self-update looks for checksums.txt.minisig in the release's
assets and requires it alongside an embedded minisign pubkey in the
running binary. Missing either side of that pair is a hard failure.
Normally invisible — stado self-update runs the check automatically.
Manual verification:
stado verify --show-builtin-keys # prints the embedded fingerprint
minisign -Vm checksums.txt -p stado.pub # verifies with the standalone toolstado verify does not verify individual assets. Manual asset checks
stay on the published manifest path: verify checksums.txt first, then
confirm the chosen archive/package digest against that manifest.
If the private key is compromised:
- Immediately publish a CRL-style advisory in the releases feed ("key X revoked as of YYYY-MM-DD — do not trust signatures after this date").
- Generate a new keypair via the ceremony above.
- Cut a new release built with the new pubkey embedded. Announce the new fingerprint in release notes.
- End users upgrading past that version get the new embedded pubkey and refuse signatures from the old one.
stado doesn't ship a runtime minisign-key-trust-list — the embedded key is singular and immutable per binary. Rotation is a binary-rebuild event, not a config change. This is a deliberate tradeoff: simpler verification path, harder key rotation. For projects that need on-the-fly rotation, cosign's Fulcio path is the alternative (that's also signed unconditionally).
Third-party plugins follow the same Ed25519 pattern at a different scope. See EP-0006 for the manifest + trust-store + CRL + Rekor layers. Summary:
- Plugin authors generate their own keypair (
stado plugin gen-key). - Users pin author pubkeys on first install (
stado plugin trust). - Install-time verification checks signature + wasm sha256 + rollback
- optional CRL + optional Rekor inclusion proof.
- Revocation happens via the CRL (operated by the project) and Rekor (public transparency log).
Plugins are a separate trust domain from the stado binary itself; compromising a plugin signing key doesn't affect release-signing integrity.
Step-by-step for maintainers who want to publish an offline-signed
plugin. Assumes you already have working plugin.wasm +
plugin.manifest.json templates — see the hello plugin in
foobarto/stado-plugins for a
minimal starting point and plugins/bundled/auto-compact/ for the
full session-capable shape.
# On an airgapped or otherwise-trusted machine:
stado plugin gen-key plugin-signer.seed
# → prints:
# pubkey (hex): <64 hex chars>
# fingerprint: <short fpr>
# seed written: plugin-signer.seed (chmod 0600 — keep offline)- Treat the
.seedfile like any other private key: offline storage, no backups to cloud drives,chmod 0600. - The fingerprint is short enough to print on a business card; users will verify the pubkey-hex matches the fingerprint on first install.
- One key per maintainer identity, not per plugin — the same key can sign every plugin you ship.
Distribute via a channel outside your plugin-distribution channel so a compromise of one doesn't take down the other. Good options:
- Your project's homepage (HTTPS, not just GitHub Pages on a custom domain)
- A DNS TXT record under your domain
- A transparency-log service (sigstore, etc.)
- Print on conference swag
The users' pinning step (stado plugin trust <pubkey> "<comment>") is
a one-time trust decision; make it easy for them to verify.
In plugin.manifest.json fill in every field before signing:
{
"name": "my-plugin",
"version": "0.3.1",
"author": "alice@example.com",
"capabilities": ["session:read", "llm:invoke:50000"],
"tools": [ /* ... */ ],
"min_stado_version": "0.9.0",
"timestamp_utc": "2026-04-20T10:15:00Z",
"nonce": "<random hex — openssl rand -hex 16>"
}wasm_sha256 + author_pubkey_fpr are filled automatically by
stado plugin sign — leave them empty in the template. Bump version
for every release; stado's rollback guard rejects installs that go
backwards. nonce prevents replay of old signed manifests under the
same version.
stado plugin sign plugin.manifest.json --key plugin-signer.seed --wasm plugin.wasm
# Produces:
# plugin.manifest.json (with wasm_sha256 + author_pubkey_fpr filled in)
# plugin.manifest.sig (base64 Ed25519 signature)Both files must ship side-by-side — the install verifier reads the
.sig from the same directory as .json.
# One-time: point stado at the public Rekor instance.
# (Or run your own — Rekor is Apache-2-licensed.)
echo '[plugins]
rekor_url = "https://rekor.sigstore.dev"' >> ~/.config/stado/config.toml
# Rekor upload happens automatically during `stado plugin verify`
# when rekor_url is set AND the manifest has no prior entry. Users
# who pass through `stado plugin install` see the entry UUID printed
# to stderr; absence is advisory (the trust store is still the
# authoritative gate).Uploading is a unilateral action — once logged, the entry is
append-only. Do it before distributing so users' verify calls find
an entry instead of advising "no log entry".
Ship everything in a <plugin>/dist/ shape (as in
foobarto/stado-plugins):
my-plugin/
├── plugin.wasm
├── plugin.manifest.json # signed
├── plugin.manifest.sig # signature
└── README.md # usage + capability explanation
A tarball, a git tag, a GitHub release — any medium works. The verifier doesn't care about transport, only that the four files land together.
Contact the stado project to add your key to the CRL — the CRL is
operated by the project and signed by a separate key pinned in
[plugins].crl_issuer_pubkey. Do not rotate silently: users who
installed under the old key need to see a revocation event, not just
a new plugin version with a new signer.
After revocation:
- Generate a fresh key (back to step 1).
- Publish the new pubkey + rotation-event notice via the same channel as step 2.
- Re-sign + re-distribute every still-supported plugin version.
- Users re-run
stado plugin trust <new-pubkey>+ re-install.
stado ships a hardcoded deny-list of Ed25519 fingerprints whose
corresponding private seeds were committed to this repo's git history
before the .seed gitignore landed (the seeds were untracked in v0.51.1,
but history retains them forever). Any clone or mirror has the seeds, so
anyone can forge a manifest signature matching these fingerprints.
Every trust-verification entry point consults IsRevoked()
(internal/plugins/revoked.go) and refuses to verify a manifest under a
revoked fingerprint even if the operator has trusted it via
stado plugin trust. The covered paths are (*TrustStore).VerifyManifest
(standard verify), (*TrustStore).TrustVerified (TOFU/pin), and
internal/runtime.verifyPluginOverride (runtime override / installed-plugin
path). All return the same plugins.RevokedError so behavior is uniform.
This is a hard deny — there is no escape hatch. If you add a new
verification entry point, it MUST consult IsRevoked too, or this
guarantee is silently lost.
The currently-revoked fingerprints (each maps to a leaked demo-seed file
preserved in git history; the list may grow over time as more keys are
revoked — see internal/plugins/revoked.go for the live set):
| Fingerprint | Leaked seed |
|---|---|
6c48b56f20c9c344 |
plugins/examples/browser/browser-demo.seed |
65eae6fb74279268 |
plugins/examples/encode-zig/encode-zig-demo.seed |
5bc3855d455e44c4 |
plugins/examples/hello/hello-demo.seed |
08aa1288d1af3d9a |
plugins/examples/hello-go/hello-go-demo.seed |
28f0fa4d25503211 |
plugins/examples/http-session/http-session-demo.seed |
6c9bf7180872f90c |
plugins/examples/image-info/image-info-demo.seed |
effd536ec1e7eb14 |
plugins/examples/ls/ls-demo.seed |
f701ee55897ada64 |
plugins/examples/mcp-client/mcp-client-demo.seed |
45016a163a795f9f |
plugins/examples/persistent-shell/persistent-shell-demo.seed |
ff8436c9d0ab8450 |
plugins/examples/state-dir-info/state-dir-info-demo.seed |
33ecd5793539691c |
plugins/examples/webfetch-cached/webfetch-cached-demo.seed |
a3128a188d7af698 |
plugins/examples/web-search/web-search-demo.seed |
The corresponding plugins moved to
foobarto/stado-plugins under
a new anchor key (fingerprint 57a3e58ce484c5e5); the demo seeds above
are not used by anything stado ships today. The deny-list is a
belt-and-suspenders defense against an older install that pinned one of
these keys, or an attacker forging a plugin under one of them.
To remediate as a plugin maintainer who lost a seed: same flow as the project-managed CRL above — generate a fresh key, publish a rotation notice, re-sign, re-distribute. The deny-list refusal cannot be overridden from the operator side; the only path forward is a new key.
Annual rotation is good practice even without an incident. Same flow as revocation minus the CRL step: publish a rotation notice, sign future releases with the new key, leave the old key's already-published signatures valid (they're still verifiable — the CRL's absence is what matters).
Before pushing to users, round-trip a fresh install against your own trust store:
stado plugin gen-key test.seed # step 1
stado plugin sign plugin.manifest.json --key test.seed --wasm plugin.wasm # step 4
stado plugin trust $(grep 'pubkey' test-output | awk '{print $3}') "test" # step 2
stado plugin verify . # should pass
stado plugin install . # should install
stado plugin run my-plugin-0.3.1 hello '{}' # smoke-testA green round-trip here means end users will also succeed, assuming they've pinned your real pubkey.
stado spawns host subprocesses from many places — the TUI shell, plugin
runners, LSP servers, the daemon, post-turn hooks, scheduled tasks, MCP
wrappers, ACP providers. Those subprocesses inherit the host's
filesystem and network access unless stado is itself wrapped under a
process-containment sandbox. Supported wrappers: bwrap and
firejail on Linux, sandbox-exec on macOS
(internal/sandbox/wrap.go → pickRunner).
The wrap is opt-in via [sandbox] mode = "wrap" in config.toml.
Default is off. Only stado run re-execs itself under the wrapper
today (internal/sandbox/wrap.go → MaybeRewrap); the bare TUI,
stado session resume, and stado headless do NOT re-exec yet — they
run unwrapped even with mode = "wrap" configured.
To make this observable, all four entry points call
sandbox.WarnIfHostUnsandboxed (internal/sandbox/announce.go) once
per process.
Emits a warning to stderr when:
mode = "off"/ unset (the default) — host is unsandboxed; warning points at the[sandbox]config knob and the supported wrappers.mode = "wrap"but not the wrapped child — flags the gap that onlystado runre-execs today, so TUI / headless / session-resume still run unwrapped under this config.mode = "external"but no wrapper evidence is detected (i.e.STADO_REWRAPPEDis unset ANDlooksWrapped()returns false) — the operator claims to handle wrapping externally but the entry point doesn't appear to be running under one. Onlystado runvalidates this today viaMaybeRewrap; the other entry points need the warning.
Suppressed silently when:
STADO_REWRAPPED=1— we ARE the wrapped child; the sandbox is active around us, warning would be a lie.STADO_SUPPRESS_SANDBOX_WARN=1— operator/CI opt-out.mode = "external"AND the process IS wrapped — the operator's external setup is honored.
The warning is a sync.Once across the process — three calls to the
helper produce exactly one block, not three. Setting
STADO_SUPPRESS_SANDBOX_WARN=1 is the intended way to silence it for
operators who knowingly accept the posture (e.g. running on a host
that's already containerised, or in CI).
Open a GitHub security advisory on
github.com/foobarto/stado/security/advisories. Please don't open a
public issue for anything that looks exploitable.
We aim to acknowledge reports within 72 hours.