Skip to content

Verify keyless cosign signatures once sigstore-rs authenticates the signing time #116

Closed

Description

@jonasz-lasut

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 .sig tag 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#285 lines in src/bundle/verify/verifier.rs). It then compares the certificate's window against the bundle's integratedTime, 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. requireSignature keeps 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:

  1. Verifies the keyless signature: cosign verify --certificate-identity <workflow identity> --certificate-oidc-issuer https://token.actions.githubusercontent.com <ref>@sha256:…
  2. Copies the module into its own registry (cosign copy).
  3. Signs the copy with its own key (cosign sign --key), which the runtime verifies with --cosign-key.

Activity

  1. jonasz-lasut commented on Oct 6, 2026

    @jonasz-lasut
    OwnerAuthor

    #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 .sig tag. A keyless bundle on the same artifact matches no configured key and is passed over, so this issue is the remaining step.

  2. jonasz-lasut commented on Oct 26, 2026

    @jonasz-lasut
    OwnerAuthor

    Upstream check, 2026-10-26.

    sigstore-rs has not moved. 0.14.0 (2026-05-22) is still the latest release. bundle::verify on main still carries the two TODO ... sigstore-rs#285 lines 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 behind cosign::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-verify 0.15.1 (2026-10-23) verifies a v0.3 bundle offline:

    • Verified times are every RFC 3161 timestamp checked against the trusted root's timestamp authorities plus every Rekor v1 integratedTime whose 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.
    • Inclusion proof with the signed checkpoint, SCT, Fulcio chain, and the body-consistency check for CVE-2022-36056.
    • Verifier::verify(artifact, &bundle, &policy) takes bytes or a Sha256Hash, 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). PublicKeyVerificationPolicy covers key-based bundles, so one verifier can replace what cosign.rs does today.
    • sigstore-trust-root: TrustedRoot::production() over TUF with an embedded root.json, plus from_embedded, from_json and from_file for 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": swap cosign.rs's verification onto sigstore-verify + sigstore-trust-root for key-based and keyless bundles alike, and build the signer-identity layer in the operator's Cedar policy over any_identity(). The trust root, the Cedar identities, the docs and the "until then" workaround in the description stay as written.

  3. jonasz-lasut commented on Oct 27, 2026

    @jonasz-lasut
    OwnerAuthor

    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.

    1. Air-gapped, static file. --cosign-trusted-root <path> keeps its meaning: this file and nothing else, no network, the operator owns the refresh (a mounted ConfigMap; cosign initialize writes the file). Unchanged from Verify keyless cosign signatures with sigstore-verify (spike) #179.
    2. Dynamic pull of a well-known instance. A new flag (proposed name --cosign-trust-instance <public-good|staging|github>) turns on sigstore-trust-root's tuf feature for that instance. The crate's embedded root.json bootstraps TUF; a refresh walks root, timestamp, snapshot and targets, each signed and expiring, and fetches trusted_root.json by 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::custom makes it a later flag, not a design change.

    Behaviour in the dynamic mode.

    • Startup: refresh online, bounded by a startup timeout. On failure, re-verify the cache offline from the embedded root (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.
    • Refresh: on the existing ten-minute sweep, at most once per refresh interval (proposed --cosign-tuf-refresh, default one hour), skipped while one is in flight. A changed root rebuilds the verifier behind its Arc and 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, counts trusted_root_refreshes_total{outcome} and logs once per outcome change.
    • Verification never touches the network. A refresh failure can only make the root older, never a request slower. TUF metadata expiry is honoured (from_tuf, not from_tuf_at), so a cache older than the repository's timestamp window is refused and the startup fallback applies.
    • function validate takes the same flags and runs the same refresh, so an operator can rehearse it offline.

    Decisions before implementing.

    • Opt-in only, or default to public-good whenever the operator policy has trustSigner rules.
    • Fresh-or-refuse when neither the network nor a verified cache yields a root at startup.
    • Refresh cadence against the public-good timestamp role's actual expiry, so a healthy runtime never trips the freeze check.
    • Cache placement: under the module cache root (and excluded from the LRU sweep) or its own path.
    • Memo invalidation: clear on every root change, or key the memo by the root's hash.
    • One instance per runtime initially; a GitHub-attested module beside public-good ones would need two.
    • Metric names for the refresh counter and a root-age gauge, within the no-unbounded-label rule.

    Acceptance. A runtime started with only --cosign-trust-instance public-good and a trustSigner rule 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 expired timestamp refused 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.

  4. jonasz-lasut commented on Oct 27, 2026

    @jonasz-lasut
    OwnerAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions