stanchion GitHub

Distribution

Getting a plugin from whoever wrote it onto the machine that runs it, over infrastructure none of which has to be trusted.

Signatures answer one question: who produced these bytes? Shipping plugins raises three more, and a signature answers none of them.

QuestionAnswered by
Which build is the legitimate one?the lockfile
Can the place it came from lie to me?it can — the pin is checked after the fetch
Is this upgrade asking for more than the last one?the upgrade review

These are not hypotheticals. Substituting a different build for the name you asked for, downgrading you to an older signed-but-vulnerable release, and publishing a new version with one more line in [capabilities] are all attacks that work with a valid signature, and the last two work with the author’s real key and no compromise of anything.

  index (untrusted)          package source (untrusted)
        │ name + requirement         │ tar.gz
        ▼                            ▼
  ┌────────────────────────────────────────────┐
  │ stage: unpack ‖ digest ‖ manifest ‖ review │   nothing installed yet
  └────────────────────────────────────────────┘
        │ a person, or a policy, accepts
        ▼
  lockfile pin  ──►  the registry refuses anything else, at every load

Every box the bytes pass through is untrusted except the lockfile, which the host writes and commits to its own repository.

The lockfile is the trust anchor

stanchion.lock states, per plugin, the exact bytes that may load:

version = 1

[plugins.formatter]
version = "1.4.2"
digest = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
signer = "repo:acme/plugins"
source = "https://plugins.acme.test/v1/blobs/sha256:9f86d081884c7d65…"
let registry = Registry::isolated(Lua::new(), Sandbox::restricted())
    .with_lockfile(Lockfile::load("stanchion.lock")?);

digest is the DirectoryDigest root — the same number a signature covers, so pinning adds no second hashing scheme. Everything else narrows further, and source carries no authority whatsoever: it says where bytes came from, never whether to accept them.

Three properties follow, and they are the reason to do it this way:

A plugin in the root with no entry fails as Unlocked rather than loading. Once a host keeps a lockfile, an unpinned directory appearing beside the pinned ones is exactly the event worth refusing.

Pinning works with no signing infrastructure at all — a digest is a digest. Signatures add attributable authorship on top; they are not a prerequisite.

Packages are plain archives

A package is a tar.gz of the plugin directory. There is no manifest, no config blob and no media-type negotiation, because there is nothing for them to do: the index already maps a name and a version to a digest, so a package needs only to be fetchable and to unpack to the content that digest describes.

That makes a package URL an ordinary immutable file. Anything that serves bytes can host one — a bucket, a CDN, nginx, or the index server itself:

GET https://plugins.acme.test/v1/blobs/sha256:<hex>

The archive carries the plugin directory including its plugin.sigstore.json — the directory digest excludes the signature artifact from itself precisely so a package can carry its own signature.

The archive’s own bytes are not the pin. A pin is a digest over the unpacked directory: paths and file contents, nothing else. Repacking with different mtimes, a different entry order or a different gzip level produces different bytes and the same pin, which is what makes mirroring safe — a mirror can recompress and still cannot alter a file without breaking the digest.

The flip side is that tar metadata is covered by nothing. Modes, owners, mtimes and entry types are attacker-controlled even in a correctly signed package, so unpacking ignores all of them.

Unpacking refuses more than it accepts

An archive from a registry is hostile input before anything about it is verified: the digest cannot be checked until the files are on disk. So extraction refuses

Symlinks are refused rather than sanitised, because a symlink cannot be represented in a DirectoryDigest at all: the digest hashes what std::fs::read returns, and for a symlink that is whatever it points at on the machine doing the hashing. A plugin containing one would verify on the signer’s machine and mean something else on yours.

Running an index

An index is two static JSON documents. Anything that can serve a file can host one — S3, GitHub Pages, nginx, a git repository people clone. There is no database and no API, because an index holds no authority worth protecting.

<base>/v1/index.json              the catalog: which plugins exist
<base>/v1/plugins/<name>.json     the releases of one plugin
{
  "schema": 1,
  "name": "formatter",
  "updated": "2026-09-20T00:00:00Z",
  "expires": "2026-09-27T00:00:00Z",
  "releases": [
    {
      "version": "1.4.2",
      "digest": "sha256:9f86d081884c7d65…",
      "source": "https://plugins.acme.test/v1/blobs/sha256:9f86d081884c7d65…",
      "signer": "repo:acme/plugins",
      "issuer": "https://token.actions.githubusercontent.com",
      "capabilities": ["network"],
      "yanked": false
    }
  ]
}

The catalog is the same shape with a plugins array of names, and is optional: an index that cannot enumerate is still usable for everything else.

FieldWeight
digestthe only field with any. It becomes the pin, and is checked against the bytes that arrive.
versionchecked against the manifest inside the package; a disagreement fails the install.
sourcea hint. Fetching the same digest from anywhere else is equally acceptable.
signer, issuercopied into the pin, then enforced by the verifier at load.
capabilitiesadvisory, for browsing. The manifest is what the registry reads.
yankedstops new pins. It does not stop an existing pin from loading.

expires is not decoration. An attacker who can stop you reaching the real index cannot forge a release, but they can serve you last month’s snapshot forever — hiding a yank, or hiding that a fixed version exists. An expiry bounds how long that works, which is why Freshness::Required (the default over HTTP) refuses a document that has no expiry at all. The cost is that an index must be re-published periodically; an index nobody maintains stops being believed, which is the correct behaviour for a stale mirror.

Releases are ordered by semver, not by document order, so an index cannot steer a client by reordering its own file.

Implementing an index

Serve those two paths, or implement the trait against whatever you already run:

pub trait PluginIndex {
    fn releases(&self, name: &str) -> Result<PluginReleases, IndexError>;
    fn catalog(&self) -> Result<Catalog, IndexError> { /* optional */ }
    fn resolve(&self, name: &str, req: &VersionReq) -> Result<Release, IndexError> { /* provided */ }
}

DirectoryIndex reads the layout from a directory and HttpIndex reads it over HTTPS. The trait is deliberately thin because an index is not trusted: it maps a name to candidate digests, and every claim it makes is checked against the package that actually arrives.

The crate is dependency-light by default for this reason — an index publisher can depend on stanchion-dist for the document types alone, without an HTTP or TLS stack:

FeatureAdds
(none)index documents, PluginIndex, DirectoryIndex
packagepacking and unpacking packages, Installer
httpHttpIndex and HttpSource: an index and packages over HTTPS
clienteverything a host needs to install from a remote index

TLS authenticates the server, not what it said. An index reached over a flawless TLS connection to a compromised bucket is a compromised index; HTTPS here keeps a passive network from seeing which plugins you run, and is not what stands between you and a bad one.

Publishing

let mut archive = Vec::new();
let digest = package::pack(Path::new("plugins/formatter"), &mut archive)?;

// Publishing is: put the archive somewhere fetchable, then add a release to the
// index naming that digest. Both halves are ordinary file writes.
upload(&format!("blobs/sha256:{}", digest.hex()), &archive)?;

Then add a release entry naming that digest and re-publish the index document. Sign the directory as beforecosign sign-blob over the canonical preimage — and include the bundle in the directory so the package carries it.

Packing normalises entry metadata (fixed mtime, mode, ownership) and writes entries in the digest’s own sorted order, so packing one directory twice produces identical bytes. Nothing depends on that, since the pin is over the directory, but two packages that disagree are much easier to reason about when repacking is deterministic.

Installing is staged, and the gap in the middle is the point

let staged = installer.stage_upgrade("formatter", &"^1.0".parse()?, &lockfile)?;

if let Some(review) = staged.review() {
    if review.widens() {
        for concern in review.concerns() { eprintln!("  {concern}"); }
        staged.discard()?;
        return Ok(());
    }
}

lockfile.pin("formatter", staged.commit()?);
lockfile.save("stanchion.lock")?;

Fetching and unpacking happen in a staging directory beside the plugin root. commit is a rename; discard is a delete. A plugin that fails any check never exists in the root — not briefly, not half-written — so the registry is never asked to reason about a directory that is mid-install. A previous version is moved aside and deleted only once the new one is in place, so an interrupted commit leaves either the old plugin or the new one.

The upgrade review

audit reads manifests without running any plugin code. Pointed at two versions of one plugin, the same idea answers the question that actually matters when bytes arrive from elsewhere:

formatter 1.4.2 -> 1.5.0
  + capability `network`
  ! capability `fs`: { paths = ["./data"] } -> { paths = ["./data", "/etc"] }
  + rock `luasocket` >= 3.0 (unsigned, outside the sandbox)

Stealing a signing key is hard. Publishing version 1.5.0 of a plugin people already trust, with one more capability, is not — and a signature does not help, because the malicious version is correctly signed by the author whose account was taken. widens() is the predicate to gate on: a review that does not widen can be applied unattended, and one that does needs a person. A first install always counts as widening, because there is nothing to compare against.

Narrowing is inferred conservatively. Capability parameters are host-defined, so this cannot know that hosts = ["*.acme.com"] is wider than ["api.acme.com"]; anything it cannot prove is a narrowing counts as a widening. It errs towards noisy.

Yanking is not revocation

Stops new installsStops a pinned build from loading
yanked in the indexyesno
Revocationsyesyes, at next load

A yank is advice from a publisher; a revocation is a decision by the host. If you need a build to stop running on machines that already have it, you need the revocation list, and it is consulted at load rather than at install for exactly that reason.

What this does not buy


← Documentation index