Strengthen signature-to-artifact binding in container/verifier - #263
Merged
Conversation
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>
10 tasks
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>
JAORMX
approved these changes
Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Strengthens how
container/verifierbinds 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:
critical.image.docker-manifest-digest.Only the first link was being checked. Since signatures are discovered at the mutable
sha256-<hex>.sigtag, 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)bundleFromSigstoreSignedImagenow fetches each simple-signing layer's blob and refuses the layer unlesscritical.typeiscosign container image signatureandcritical.image.docker-manifest-digestequals the artifact digest. The artifact digest is threaded through from the singleremote.Getthat locates the signature tag, rather than re-resolved. Refused layers are skipped; if every layer is refused, the new sentinelErrSignatureArtifactMismatchis returned so a refused signature stays distinguishable from an absent one (ErrNoBundles/ErrProvenanceNotFoundOrIncompletesemantics are unchanged).A mismatched
critical.identity.docker-referenceis logged, not refused — see "Judgement calls".Discovery order (
utils.go)getSigstoreBundlesnow queries OCI 1.1 referrers before the.sigtag, 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.sigtag. 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 whileVerifyBundleOfflinebound stored bundles to whateverartifactDigestit 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
Bundleis bound to the ARTIFACT digest — in memory, persisted, online and offline.VerifyBundleOffline/VerifyBundleOfflineWithKeytake 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
MessageSignaturehas 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. SoBundle.Rawfor 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.DecodeStoredBundlereads both shapes, andcontainer/signer'sResult.Bundleemits the same form so both producers of stored bundles agree.Result.PayloadDigestis retained and re-documented as informational.Judgement calls
docker-referenceadds 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/GetVerificationResultsnow surfaceErrSignatureArtifactMismatchinstead ofErrImageNotSignedwhen a signature is found that does not cover the artifact. Deliberate — these are different verdicts — but it is a behaviour change for consumers switching onErrImageNotSigned.Downstream (
stacklok/toolhive, currently pinnedv0.0.42)pkg/plugins/pluginsvc/sync.go'sVerifyBundleOffline(pl.SigstoreBundle, entry.Digest, ...)is now the correct call, and its.sigpath — which could not have succeeded before — is fixed by the dependency bump. No logic change needed there.v0.0.42from a cosign signature carry no payload, cannot be bound to an artifact, and now fail closed withErrVerificationFailedrather than appearing to verify. Stored bundles need re-retrieval, so a follow-up instacklok/toolhiveshould either re-fetch and re-store them or treat a payload-less stored bundle as "must re-verify online".Tests
.sigtag and asserts verification fails withErrSignatureArtifactMismatchand notErrNoBundles, that theSigstorepath errors too, and that the original artifact still verifies.critical.type, missing/malformed digest, and non-JSON payload rejection (table-driven, plus a direct unit table overcheckSimpleSigningBindingasserting no case panics).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.taskpasses: golangci-lint 0 issues,go vetclean, license headers clean. Coverage:container/verifier83.1%,container/signer85.4%.🤖 Generated with Claude Code