Skip to content

Strengthen signature-to-artifact binding in container/verifier - #263

Merged
samuv merged 2 commits into
mainfrom
t3code/bind-cosign-signatures
Sep 2, 2026
Merged

Strengthen signature-to-artifact binding in container/verifier#263
samuv merged 2 commits into
mainfrom
t3code/bind-cosign-signatures

Conversation

@samuv

@samuv samuv commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What

Strengthens how container/verifier binds a cosign signature to the artifact it is verifying, and settles an inconsistency in what a stored bundle is bound to.

Cosign signature verification is a two-link chain:

  1. the signature covers a "simple signing" payload blob, and
  2. that payload names the artifact it covers, in critical.image.docker-manifest-digest.

Only the first link was being checked. Since signatures are discovered at the mutable sha256-<hex>.sig tag, a signature manifest merely reachable at an artifact's tag was treated as covering that artifact, regardless of which artifact it was actually made for. The binding data was present all along — nothing read it.

Changes

Payload binding (container/verifier/sigstore.go, simplesigning.go)
bundleFromSigstoreSignedImage now fetches each simple-signing layer's blob and refuses the layer unless critical.type is cosign container image signature and critical.image.docker-manifest-digest equals the artifact digest. The artifact digest is threaded through from the single remote.Get that locates the signature tag, rather than re-resolved. Refused layers are skipped; if every layer is refused, the new sentinel ErrSignatureArtifactMismatch is returned so a refused signature stays distinguishable from an absent one (ErrNoBundles / ErrProvenanceNotFoundOrIncomplete semantics are unchanged).

A mismatched critical.identity.docker-reference is logged, not refused — see "Judgement calls".

Discovery order (utils.go)
getSigstoreBundles now queries OCI 1.1 referrers before the .sig tag, and returns the union of both layouts. A referrer is addressed by the artifact's own digest, so it binds structurally; preferring it means whoever can write tags cannot choose which code path runs, and cannot suppress a genuine attestation by adding a .sig tag. Defence in depth — the payload check runs regardless.

Digest contract (bundles.go, stored.go, container/signer)
Online verification bound .sig-derived bundles to the payload digest while VerifyBundleOffline bound stored bundles to whatever artifactDigest it was given. Those are different values, so offline re-verification of a .sig-derived bundle against a real artifact digest could not succeed.

Settled as: a Bundle is bound to the ARTIFACT digest — in memory, persisted, online and offline. VerifyBundleOffline/VerifyBundleOfflineWithKey take the artifact digest, the value a caller already holds from a lock file or registry entry. Callers never handle payload digests.

Reaching that required the payload to survive storage: a Sigstore MessageSignature has nowhere to carry it, and a bundle stored without it can be verified but no longer bound — it proves someone signed something, with no way left to check what. So Bundle.Raw for a cosign signature is now a small self-describing envelope (StoredBundleMediaType) holding the bundle plus the payload. Attestation bundles need no payload and stay bare Sigstore bundle JSON. DecodeStoredBundle reads both shapes, and container/signer's Result.Bundle emits the same form so both producers of stored bundles agree. Result.PayloadDigest is retained and re-documented as informational.

Judgement calls

  • Repository check is a warning, not a rejection. The digest binding is what proves a signature covers an artifact, and it is complete on its own — a signature only passes it if the signer signed a payload naming this exact manifest digest. docker-reference adds only "the signer was looking at this repository", and enforcing it would break signature-preserving copies (mirrors, registry promotion, air-gapped imports) where artifact and signature are both genuine. Cosign's own verification does not enforce it either. Easy to flip if reviewers disagree.
  • Sigstore.VerifyServer / GetVerificationResults now surface ErrSignatureArtifactMismatch instead of ErrImageNotSigned when a signature is found that does not cover the artifact. Deliberate — these are different verdicts — but it is a behaviour change for consumers switching on ErrImageNotSigned.

Downstream (stacklok/toolhive, currently pinned v0.0.42)

  • Source-compatible: no exported signature changed. pkg/plugins/pluginsvc/sync.go's VerifyBundleOffline(pl.SigstoreBundle, entry.Digest, ...) is now the correct call, and its .sig path — which could not have succeeded before — is fixed by the dependency bump. No logic change needed there.
  • Not data-compatible: bundles persisted by v0.0.42 from a cosign signature carry no payload, cannot be bound to an artifact, and now fail closed with ErrVerificationFailed rather than appearing to verify. Stored bundles need re-retrieval, so a follow-up in stacklok/toolhive should either re-fetch and re-store them or treat a payload-less stored bundle as "must re-verify online".

Tests

  • Cross-artifact substitution regression: copies a genuine signature manifest verbatim onto another artifact's .sig tag and asserts verification fails with ErrSignatureArtifactMismatch and not ErrNoBundles, that the Sigstore path errors too, and that the original artifact still verifies.
  • Payload critical.type, missing/malformed digest, and non-JSON payload rejection (table-driven, plus a direct unit table over checkSimpleSigningBinding asserting no case panics).
  • Offline round trip against the artifact digest, and rejection when handed a different artifact's digest.
  • Legacy payload-less stored bundle fails closed (documents the migration above).
  • Referrer path unaffected: end-to-end DSSE attestation referrer round trip, a both-layouts case asserting ordering and that each bundle verifies, and that a genuine attestation survives a substituted cosign signature.
  • Blob integrity: tampered and unsupported-algorithm layer digests.
  • TestRetrieveKeySignedBundleRoundTrip's digest assertion changed deliberately to the artifact digest; TestCorruptCertificateIsNotReclassifiedAsKeySigned's fixture now carries a correctly-binding payload so the new check cannot mask what that test covers.

task passes: golangci-lint 0 issues, go vet clean, license headers clean. Coverage: container/verifier 83.1%, container/signer 85.4%.

🤖 Generated with Claude Code

Cosign signature verification is a two-link chain: the signature covers a
simple-signing payload blob, and that payload names the artifact it covers
via critical.image.docker-manifest-digest. Only the first link was checked.
Because signatures are discovered at the mutable "sha256-<hex>.sig" tag, a
signature manifest reachable at an artifact's tag was accepted as proof that
artifact was signed, whoever it was actually made for.

container/verifier now fetches and parses the payload during retrieval and
refuses any layer whose critical.type is not a container image signature or
whose docker-manifest-digest names a different artifact, reporting the new
ErrSignatureArtifactMismatch so a refused signature stays distinguishable
from an absent one. The artifact digest is threaded through from the single
remote.Get that locates the signature tag rather than re-resolved. A
mismatched critical.identity.docker-reference is logged rather than refused:
the digest binding is what proves coverage, and enforcing the repository
would break signature-preserving mirroring.

Referrer discovery now runs before the .sig tag and both layouts are
returned, so whoever can write tags cannot choose which path runs or
suppress a genuine attestation.

Settle the digest contract, which online and offline verification disagreed
on. A verifier.Bundle is now bound to the ARTIFACT digest everywhere, and
VerifyBundleOffline takes that digest — the value callers already hold.
Reaching that requires the simple-signing payload to survive storage, since
a Sigstore MessageSignature has nowhere to carry it and a bundle stored
without it can be verified but no longer bound. Bundle.Raw for a cosign
signature is therefore a small self-describing envelope
(StoredBundleMediaType) holding bundle and payload; attestation bundles,
which bind structurally, stay bare Sigstore bundle JSON. DecodeStoredBundle
reads both, and container/signer emits the same form so both producers of
stored bundles agree.

No exported signature changes. Bundles persisted by earlier versions from a
cosign signature carry no payload, cannot be bound to an artifact, and now
fail closed rather than appearing to verify; they need re-retrieval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Samuele Verzi <samu@stacklok.com>
Three follow-ups to the simple-signing binding change:

Bound the work one signature manifest can cause. The layer list and the
blobs it names are both registry-supplied, so a manifest listing many
simple-signing layers turned one verification into that many authenticated
blob fetches. Layers are now capped at 32, the total payload bytes read
across a manifest share one budget, and layers naming the same blob are
fetched once and reused — distinct signatures over one artifact share a
payload, so deduplicating the fetch rather than the layer keeps every
signature of a multi-signer artifact.

Recognise one specific wrong digest. A caller holding a stored cosign
bundle previously had to compute the simple-signing payload's digest and
pass that; the payload now travels with the bundle and the entry points take
the artifact digest, so that older call shape arrives as a mismatch. It is
still refused — a digest derived from the payload proves nothing about which
artifact the payload names — but the error now names the fix instead of
reading like a substituted signature.

Correct the PayloadDigest doc comment, which still described the value as
what a bundle verifier needs. It is informational; verification takes the
artifact's own manifest digest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Samuele Verzi <samu@stacklok.com>
@samuv
samuv merged commit 3a3921f into main Sep 2, 2026
5 checks passed
@samuv
samuv deleted the t3code/bind-cosign-signatures branch September 2, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants