Skip to content

Verify key-signed skill installs against a public key - #6447

Draft
samuv wants to merge 2 commits into
t3code/key-verify/02-lock-public-keyfrom
t3code/key-verify/03-skills-install-public-key
Draft

Verify key-signed skill installs against a public key#6447
samuv wants to merge 2 commits into
t3code/key-verify/02-lock-public-keyfrom
t3code/key-verify/03-skills-install-public-key

Conversation

@samuv

@samuv samuv commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

A skill pushed with thv skill push --key could be published but never installed project-scoped. The keyless path has no trust root to chain a cosign key-pair signature to, and the signing key is recoverable from neither the artifact nor its attached bundle — cosign's manifest defines no annotation carrying it — so nothing could supply the trust anchor. #6443 made that refusal honest and #6444 gave the lock file somewhere to record a key; this PR gives the install a way through.

  • --public-key on thv skill install. The CLI reads the cosign.pub file and sends the encoded key material, not the path: the server may be a different process on a different host, where that path names nothing — or something else. Required on true first use, then pinned into the lock entry, which supplies it on every install thereafter. Trust on first use, with the anchor named explicitly because it cannot be observed.
  • Dispatch is lock-first. The recorded expectation picks the verification path, never the artifact. Were the artifact allowed to select its own policy, a republished key-signed artifact could walk an entry out of the certificate identity it is pinned to.
  • Conflicts are refused, not resolved by precedence. A supplied key that disagrees with the lock — a different key, an identity pin, a recorded unsigned exception, a catalog-declared identity, or allow_signer_change — is an error. Silently preferring either anchor is how a mistyped --public-key installs as though it had been honored. v1 offers no in-place re-anchor, and each refusal says so.
  • ErrKeylessSigned, the mirror of Report key-signed artifacts as such at install #6443's ErrKeySigned: a key aimed at a keylessly-signed artifact is the likeliest way to reach this path by mistake, and a bare "signature verification failed" hides the remedy (drop the key).
  • Git and local-build installs refuse a public key outright — a commit signature is made with a Fulcio certificate, and a local build carries no registry signature at all.

Part of #6442. Scoped to install; --public-key on upgrade/sync --adopt follows in the next PR.

Two fixes this exposed

  • publicKey shared the 512-character reference bound, but an RSA-4096 SPKI encodes to 736 — the bound would have rejected a legitimate anchor rather than the oversized garbage it guards against. It now has its own.
  • validateProvenance ran the anchor's base64 decode before the length check, sizing the allocation off an unchecked value from a hand-editable file. The syntactic checks now run first.

Type of change

  • New feature (non-breaking change which adds functionality)

Changes

File Change
pkg/skills/verifier/publickey.go New. EncodePublicKey/DecodePublicKey between cosign.pub and the stored base64 DER SPKI
pkg/skills/verifier/oci.go, errors.go ErrKeylessSigned and the onlyKeylessSigned diagnosis
pkg/skills/skillsvc/verify.go resolveKeyAnchor, the key verification branch, conflict refusals, key-path classification
pkg/skills/skillsvc/install.go Entry guard: a key this install could never use is bad input
pkg/skills/lockfile/validation.go Own bound for publicKey; syntactic checks before the anchor decode
pkg/skills/options.go, pkg/api/v1/skills*.go, pkg/skills/client/* PublicKey through the option, DTO, and client
cmd/thv/app/skill_install.go --public-key flag and PEM read

Test plan

  • Unit tests added/updated
  • task test passes (pre-existing unrelated failure only: TestMCPGoClientInitializeAndPing, whose :8096 is held by a stray process)
  • task lint-fix clean for every file touched (the 6 remaining staticcheck hits are pre-existing, in untouched cmd/thv-operator/ files)
  • task docs regenerated

New coverage: PEM↔base64 round-trip and its rejections (private key refused by label, multi-block, non-SPKI, over-bound); every arm of resolveKeyAnchor; the entry guard; key-path error classification; the git and local-build refusals; onlyKeylessSigned including the invariant that it and onlyKeySigned never both hold.

Does this introduce a user-facing change?

Yes. thv skill install --public-key <cosign.pub> installs a skill signed with thv skill push --key. The key is recorded in the lock file and reused on later installs; supplying a conflicting one is refused rather than ignored.

Special notes for reviewers

Why the key must be supplied rather than observed. A key-pair bundle carries no certificate and writes no Rekor entry, so there is nothing to read the signing key off. That makes key provenance a weaker claim than keyless — it says the holder of this key signed this artifact, nothing about who that holder is — which is why the anchor has to come from outside the artifact every time, and why allow_signer_change cannot re-anchor it (re-recording "what was observed" would mean re-recording whatever the caller named).

Wrong key and damaged signature are genuinely indistinguishable. The bundle records no key of its own to compare against, so the error names both causes instead of guessing.

allow_unsigned is not a remedy on any arm of the key path. An install that named a public key asked for that key to be enforced; recording an unsigned exception would file a false trust decision in the lock.

This is ~420 lines across 12 files, a little over the repo's 400-line/10-file guideline. Splitting the ErrKeylessSigned diagnosis out would fit the cap but would ship the key path without the message for its most common failure. Happy to split if you'd prefer.

Generated with Claude Code

@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Aug 27, 2026
@samuv samuv changed the title t3code/key verify/03 skills install public key Verify key-signed skill installs against a public key Aug 27, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Aug 27, 2026
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.14286% with 11 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (t3code/key-verify/02-lock-public-key@ea61f12). Learn more about missing BASE report.

Files with missing lines Patch % Lines
pkg/skills/skillsvc/verify.go 93.87% 6 Missing ⚠️
pkg/skills/verifier/publickey.go 87.50% 3 Missing ⚠️
pkg/skills/skillsvc/install.go 50.00% 1 Missing ⚠️
pkg/skills/verifier/oci.go 87.50% 1 Missing ⚠️
Additional details and impacted files
@@                           Coverage Diff                           @@
##             t3code/key-verify/02-lock-public-key    #6447   +/-   ##
=======================================================================
  Coverage                                        ?   78.08%           
=======================================================================
  Files                                           ?      768           
  Lines                                           ?    74528           
  Branches                                        ?        0           
=======================================================================
  Hits                                            ?    58194           
  Misses                                          ?    16329           
  Partials                                        ?        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 self-assigned this Aug 27, 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.

The install path now reaches key verification, but the online verifier does not bind the verified signature payload to the artifact digest being installed. CI is green, but this trust-boundary issue must be fixed before merge.

http.StatusUnprocessableEntity,
)
}
result, verifyErr := s.artifactVerifier().VerifyOCIWithKey(ctx, ref, digest, pubKeyPEM)

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.

This newly activates VerifyOCIWithKey, but that method only verifies each retrieved bundle against b.DigestAlgo:b.DigestHex; for cosign this is the digest of the simple-signing payload layer, not the requested artifact digest. Unlike VerifyBundleOfflineWithKey, it never reconstructs signer.PayloadDigest(ref, digest) or otherwise checks that the signed payload names this artifact. A registry attacker can therefore copy artifact A's valid signature layer into artifact B's sha256-<B>.sig manifest and have B accepted under A's trusted key. Please bind each online candidate to the expected ref/digest before accepting it, and add a regression test that attaches A's signature to B and expects verification to fail. (CWE-345/CWE-347)

@samuv
samuv force-pushed the t3code/key-verify/03-skills-install-public-key branch from 5201699 to d9b0cb4 Compare August 31, 2026 08:32
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Aug 31, 2026
keyAnchor, err := resolveKeyAnchor(opts, skillName, expected, expectUnsigned, catalogExpected)
if err != nil {
return nil, err
}

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.

This enables the public-key verification path, but a true first install of a key-signed artifact that omits --public-key still falls through the keyless path and reaches the unchanged keySignedInstallError, which says key-pair signatures cannot be verified and tells the user to republish keylessly. The skill push --key help and architecture docs repeat that now-obsolete guidance. Please make this fallback tell the user to retry with --public-key, update the push help/docs to describe the supported flow, and regenerate the CLI docs; otherwise the most likely missing-anchor error directs users away from the feature added here.

@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 rebased head against its declared stacked base. The existing digest-binding blocker remains unresolved. I also found that the newly supported first-install flow still tells users who omit --public-key that key-pair signatures cannot be verified and should be republished keylessly; the push help and architecture docs repeat that stale guidance. Please direct them to --public-key and update the generated CLI/docs. CI has completed with no failing or pending checks.

@samuv
samuv force-pushed the t3code/key-verify/03-skills-install-public-key branch from d9b0cb4 to 7c97d12 Compare August 31, 2026 15:56
samuv added 2 commits August 31, 2026 18:26
The lock file stores a pinned key as single-line base64 DER SPKI because
provenance values must be graphic and whitespace-free, so PEM armor cannot
be stored verbatim. Nothing converted between the two forms yet, and the
field shared the reference length bound, which is narrower than the key
material it now has to hold.

Add EncodePublicKey/DecodePublicKey as the single conversion between a
cosign.pub file and the stored form. Encoding refuses a private key by its
PEM label rather than by whatever a PKIX parse makes of its bytes: the
result is sent over the API and written to the lock file, and neither is
somewhere private material should reach by accident. Decoding re-validates
rather than trusting its input, since the value arrives from a request body
or a hand-editable file and is the artifact's only trust anchor.

Give publicKey its own bound: an RSA-4096 SPKI encodes to 736 characters,
so the 512 reference limit would reject a legitimate anchor rather than the
oversized garbage these limits exist to stop. Run the syntactic field checks
before the anchor's decode, so the length is bounded by a checked number
instead of the allocation being sized off an unchecked one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Samuele Verzi <samu@stacklok.com>
A skill pushed with `thv skill push --key` could be published but never
installed project-scoped: the keyless path has no trust root to chain a
key-pair signature to, and the signing key is recoverable from neither the
artifact nor its bundle, so nothing could supply the anchor. The previous
change made that refusal honest; this one gives it a way through.

Add `--public-key` to `thv skill install`, carried to the service as encoded
key material rather than as a path, since the server may be another process
on another host where that path names nothing. The key is required on true
first use and pinned into the lock entry, which supplies it on every install
thereafter — trust on first use, with the anchor named explicitly because it
cannot be observed.

Dispatch is lock-first: the recorded expectation picks the verification path,
never the artifact. Were the artifact allowed to select its own policy, a
republished key-signed artifact could walk an entry out of the certificate
identity it is pinned to. Every disagreement between a supplied key and the
recorded state is refused rather than resolved by precedence, so a mistyped
key cannot install as though it had been honored; v1 offers no in-place
re-anchor, and the refusals say so.

Report a key aimed at a keylessly-signed artifact as that, not as a failed
signature — the mirror of the key-signed diagnosis, and the likeliest way to
reach this path by mistake. Git and local-build installs refuse a public key
outright: a commit signature is made with a certificate, and a local build
has no registry signature at all.

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/03-skills-install-public-key branch from 7c97d12 to 3b16c8d Compare August 31, 2026 16:29
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants