Reconstruct bundles for key-signed cosign signatures - #197
Conversation
Bundle reconstruction required the Fulcio certificate and tlog
annotations on the simple-signing layer, so the classic key-signed
cosign layout ("cosign sign --key" — signature annotation only) was
reported as unsigned by RetrieveBundles. Key-signed layers now build
public-key verification material, making them retrievable and
verifiable with VerifyBundleWithKey, while certificate-bearing layers
keep requiring tlog entries. A reconstructed key-signed bundle still
fails keyless verification, so nothing certificate-less can sneak
through the Fulcio path.
Needed by ToolHive's skill signing flow (stacklok/toolhive#5899),
which verifies key-signed skill artifacts at install time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JAORMX
left a comment
There was a problem hiding this comment.
Panel review — PR #197 (key-signed bundle reconstruction)
Fixed point: 6b57b0d (merge-base with main, i.e. #192) · Diff: 2 files, +150/−2, 1 commit
Axes: Spec (PR body + RFC THV-0080 trust model + toolhive#6023 contract + #192 API contract) · Standards (CLAUDE.md + package conventions) · Domain (secure-code-reviewer, software-architect, library-reuse-reviewer, code-duplication-reviewer, devex-reviewer)
go test ./container/... green on the PR head. Axes are reported separately by design — they ask different questions and don't mask each other.
Spec — does this implement what was asked?
The PR delivers exactly what its body claims: key-signed layers become retrievable and verifiable via VerifyBundleWithKey, certificate-bearing layers keep strict requirements, and the keyless path still rejects certificate-less bundles (pinned by TestKeySignedBundleFailsKeylessVerification). No scope creep.
- [Missing] Offline re-verification of a stored key-signed bundle. RFC THV-0080: "Sigstore bundles are stored alongside local install state.
sync --check… re-verifies the stored bundle + locked identity offline — no network."VerifyBundleOffline(bundles.go:263) hardcodesOfflineTrustedMaterial()+DefaultVerifierOptions()(Fulcio + tlog requirements), so a stored key-signedRawalways fails there — there is noVerifyBundleOfflineWithKey. The PR body scopes itself to onlineVerifyBundleWithKey, so this is a partial implementation of the broader spec, not a regression — but the root cause (no key-path offline entry point) is unfixed, and the toolhive signer flow this unblocks is exactly the store-then-reverify-offline consumer. - [Missing, minor] No test asserts the retrieved bundle's
MessageDigestbinds the simple-signing payload digest — the binding currently holds only becauseDigestHexandMessageDigestboth derive fromlayer.Digest.Hex(sigstore.go:80,324). The coincidence is untested.
Standards — does this follow project conventions?
No hard violations. Error wrapping matches the package (%w), the new helper has godoc, no exported signatures changed, no tests removed, go.mod/go.sum untouched, and the new code path is exercised by both new tests.
- [Judgement] CLAUDE.md asks for table-driven tests for new functions;
hasCosignSignatureAnnotationis only covered indirectly via the two round-trip tests. Defensible for a one-line boolean — the integration tests cover both branches of the modifiedgetBundleVerificationMaterial— noted per convention. - [Judgement] The expanded step-1 comment block (sigstore.go:169–174) deviates from the file's terse single-line numbered-step style, but documents a non-obvious fallback; acceptable.
Domain — what do the specialists say?
Ship-blockers
-
[High] Corrupt-certificate layers are silently reclassified as key-signed — sigstore.go:175–185.
getVerificationMaterialX509CertificateChainreturns the same"failed to decode PEM block"error for annotation absent (legitimate key-signed layout) and annotation present but PEM-corrupt (tampered/malformed keyless layer). The fallback fires on both whenever a signature annotation exists, so a corrupt-cert keyless layer — previously a retrieval error — now becomes a "key-signed" bundle with an emptyPublicKeyIdentifier. The annotations are attacker-controlled registry data (the file's own comments say so at sigstore.go:239–241). Fix: discriminate on annotation presence, not on the decoder error — checkAnnotations["dev.sigstore.cosign/certificate"] == ""first and only then fall to the public-key branch; a present-but-undecodable cert must stay an error. Add a test: garbage cert annotation + signature annotation → retrieval must not yield a bundle. Sources: software-architect, echoed by secure-code-reviewer's trust-boundary read. Cross-confirmed. -
[High] The offline re-verify contract is unmet for exactly the bundles this PR makes storable — and the docs currently promise it — bundles.go:42 (
Rawis "suitable for durable storage and later re-verification withVerifyBundleOffline"), bundles.go:258–262, doc.go:12–30. The documented store-Raw-then-VerifyBundleOfflinenarrative is false for key-signed bundles (Fulcio material + tlog requirement hardcoded). A consumer doing what the docs say getsErrVerificationFailedwith no way to supply a key. Fix (either): addVerifyBundleOfflineWithKey(rawBundle, artifactDigest, pubKeyPEM)(~15 lines mirroringVerifyBundleWithKey, and restores symmetry with theVerifyBundle/VerifyBundleWithKeypair) — preferred given the stated skills-flow consumer — or scope the doc comments onBundle.Raw,VerifyBundleOffline, and doc.go to keyless-only and track the key variant as a follow-up. Sources: software-architect, devex-reviewer, Spec axis. Cross-confirmed — the panel's most important issue.
Judgement calls (discuss, not necessarily blocking)
-
[Medium] Layout classification lives one level too deep — "certificate absent && signature present ⇒ key-signed" is embedded inside the material extractor
getBundleVerificationMaterial, while the signature annotation is independently re-read bygetBundleMsgSignature(sigstore.go:329). Lifting classification intobundleFromSigstoreSignedImage's loop makes the corrupt-cert fix natural and the two-layout design explicit. Fine to defer if finding 1 is fixed in place. Source: software-architect. -
[Medium] Stored key-signed bundles are not self-describing — reconstructed bundles pin
version=0.1media type with an emptyPublicKeyIdentifier{}as an implicit "this is key-signed" sentinel; the attestation path storesv0.3. A reader of storedRawcan't recover the layout, and offline re-verify requires out-of-band key knowledge. Cheap improvement: populatePublicKeyIdentifier.Hint(e.g."cosign-keypair") + document the sentinel and the two media types onBundle/RetrieveBundles. Related boundary worth a comment: cosign v3 bundle-format layers are silently dropped by the simple-signing media-type filter (sigstore.go:156). Source: software-architect. -
[Medium]
Bundlegives no keyless/key-signed discrimination — a consumer that doesn't already know the signing flow must tryVerifyBundlethenVerifyBundleWithKeyand interpret opaque errors. For the paired toolhive signer/verifier use case this is fine; for library generality aHasCertificate()helper or doc guidance would close it. Also:ErrNoBundles/RetrieveBundlesdoc comments still read as if bundles imply keyless — update to say key-signed layouts are now returned. Source: devex-reviewer.
Mechanical fixes
- [Low] Strengthen the negative test's assertion — keysigned_test.go:129:
require.Error→assert.ErrorIs(t, err, ErrVerificationFailed), matching the package's public error contract (bundles_test.go:127 already does this). Sources: devex-reviewer, secure-code-reviewer. - [Low] Log the layer digest + error on per-layer extraction failure — sigstore.go:56,63:
slog.Error("error getting bundle verification material")drops theerrand which layer failed; with the new reclassification path, operators need that breadcrumb. Source: software-architect. - [Info] Drop
testCosignSimpleSigningMediaType— keysigned_test.go:31 duplicates exportedMediaTypeCosignSimpleSigningV1JSON(utils.go:44) in the same package. Sources: library-reuse-reviewer, code-duplication-reviewer (as Info), Standards. - [Info]
v1.NewHash(imgDigest.String())round-trip — keysigned_test.go:74–76 re-parses av1.Hashwhose.Algorithm/.Hexfields are directly available; the error check is dead. Source: library-reuse-reviewer.
Clean bill
- Security: no downgrade path — a public-key-material bundle with zero tlog entries cannot satisfy
WithTransparencyLog(1), so the Fulcio path rejects it before signature verification (proven end-to-end by the new test). The emptyPublicKeyIdentifieris purely informational:PublicKeyMaterial's callback ignores the hint. No new panic/DoS surface. Source: secure-code-reviewer. - Duplication/reuse: production change is minimal and correctly shaped for sigstore-go; no extraction warranted anywhere (net ~4 lines of test polish only).
Gaps / follow-ups beyond this PR
- [Info, pre-existing — file separately] The package never parses the simple-signing payload's
critical.image.docker-manifest-digest; bundleMessageDigestand the policy artifact digest both come from registry-suppliedlayer.Digest.Hex, making sigstore-go's digest cross-check tautological for both keyless and key-signed flows (CWE-354 / OWASP A08:2021). Exploitation needs registry compromise; not introduced or worsened here, but worth a tracking issue since this package is becoming the skills trust anchor.
Summary
- Spec: 2 findings (offline-with-key gap against the RFC; untested digest-binding coincidence)
- Standards: clean, 2 judgement notes
- Domain: 2 ship-blockers (corrupt-cert reclassification; offline/docs contract gap), 3 judgement calls, 4 mechanical/info fixes
Most important single issue: finding 2 — the stored-bundle story this PR enables (and the docs promise) only works for keyless; either ship VerifyBundleOfflineWithKey or narrow the docs before the skills flow builds on it. Finding 1 (corrupt-cert reclassification) is a small, sharp correctness fix that should land with it.
Each axis is orthogonal — verify each independently before shipping.
Classify the signature layout by certificate annotation presence instead of the PEM decoder error, so a corrupt certificate on a keyless layer stays a retrieval failure rather than being silently reclassified as key-signed (the annotations are registry-supplied, attacker-controlled data). Guarded by a test. Complete the stored-bundle story for key-signed bundles: the Bundle docs promised offline re-verification but the only offline entry point hardcoded Fulcio material, so stored key-signed bundles could never re-verify. Add VerifyBundleOfflineWithKey, a HasCertificate discriminator, a "cosign-keypair" public-key hint on reconstructed bundles so stored bytes are self-describing, and scope the package docs to say which entry point serves which layout. Also from review: per-layer extraction failures now log the layer digest and error; the round-trip test asserts the bundle digest binds the simple-signing payload; the keyless-rejection test asserts the typed sentinel; test-local duplicates of the exported media type and a redundant hash round-trip are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All findings addressed in 71d805a — point-by-point: Ship-blockers
Judgement calls
Mechanical
Spec-axis minors
Follow-up filed
Package tests and lint green on the new head. |
Summary
Follow-up to #192.
RetrieveBundlesreported classic key-signed cosign signatures (cosign sign --key) as unsigned: bundle reconstruction required the Fulcio certificate and tlog-entry annotations on the simple-signing layer, which only keyless signatures carry. The key-signed layout attaches just the signature annotation — trust is established by the verifier supplying the public key, so there is no certificate or transparency-log material on the manifest by design.Key-signed layers (signature annotation present, certificate absent) now build public-key verification material, making them retrievable and verifiable through
VerifyBundleWithKey. Certificate-bearing layers keep the existing strict requirements, and a reconstructed key-signed bundle still fails keyless (Fulcio) verification — nothing certificate-less can sneak through that path (covered by a test).Needed by ToolHive's skill-signing flow (stacklok/toolhive#5899), which signs skill artifacts with cosign keys (stacklok/toolhive#6023) and verifies them at install time via this package. Would be great to include in the same release as #192.
Testing
TestRetrieveKeySignedBundleRoundTrip: pushes a key-signed artifact (payload layer + signature annotation at thesha256-<hex>.sigtag) to an in-process registry, retrieves the bundle, verifies it against the signing key, and rejects a different key.TestKeySignedBundleFailsKeylessVerification: the reconstructed bundle must not pass the Fulcio path.