Repository navigation
Verify keyless cosign signatures once sigstore-rs authenticates the signing time #116
Description
The runtime verifies cosign signatures made with a key (--cosign-key). Signatures made keyless (a short-lived Fulcio certificate plus a transparency-log entry, which is how this repository's own release workflows sign) cannot be verified yet. This issue tracks adding them once the upstream verifier can do it safely.
Where we are
- cosign 3 stores a signature as a Sigstore bundle (v0.3) attached to the signed manifest as an OCI 1.1 referrer. On registries without the referrers API, GHCR among them, it falls back to an index tagged
sha256-<hex>. - The runtime is moving from cosign's legacy
.sigtag to these bundles, for key-based signatures only (see the linked PR). - A keyless bundle on the same artifact is not an error: no configured key verifies it, so it is passed over, and a module signed both ways still verifies by its key.
Why keyless waits
Keyless verification depends on proving when the signature was made: the Fulcio certificate is valid for ten minutes, and the signature must fall inside that window by a time the verifier can trust. sigstore-rs 0.14.0, the latest release, does not establish that time:
- Its bundle verifier skips the Rekor signed entry timestamp (SET) and the inclusion proof (the
TODO ... sigstore-rs#285lines insrc/bundle/verify/verifier.rs). It then compares the certificate's window against the bundle'sintegratedTime, which nothing authenticates. - It does not verify RFC 3161 timestamps, which take the SET's place in Rekor v2 bundles and in GitHub's own Sigstore instance.
Without an authenticated signing time, a key taken from a CI run during its certificate's window would verify indefinitely.
Upstream: sigstore/sigstore-rs#283 (inclusion proofs and checkpoint verification), sigstore/sigstore-rs#539 (verification through timestamp authorities), sigstore/sigstore-rs#533 (cosign 3 bundles as OCI referrers).
What lands when upstream does
- A trust root: Sigstore's
trusted_root.json(Fulcio CAs, CT log keys, Rekor keys, timestamp authorities), fetched over TUF at startup and cached, with a mounted file as the override for air-gapped clusters. - Signer identities in the operator's Cedar policy, default-deny and per repository: which OIDC issuer and certificate identity may sign modules from which repository. For example, only this repository's release workflow, on version tags, for
ghcr.io/jonasz-lasut/wasmfn/examples.requireSignaturekeeps deciding which repositories need a signature at all. - The signing time: the SET or the inclusion proof and checkpoint (Rekor v1), and RFC 3161 timestamps (Rekor v2).
- Docs: the trust-model one-pager, the README's signing section, and AGENTS.md, which records keyless as deliberately unsupported; this reverses that decision.
The project's published artifacts, the example modules (#112) and the WIT package (#106), can then be trusted by the identity of this repository's release workflow, without a countersignature.
Until then
An operator who wants to require a signature on a module that was signed keyless:
- Verifies the keyless signature:
cosign verify --certificate-identity <workflow identity> --certificate-oidc-issuer https://token.actions.githubusercontent.com <ref>@sha256:… - Copies the module into its own registry (
cosign copy). - Signs the copy with its own key (
cosign sign --key), which the runtime verifies with--cosign-key.
Activity
jonasz-lasut commentedon Oct 6, 2026
#118 is merged: the runtime now verifies key-based cosign 3 Sigstore bundles, found through the OCI 1.1 referrers API or the
sha256-<hex>tag fallback, and no longer reads cosign's legacy.sigtag. A keyless bundle on the same artifact matches no configured key and is passed over, so this issue is the remaining step.jonasz-lasut commentedon Oct 26, 2026
Upstream check, 2026-10-26.
sigstore-rs has not moved. 0.14.0 (2026-05-22) is still the latest release.
bundle::verifyon main still carries the twoTODO ... sigstore-rs#285lines for the SET and the inclusion proof, although #285 itself merged in January and shipped in 0.14.0. sigstore/sigstore-rs#283 is open since 2023, #533 and #539 are open without a single comment, the TSA prototype behind #539 is a fork branch from 2025-08, main since the release is dependency bumps plus two small fixes in June, and the pull requests opened since July are still open.One nuance: 0.14.0 did ship a
cosign-module path that reads v0.3 referrer bundles and verifies the SET, the inclusion proof and the checkpoint (sigstore/sigstore-rs@dedbebd2,src/cosign/signature_layers.rs). It is crate-private behindcosign::Client's own oci-client pulls, pinned to Rekor v1, and has no RFC 3161 support, so it fits neither a runtime with its own registry client nor GitHub's own Sigstore instance.The blocker is met by a different crate. The sigstore org hosts a second Rust implementation, https://github.com/sigstore/sigstore-rust (Wolf Vollprecht's rewrite, started while his v0.3 PRs to sigstore-rs, #513, #514 and #518, were pending; those were closed in favor of #285 or abandoned; Jussi Kukkonen commits there too). docs.sigstore.dev still lists sigstore-rs as the Rust client and neither README mentions the other project. Its
sigstore-verify0.15.1 (2026-10-23) verifies a v0.3 bundle offline:integratedTimewhose SET verifies with a Rekor key. The certificate must be valid at each of them, a present-but-failing source is fatal, and no verified time is a refusal: there is no fallback to the current time. Rekor v2 entries (hashedrekord/0.0.2) require a TSA timestamp.Verifier::verify(artifact, &bundle, &policy)takes bytes or aSha256Hash, so the pinned manifest digest works without the manifest bytes, and a DSSE in-toto statement is bound to that digest.VerificationPolicy::any_identity()returns the issuer and SAN for the caller's own policy (identity matching inside the crate is exact only).PublicKeyVerificationPolicycovers key-based bundles, so one verifier can replace whatcosign.rsdoes today.sigstore-trust-root:TrustedRoot::production()over TUF with an embeddedroot.json, plusfrom_embedded,from_jsonandfrom_filefor the air-gapped override planned above;tsa_authorities()exposes the TSAs.Costs: pre-1.0 API churn (four minor releases between 2026-09-23 and 2026-10-13, the latest renaming functions and features, sigstore/sigstore-rust#319), default crypto aws-lc-rs (already in our lock file through rustls), MSRV 1.86, Apache-2.0. Bundle discovery over OCI referrers stays ours; the crate has no registry client.
Proposal: re-scope this issue from "wait for sigstore-rs" to "verify through
sigstore-verify": swapcosign.rs's verification ontosigstore-verify+sigstore-trust-rootfor key-based and keyless bundles alike, and build the signer-identity layer in the operator's Cedar policy overany_identity(). The trust root, the Cedar identities, the docs and the "until then" workaround in the description stay as written.feat(cosign): reword the no-verifier refusals
test(cosign): keyless cases over a test Sigstore
feat(cosign): verify keyless signatures with sigstore-verify
docs: document keyless cosign verification
feat(cosign): reword the no-verifier refusals
test(cosign): keyless cases over a test Sigstore
jonasz-lasut commentedon Oct 27, 2026
Where #179 leaves this. The runtime verifies keyless signatures against a Sigstore trusted root, but only one it reads from a file at startup (
--cosign-trusted-root trusted_root.json). Sigstore rotates the material in that file: the public-good root fetched on 2026-10-26 already carries two generations of Rekor keys, Fulcio chains and CT keys. A long-running runtime holding a stale copy keeps admitting old signatures and starts refusing every module signed after the next shard or CA rotation, with a message that names a log key it has never heard of. Fetching the root over TUF at startup and refreshing it while serving is the remaining half of this issue. The design and the decisions it needs are written up separately; this note records the scope.Initial scope: two modes, mutually exclusive.
--cosign-trusted-root <path>keeps its meaning: this file and nothing else, no network, the operator owns the refresh (a mounted ConfigMap;cosign initializewrites the file). Unchanged from Verify keyless cosign signatures with sigstore-verify (spike) #179.--cosign-trust-instance <public-good|staging|github>) turns on sigstore-trust-root'stuffeature for that instance. The crate's embeddedroot.jsonbootstraps TUF; a refresh walks root, timestamp, snapshot and targets, each signed and expiring, and fetchestrusted_root.jsonby the length and hashes the verified metadata pins. Verified metadata and the target are cached under the fixed cache root (/tmp/function-wasm-cache/tuf/<instance>). The fetch goes through the runtime's own reqwest client with timeouts, which the crate asks for against the slow-retrieval attack.A private TUF mirror (custom URL plus its own
root.json) is out of the initial scope.TufConfig::custommakes it a later flag, not a design change.Behaviour in the dynamic mode.
TufConfig::offline()): a cache a previous pod filled is as trustworthy as a fresh fetch, only older. With neither, either start from the crate's embedded snapshot with a warning or refuse to start (a flag; to decide below). Readiness is not held back in any case: the root's age is a policy matter, not a liveness one.--cosign-tuf-refresh, default one hour), skipped while one is in flight. A changed root rebuilds the verifier behind itsArcand clears the verified-digest memo, so a module whose authority was dropped is re-verified on its next request. A failed refresh keeps the last good root, countstrusted_root_refreshes_total{outcome}and logs once per outcome change.from_tuf, notfrom_tuf_at), so a cache older than the repository's timestamp window is refused and the startup fallback applies.function validatetakes the same flags and runs the same refresh, so an operator can rehearse it offline.Decisions before implementing.
public-goodwhenever the operator policy hastrustSignerrules.timestamprole's actual expiry, so a healthy runtime never trips the freeze check.Acceptance. A runtime started with only
--cosign-trust-instance public-goodand atrustSignerrule admits this repository's published examples, keeps admitting them across a Rekor shard rotation without a restart, and never contacts the network on the request path. Tests: the TUF round trip against a local TUF repository (online refresh, then offline from the cache alone); a rotation of the in-memory Sigstore instance's Rekor key republished through that repository (refused before the refresh, admitted after, memo invalidated); an expiredtimestamprefused with the last good root kept and the counter moved. Conformance goldens cover the new wording, the operator docs gain the flags, the cache path, the air-gapped recipe and the troubleshooting entries, and the trust-model one-pager moves a revision.jonasz-lasut commentedon Oct 27, 2026
The TUF fetch and refresh is split out to #183, whose body is the scope note above with the two initial modes (an air-gapped static file, or a dynamic pull of public-good, staging or github). This issue closes with #179 on the file-based trusted root; the countersigning workaround in the description is history from then on.
docs: point the TUF follow-up at its issue