stanchion GitHub

Signatures

Binding an identity to the exact bytes that will run, and using that provenance to tier capabilities.

A signature binds an identity to the exact bytes that will run, checked against a trust root the host controls before any Lua executes.

What is signed

Every file in the plugin directory, not just plugin.toml. Signing the manifest alone would be worse than useless: swap init.lua, leave the manifest, and an audit reports capabilities the code does not match. DirectoryDigest hashes each file and folds them in sorted order:

for each file, sorted by path (excluding the signature artifact):
    relative path ‖ 0x00 ‖ sha256(contents) ‖ 0x00

Those bytes — DirectoryDigest::preimage — are the signed artifact, so plain cosign can produce a bundle without any bespoke tool. The per-file hashes are kept, and the loader re-checks each file as it reads it, so what runs is what was verified even if the directory changed in between.

Sigstore

The sigstore-verify feature provides keyless verification: the author signs with an OIDC identity, Fulcio issues a short-lived certificate, and the host checks the bundle against sigstore’s trust root plus an identity policy of its choosing. Authors hold no long-term key, and the host trusts an identity rather than a key fingerprint.

use sigstore::bundle::verify::policy::GitHubWorkflowRepository;

let registry = Registry::isolated(Lua::new(), Sandbox::restricted())
    .with_verifier(SigstoreVerifier::production(
        "repo:acme/plugins",
        GitHubWorkflowRepository::new("acme/plugins"),
    )?)
    .require_signatures(true);

Verification is offline by default — loading plugins should not depend on reaching a log server; the bundle’s inclusion proof is checked either way.

Two costs worth knowing. sigstore-verify pulls roughly 246 transitive dependencies, including TUF, HTTP and TLS stacks, which is why it is optional. And sigstore-rs describes itself as experimental with an API that can change — which is why verification sits behind the PluginVerifier trait rather than being wired in directly, so you can supply your own PKI instead.

Unsigned plugins

require_signatures(false), the default, loads an unsigned plugin as Signer::Unsigned. That makes signing a gradient rather than a cliff — existing plugins keep working, and policy decides what they may reach.

Provenance tiers capabilities

This is where signing stops being a checkbox. CapabilityRequest carries the verified signer, so the policy can grant by who signed:

Rules::deny_all().allow_with("network", |request| match request.signer().identity() {
    Some(id) if id.starts_with("repo:acme/") => Decision::Grant,
    _ => Decision::deny("network requires a first-party signature"),
})

Pair that with optional = true and an unsigned plugin degrades gracefully instead of failing: it loads, just without the capability.

audit reports each plugin’s signer alongside its requests, still without running any plugin code.

Revocation

A signature proves who produced a plugin. It cannot say the plugin was later withdrawn, so revocation is the separate, mutable half — checked after verification succeeds, because a revoked signature is still a valid signature.

let registry = Registry::isolated(Lua::new(), Sandbox::restricted())
    .with_verifier(verifier)
    .with_revocations(Revocations::load(Path::new("revoked.toml"))?);
[[revoked]]
digest = "9f86d081884c7d65…"
reason = "CVE-2026-1234"

[[revoked]]
identity = "repo:acme/compromised"
reason = "key compromise"

A digest entry refuses one specific build; an identity entry refuses everything that signer produced. Digests compare case-insensitively so a hand-written list still matches, and an entry naming neither is a configuration error rather than one that silently matches nothing.

Revocation works without any verifier: a digest denylist refuses a known-bad build with no signing infrastructure at all.

Applying a list to plugins already running

A revocation list changes while your process is running, which is exactly when it matters. apply_revocations replaces the list and unloads every loaded plugin it now names, returning one LoadFailure per plugin dropped:

for refused in registry.apply_revocations(Revocations::load(&path)?) {
    tracing::warn!("unloaded {}: {}", refused.name, refused.reason);
}

The new list also governs later loads, so re-running discovery does not resurrect what it refused.

Checks run against the digest each plugin was loaded from, recorded at load and readable as plugin.digest() — not against the directory as it stands now, which may have changed underneath a running plugin. An identity-only list needs no digest at all; it is answered from the recorded signer without touching the filesystem. For a plugin loaded without a digest — nothing at load needed one — the directory is hashed at this point, and if that read fails the plugin is unloaded with the I/O error as its reason: a trust decision that cannot be made is not one to resolve in the plugin’s favour.

Unloading drops the Plugin, which releases its Lua state under per-plugin isolation and leaves its exports proxy resolving to nothing. A caller still holding an instance keeps talking to a plugin the host has just refused, so take the returned names as the signal to release those handles. Registry::remove does the same thing for one plugin by name, and hands it back so the caller chooses when it is dropped.

What signatures do not buy


← Documentation index