Repository navigation
Add Fulcio/Rekor keyless signing to SignOCI - #235
Conversation
SignOCI could only sign with a file-based cosign key, hard-failing without one, so the certificate-bearing attach and retrieval paths had no producer: nothing in-tree could actually mint a keyless signature. Options now takes an OIDC identity token, which selects the keyless flow: a single-use ephemeral key pair, a Fulcio certificate binding it to the token's identity, and a Rekor v1 transparency-log entry. The entry is mandatory rather than optional, because the verification policy container/verifier applies to keyless bundles requires one. FulcioURL and RekorURL override the public-good endpoints, defaulted at signing time. Supplying both a key and a token is rejected instead of resolved by precedence: the two produce trust material verifiers check in materially different ways, so choosing silently would attach a signature the caller cannot verify the way it expects. Tests stand up a complete in-process Sigstore deployment over httptest — Fulcio, a Certificate Transparency log, and Rekor v1 — speaking the real HTTP APIs, so no test reaches the public-good or staging instances. Its material is cryptographically genuine, which lets the same deployment serve as the verification trust root: a bundle it signs is checked end to end under DefaultVerifierOptions, embedded SCT and signed entry timestamp included, rather than merely inspected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Re-signing with an identity that already signed the artifact made attachCosignSignature a no-op, but SignOCI still returned the freshly (and never attached) built bundle — every signing operation is randomised, so its signature did not match the sole registry layer, breaking the "one signature, two representations" contract from the dedupe side. SignOCI now retrieves and returns the bundle actually on the registry when nothing was appended. Bundle validation also ran after attachCosignSignature, so a malformed-but-parseable Fulcio/Rekor response could mutate the registry and only then fail, returning an error despite having already signed. Validation now runs first. Separately, sigstore-go's Fulcio client builds its HTTP request without attaching the caller's context, so a canceled SignOCI previously stayed blocked until the client's own timeout; and sigstore/rekor's response parsing dereferences several Verification/InclusionProof fields unconditionally, so a well-formed-but-incomplete Rekor response reliably panics the process. Both are guarded by running sign.Bundle on its own goroutine and racing it against ctx.Done(), with a recover() converting a panic into a returned error instead of taking down a long-lived signing process.
bundleMatchesSigner's keyless branch matched on certificate identity (SAN + OIDC issuer) alone, unlike attach's own dedupe check (signedByIdentity), which treats identity as a candidate rather than a verdict and additionally requires the layer's signature to verify. A same-identity layer whose signature doesn't verify against the current payload — corrupt, or signed over a different one — could therefore be selected ahead of a genuinely valid layer when RetrieveBundles returns it first, making SignOCI return a bundle whose signature does not match its own PayloadDigest. Also documents that Result.Bundle's JSON shape differs by path (a fresh attach serializes as bundle v0.3, a dedupe match is the v0.1 reconstruction RetrieveBundles builds from OCI annotations) and that IdentityToken is sent to FulcioURL in the clear over a non-HTTPS endpoint.
72c783d to
697e3d4
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Panel review (spec vs stacklok/toolhive#6307, repo standards, six specialist reviewers — every technical claim verified against the pinned deps on disk: sigstore-go v1.3.0, sigstore v1.10.9, rekor v1.5.3).
Spec axis — clean within this PR's claimed scope. Keyless flow, ErrAmbiguousSigningMethod, signing-time defaulting, mandatory Rekor v1 entry (verified: container/verifier requires SCT=1/tlog=1/observer=1), unchanged key path — all confirmed against the diff. Still open against #6307 (correctly deferred, not done here): CLI/push-API credential plumbing, Sigstore-staging E2E, stock cosign verify interop.
Standards — CLAUDE.md Boundaries says never modify go.mod dependencies without being asked; the 5 indirect→direct promotions are test-only and correct (go.sum unchanged), but please confirm an approver is fine with them. Also: table-driven tests for new functions — TestKeylessBundleOptionsDefaultToPublicGood is the clear candidate.
Domain — the likely blocking finding: keyless dedupe verifies the layer's signature against the key in its own cert, never chaining that cert to the configured Fulcio root (inline on cosign_attach.go). A registry writer can preload a self-signed same-identity layer, making signing a silent no-op that returns attacker material as success. Also inline: no HTTPS/loopback validation on FulcioURL/RekorURL (bearer token; networking.ValidateEndpointURL exists); Fulcio-vs-Rekor failure stage unidentifiable in errors; goroutine+recover wrapper scoped wider than the two verified upstream defects (pkg/sign/certificate.go:175-207, pkg/tle/tle.go:33-44 — claims confirmed).
Verified non-findings: hardcoded-endpoint rationale holds (root.CertificateAuthority exposes only Verify); Rekor-v1-only holds (v2 needs a TSA per pkg/sign/signer.go:171-189); no IdentityToken leakage into local errors; unsigned-JWT parsing is test-only; v0.3/v0.1 Result.Bundle shape variance round-trips through container/verifier (pinned by TestSignOCIKeylessRoundTrip).
Verify the full Sigstore chain of trust before treating a cert-bearing layer as a dedupe match, instead of comparing certificate SAN and issuer alone: those fields live inside the certificate but are public, attacker-controlled input, so a self-signed cert with a matching SAN and issuer could previously be mistaken for a genuinely signed layer. Dedupe now calls into the existing verifier to confirm the certificate chains to a trusted root before comparing identity. Split keyless signing into distinct Fulcio and Rekor stages so errors say which one failed, instead of a single generic "building sigstore bundle" wrapper. Fulcio's certificate is now fetched once directly and handed to sign.Bundle through a small cache so its internal call is a no-op, keeping Rekor submission as the only remaining fallible step. Narrow the context-cancellation race and panic recovery to only the keyless path, since the key-signed path has no network calls to race or untrusted responses to guard against. Validate FulcioURL and RekorURL with the shared HTTPS/loopback check used elsewhere in this module, rejecting plain-HTTP endpoints outside loopback rather than sending signing material over an unencrypted connection. Add a per-instance trust-material injection seam (newDefaultForTest) so tests can exercise real chain-of-trust verification against an in-process CA instead of the production Sigstore root, without a global variable that would be unsafe under parallel tests.
|
Addressed the domain findings from the panel review (replies inline on each):
On the two "Standards" notes in the review body:
Also note: the branch was rebased onto main after the review to pick up the Verification: |
Summary
container/signer.Default.SignOCIcould only sign with a file-based cosign key, returningErrKeyRequiredwhen none was given. That left the certificate-bearing attach and retrieval paths — fixed in #234 (PR C1) — without a producer: nothing in-tree could actually mint a keyless signature, so the layout could only be exercised with hand-built fixtures.This adds the real keyless path. Part of the keyless push-signing work tracked as stacklok/toolhive#6307.
Options.IdentityTokenselects the keyless flow. A single-use ephemeral key pair is minted, Fulcio issues a short-lived certificate binding it to the token's identity, and the signature is submitted to Rekor. Acquiring the token (ambient CI credentials, browser flow, device flow) stays entirely the caller's problem — this package never performs OAuth and only forwards the token it is handed.container/verifier's own policy for keyless bundles requiresWithSignedCertificateTimestamps(1),WithTransparencyLog(1)andWithObserverTimestamps(1), so a bundle with no transparency-log entry would not verify under the policy this repo itself applies. Rekor v1 specifically: v2 issues proof-only entries and needs a separate timestamp authority, and the cosign bundle annotation has no field for a v2 inclusion proof.Options.FulcioURL/Options.RekorURLoverride the public-good endpoints, defaulted at signing time so a zero-valuedOptionstargets public-good.KeyandIdentityTokenset is an error, not a precedence rule. The two produce trust material verifiers check in materially different ways (a bare public key versus a Fulcio identity plus the transparency log), so resolving it silently would attach a signature the caller cannot verify the way it expects. New sentinelErrAmbiguousSigningMethod.ErrKeyRequired's message now names both options. Phrased in terms of theOptionsfields, since this package does not know CLI flag names.attachCosignSignatureandcontainer/verifierare untouched — C1 already handles cert-bearing bundles correctly, and the keyless path calls it with exactly the same arguments as the key path.Where defaulting happens, and why the URLs are hardcoded
Both documented on
Optionsand the constants. Defaulting is applied insidekeylessBundleOptionsat signing time. The endpoints are hardcoded rather than derived from a trusted root because reading them means fetching a root (a TUF round trip, or the point-in-time snapshot embedded incontainer/verifier) purely to learn a URL, and the URI is only reachable by type-assertingroot.CertificateAuthority— a single-methodVerifyinterface — to the concrete*root.FulcioCertificateAuthority. More importantly the trusted root is verification material: keying signing endpoints off it would let a trust-root refresh silently redirect where signing requests, and the identity token in them, are sent.Type of change
Changes
container/signer/signer.goOptions.IdentityToken/FulcioURL/RekorURL;signingMaterial+keylessBundleOptions;ErrAmbiguousSigningMethod; updatedErrKeyRequiredcontainer/signer/keyless_test.gohttptest, doubling as the verification trust root; keyless testsgo.modcertificate-transparency-go,rekor,go-openapi/runtime,swag/conv,json-canonicalization). No new modules,go.sumunchangedTest plan
task test, full suite, race detector)task lint, 0 issues)go build ./container/...andgo vet ./container/...TestSignOCIRoundTrip,TestSignOCIEncryptedKey,TestSignOCIAcceptsCosignCLIKeyFormat, the C1 attach tests, …)This PR is network-free. Real Sigstore-staging E2E coverage is separate follow-on work, not this PR.
How the Fulcio/Rekor interaction is tested without the network
testSigstoreis a complete Sigstore deployment running in-process behindhttptestservers that speak the actual HTTP contracts sigstore-go's clients drive —POST /api/v2/signingCertandPOST /api/v1/log/entries. Servers, not stubs: mocking sigstore-go'sCertificateProvider/RekorClientinterfaces would have left the part most likely to break — thatSignOCIwires the ephemeral key, identity token and endpoint URLs into real requests — untested.The material it issues is cryptographically genuine, which is what lets the same object also implement
root.TrustedMaterialand serve as the verification trust root:SignOCIsigned the token's subject with the very key it asked to have certified.SignedEntryTimestampand single-leaf checkpoint.Coverage:
TestSignOCIKeylessRoundTripverifier.RetrieveBundles→HasCertificate()is trueTestSignOCIKeylessBundleVerifiesverifier.VerifyBundlewithDefaultVerifierOptions()against the test trust root; identity read back and re-pinned; a different identity is rejectedTestSignOCIKeylessRejectedByUnrelatedTrustRootTestSignOCIKeylessAttachesCertificateAndTlogAnnotationsTestSignOCIKeylessReSignAppendsForNewIdentityTestSignOCIKeylessSurfacesFulcioFailureTestSignOCIRejectsAmbiguousSigningMethod,TestErrKeyRequiredNamesBothSigningMethodsI mutation-tested the two assertions that could quietly stop covering anything, and confirmed both fail when the fake stops doing its job: dropping the SCT extension yields
only able to verify 0 SCT entries; unable to meet threshold of 1, and corrupting theSignedEntryTimestampyieldsnot enough verified log entries from transparency log: 0 < 1.Special notes for reviewers
go.modchurn is only indirect → direct promotion for test-only imports;go.sumis unchanged.sign.BundleOptions.TrustedRootis deliberately left unset, sosign.Bundledoes not verify the bundle it just produced. It would need a trusted root for the target deployment, which this package cannot scope to a caller's customFulcioURL/RekorURLwithout a TUF fetch. Verification stays the caller's step throughcontainer/verifier.Generated with Claude Code