Record a pinned cosign key in the lock schema - #6444
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6444 +/- ##
==========================================
- Coverage 78.05% 78.04% -0.01%
==========================================
Files 767 767
Lines 74343 74395 +52
==========================================
+ Hits 58025 58063 +38
- Misses 16313 16327 +14
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
61a9f43 to
f28ca44
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Reviewed against t3code/key-verify/01-classify-key-signed. The lock schema fails closed for older readers, enforces anchor exclusivity and OCI-only key pins, and keeps keyless verifier paths explicit. No blocking findings. CI is green.
51799aa to
b8d1713
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Re-reviewed the rebased current head against main. The schema still accepts a key-pinned Git resolved reference when its digest is written in OCI form, even though restore dispatches from the resolved reference; that leaves an OCI-only trust anchor attached to a Git entry and moves validation failure into the fetch path. Please make reference kind and digest kind consistent at the lock boundary. All 47 CI checks are green.
b8d1713 to
1402640
Compare
Verifying a key-signed artifact needs a trust anchor, and a lock entry could previously express only a keyless certificate identity. Entries may now pin a cosign public key instead. The full key is stored rather than a digest of it, because the key is recoverable from neither the artifact nor the stored bundle: cosign's signature manifest defines no annotation carrying it, so a digest would have nothing to hash at verification time. It is held as base64 DER SPKI because a lock value may not contain whitespace, which rules out PEM armor. The two anchors are mutually exclusive. They are checked by different policies against different trust roots, so an entry carrying both would not say which applies, and one carrying neither pins nothing. Certificate-derived fields are refused on a key-pinned entry as well: read from a certificate a key-pair signature does not have, they would pin constraints no verification could check. That exclusivity is also why no schema version bump is needed. A build predating this field sees an entry with no signer identity, reports it as required, and fails the lock file closed rather than treating the entry as unpinned. Bumping instead would be actively harmful, since the version check is an equality comparison and would reject every existing lock file. Nothing writes a key-pinned entry yet. Until the install path lands, handing one to a keyless verification path is refused up front: its certificate fields are empty by construction, which Sigstore rejects for want of subject alternative name criteria — closed already, but with a message that names neither the entry nor the mismatch. Skill info and install output render a key-pinned entry distinctly, since an empty signer identity would otherwise read as an untracked install. Refs #6442 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Base64 decoding proved only the encoding, so a well-encoded blob that was not a key at all passed lock validation and would have failed deep inside verification instead, long after the lock file stopped looking like the suspect. The decoded value is now parsed as a DER SPKI public key. The test fixture had the same flaw and was not a key either; it is now a real one. A key pair signs an OCI artifact, while a git entry's signature lives on the commit and is always certificate-based, so a key-pinned git entry pinned an anchor no verification of that entry could use. Entry validation rejects the combination, and VerifyGit refuses a key-pinned expectation as well: lock validation only ever sees lock files, and an expectation built in memory never passes through it. Also restores the doc comment on expectedIdentity, which the new helper had been inserted in front of. Refs #6442 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
The OCI-only rule on a pinned public key inferred the entry's kind from its digest, but restore dispatches from resolvedReference. A hand-edited entry naming a git reference alongside a syntactically valid OCI digest therefore passed validation carrying a key anchor no git verification could use, and the malformed trust decision surfaced only once the fetch built "git://host/repo@sha256:...". Classify the entry from resolvedReference — the field that decides — and reject a digest whose form contradicts it. The disagreement is malformed independently of any key anchor, so the check stands on its own; the install path cannot write such a pair, which is why the lock boundary is where it belongs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
1402640 to
ea61f12
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Re-reviewed the current head against main across Spec, Standards, Security, Architecture, DevEx, and Reuse. The new commit fixes the reference/digest-kind inconsistency for both skills and plugins, resolving the prior blocker. I left one non-blocking validation-order note that is already fixed in the stacked child PR. All 47 CI checks are green.
Summary
Verifying a key-signed artifact requires a trust anchor to check it against, and a lock entry could previously express only a keyless certificate identity. This adds the alternative anchor so the install path (next PR) has somewhere to record one. Nothing writes a key-pinned entry yet — this is schema, validation, and every reader of the lock.
Provenance.publicKey— the pinned cosign public key, as base64 DER SPKI.validateProvenanceAnchor: a keyless certificate identity, or a public key. Certificate-derived fields (repositoryUri,repositoryRef,runnerEnvironment,sigstoreUrl) are refused on a key-pinned entry.VerifyOCI,VerifyBundleOffline, andVerifyGit.ProvenanceInfo.PublicKeyplus both lock↔API conversions, and distinctskill info/skill installrendering.Two design points that are load-bearing, both established by reading the code rather than assumed:
The full key is stored, not a digest of it. The key is recoverable from neither the artifact nor the stored bundle —
signatureAnnotationsdefines no annotation carrying it, and the retrieve side reconstructs the bundle with a fixed placeholder hint (keySignedPublicKeyHint = "cosign-keypair"). A digest would have nothing to hash at verification time. Base64 DER SPKI rather than PEM because a lock value may not contain whitespace (validateProvenance), which rules out PEM armor.No schema version bump, deliberately. A build predating this field sees an entry with no
signerIdentity, reports it as required, and fails the lock file closed — the downgrade guard already exists. Bumping would be actively harmful:validation.go:35compareslf.Version != CurrentVersion, so raising it to 2 would make every existing v1 lock file unreadable.Refs #6442 (second of ~7 PRs; the issue stays open).
Type of change
Test plan
task test)task test-e2e)task lint-fix)Full unit suite passes for every package touched. One unrelated failure persists —
pkg/transport/proxy/streamable'sTestMCPGoClientInitializeAndPing, caused by a stray local process holding:8096; confirmed pre-existing on cleanmainand untouched here.task lint-fixreports issues only incmd/thv-operator/files this PR does not touch.New coverage: four validation cases (key-pinned entry accepted without an identity; both anchors rejected as mutually exclusive; certificate fields rejected on a key-pinned entry; non-base64 rejected), and both keyless paths — online and offline — refusing a key-pinned entry. The two pre-existing "signerIdentity/certIssuer is required" cases were checked to still take their original routes through the new logic.
API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.No operator API surface. The
docs/server/*churn is the additivepublic_keyfield on the swagger-documented provenance shape.Changes
pkg/skills/lockfile/lockfile.goProvenance.PublicKey; anchor semantics documented on the typepkg/skills/lockfile/validation.govalidateProvenanceAnchor— identity XOR key, base64, certificate fields refusedpkg/skills/verifier/errors.goerrKeyPinnedEntry, wrappingErrSignatureInvalidpkg/skills/verifier/types.gokeyPinnedExpectationpkg/skills/verifier/oci.go,offline.gopkg/skills/options.goProvenanceInfo.PublicKeypkg/skills/skillsvc/verify.gocmd/thv/app/skill_info.go,skill_install.godocs/arch/12-skills-system.mddocs/server/*task docs)Does this introduce a user-facing change?
Not yet in behavior — nothing writes a key-pinned entry until the install path lands. A hand-written
publicKey:entry is now accepted by lock validation and rendered distinctly byskill info, rather than being rejected for a missingsignerIdentity.Special notes for reviewers
The empty-identity hazard was already closed; this guard is for legibility, not safety. I checked rather than assumed: a key-pinned provenance has empty certificate fields, and
expectedIdentitywould build an identity from them. Sigstore rejects that outright —NewCertificateIdentity(certificate_identity.go:172) and againSANMatcher.Verify(line 121) both require SAN criteria — so it never became a match-anything policy. What was missing was a message naming the entry and the mismatch instead of "there must be subject alternative name criteria".errKeyPinnedEntrywrapsErrSignatureInvalidso existing classification is unchanged.Key parseability is enforced at the lock boundary. The pinned value is the entry's only trust anchor, so
x509.ParsePKIXPublicKeyruns during validation rather than leaving an unusable anchor to fail deep inside verification.validateProvenance's "purely syntactic" contract still holds — parsing DER is a well-formedness check, not a trust judgment.On the length bound:
publicKeypasses through the existingmaxReferenceLength(512) check. A P-256 SPKI is 124 chars, and core'sfileKeypairhardcodes ECDSA P-256 (GetSigningAlgorithm,GetKeyAlgorithm) — whatcosign generate-key-pairproduces. Only an RSA-4096 key would exceed the bound, and core would mislabel that as ECDSA regardless, so it is already unsupported upstream.TestProvenanceConversionsPreserveEveryFieldneeded restructuring, and satisfying it the obvious way would have been wrong. That reflection guard requires every field of both provenance shapes to be non-zero in its fixture. AddingpublicKeyto the single existing fixture would have produced identity and key on one struct — exactly the state this PR teaches validation to reject — and made an impossible lock entry the canonical example future readers copy. Instead the fixture is split into two legal ones (keyless, key-pinned), each round-tripped independently, and the helper becamerequireEveryFieldCovered, asserting each field is non-zero in at least one fixture. The guard keeps its full strength: a newly added field still has to appear somewhere.Plugin rendering is not here.
ai_plugin_info.goonmainrenders no provenance at all — that arrives with #6438 — so there was nothing to modify. It lands with the plugin mirrors, PRs 5–6.Generated with Claude Code