Skip to content

Record a pinned cosign key in the lock schema - #6444

Merged
samuv merged 3 commits into
mainfrom
t3code/key-verify/02-lock-public-key
Aug 31, 2026
Merged

Record a pinned cosign key in the lock schema#6444
samuv merged 3 commits into
mainfrom
t3code/key-verify/02-lock-public-key

Conversation

@samuv

@samuv samuv commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #6443. Review that one first.

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.
  • Exactly one anchor per entry, enforced by validateProvenanceAnchor: a keyless certificate identity, or a public key. Certificate-derived fields (repositoryUri, repositoryRef, runnerEnvironment, sigstoreUrl) are refused on a key-pinned entry.
  • Keyless verification paths refuse a key-pinned entry up front, with a message that names the mismatch — VerifyOCI, VerifyBundleOffline, and VerifyGit.
  • The pinned value is parsed as a DER SPKI public key, not merely decoded as base64.
  • A key-pinned git entry is rejected: a key pair signs an OCI artifact, while a git entry's signature lives on the commit and is always certificate-based.
  • ProvenanceInfo.PublicKey plus both lock↔API conversions, and distinct skill info / skill install rendering.

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 — signatureAnnotations defines 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:35 compares lf.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

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Full unit suite passes for every package touched. One unrelated failure persists — pkg/transport/proxy/streamable's TestMCPGoClientInitializeAndPing, caused by a stray local process holding :8096; confirmed pre-existing on clean main and untouched here. task lint-fix reports issues only in cmd/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

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

No operator API surface. The docs/server/* churn is the additive public_key field on the swagger-documented provenance shape.

Changes

File Change
pkg/skills/lockfile/lockfile.go Provenance.PublicKey; anchor semantics documented on the type
pkg/skills/lockfile/validation.go validateProvenanceAnchor — identity XOR key, base64, certificate fields refused
pkg/skills/verifier/errors.go errKeyPinnedEntry, wrapping ErrSignatureInvalid
pkg/skills/verifier/types.go keyPinnedExpectation
pkg/skills/verifier/oci.go, offline.go Both keyless paths refuse a key-pinned entry
pkg/skills/options.go ProvenanceInfo.PublicKey
pkg/skills/skillsvc/verify.go Both provenance conversions carry it
cmd/thv/app/skill_info.go, skill_install.go Distinct rendering for key-pinned entries
docs/arch/12-skills-system.md Anchor exclusivity, why the full key, why no version bump
docs/server/* Regenerated (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 by skill info, rather than being rejected for a missing signerIdentity.

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 expectedIdentity would build an identity from them. Sigstore rejects that outright — NewCertificateIdentity (certificate_identity.go:172) and again SANMatcher.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". errKeyPinnedEntry wraps ErrSignatureInvalid so existing classification is unchanged.

Key parseability is enforced at the lock boundary. The pinned value is the entry's only trust anchor, so x509.ParsePKIXPublicKey runs 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: publicKey passes through the existing maxReferenceLength (512) check. A P-256 SPKI is 124 chars, and core's fileKeypair hardcodes ECDSA P-256 (GetSigningAlgorithm, GetKeyAlgorithm) — what cosign generate-key-pair produces. Only an RSA-4096 key would exceed the bound, and core would mislabel that as ECDSA regardless, so it is already unsupported upstream.

TestProvenanceConversionsPreserveEveryField needed 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. Adding publicKey to 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 became requireEveryFieldCovered, 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.go on main renders 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

@github-actions github-actions Bot added the size/S Small PR: 100-299 lines changed label Aug 27, 2026
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.04%. Comparing base (450ba5f) to head (ea61f12).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@samuv
samuv force-pushed the t3code/key-verify/02-lock-public-key branch from 61a9f43 to f28ca44 Compare August 27, 2026 09:31
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed size/M Medium PR: 300-599 lines changed and removed size/S Small PR: 100-299 lines changed size/M Medium PR: 300-599 lines changed labels Aug 27, 2026
@samuv samuv self-assigned this Aug 27, 2026
JAORMX
JAORMX previously approved these changes Aug 31, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Base automatically changed from t3code/key-verify/01-classify-key-signed to main August 31, 2026 08:32
@samuv
samuv force-pushed the t3code/key-verify/02-lock-public-key branch from 51799aa to b8d1713 Compare August 31, 2026 08:32
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Aug 31, 2026
Comment thread pkg/skills/lockfile/validation.go Outdated

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@samuv
samuv force-pushed the t3code/key-verify/02-lock-public-key branch from b8d1713 to 1402640 Compare August 31, 2026 15:54
samuv added 3 commits August 31, 2026 18:21
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>
@samuv
samuv force-pushed the t3code/key-verify/02-lock-public-key branch from 1402640 to ea61f12 Compare August 31, 2026 16:26
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Aug 31, 2026
Comment thread pkg/skills/lockfile/validation.go

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@samuv
samuv merged commit d1d76c3 into main Aug 31, 2026
59 of 60 checks passed
@samuv
samuv deleted the t3code/key-verify/02-lock-public-key branch August 31, 2026 17:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants