Skip to content

Verify key-signed plugin installs against a public key - #6524

Merged
samuv merged 1 commit into
mainfrom
samuv/plugins-install-public-key
Sep 8, 2026
Merged

Verify key-signed plugin installs against a public key#6524
samuv merged 1 commit into
mainfrom
samuv/plugins-install-public-key

Conversation

@samuv

@samuv samuv commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

A cosign key-pair-signed plugin artifact cannot be installed project-scoped today, and the failure has no escape hatch: retrieveBundles finds the signature material so ErrUnsigned never fires, keyless verification then fails for want of a Fulcio chain, and --allow-unsigned refuses to help because the artifact genuinely is signed. Skills fixed this on the install side in #6447; plugins have had no key surface at all — no install option, no API field, no verify branch. Per the parity decision on #6442, this transliterates the skills install-side key path onto pkg/plugins.

  • Install can now supply the anchor. plugins.InstallOptions.PublicKey, installPluginRequest.public_key, the Go client's installRequest.public_key, and thv ai-plugin install --public-key (reusing skills' readInstallPublicKey, which reads the PEM and sends key material — a path would not resolve on a server in another process).
  • Dispatch is lock-first, and conflicts are errors. resolveKeyAnchor picks the key path from what the lock records, so a supplied key can confirm a pin but never replace it; a key against a keyless-pinned entry, against an unsigned: true entry, against a different pinned key, or combined with allow_signer_change is refused rather than silently ignored. Silently preferring one of two conflicting anchors is how a mistyped --public-key installs as though it had been honored.
  • Refusals now say something true. validateInstallPublicKey rejects a key on a non-project-scope install and a malformed key as bad input (400), before anything is fetched. classifyKeyVerifyError names the keyless-artifact case (drop the key) separately from "wrong key or damaged signature", which are genuinely indistinguishable because the bundle records no key of its own. The ErrKeySigned arm no longer says "re-publish it with keyless signing" — with nothing pinned it names --public-key; with a keyless identity pinned it explains that a key cannot satisfy that pin and the entry must be removed and reinstalled. allow_unsigned is stated as no remedy throughout: the artifact is signed, and recording it as an unsigned exception would file a false trust decision in the lock.
  • Git and local installs refuse a key instead of accepting and dropping it — a commit signature is checked against a certificate, and a local build carries no registry signature at all.
  • A key-pinned entry renders as one. provenanceInfoFromLock carries PublicKey, and both thv ai-plugin install and thv ai-plugin info gained the key-pinned case ahead of the identity cases; today either would print signed by "". lockedAnchorDescription does the same for the local-build refusal message.

Scope is install-only, identical to skills v1: no --public-key on upgrade or sync, and no in-place re-anchor (uninstall, then reinstall with the new key).

Part of #6442

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)

Unit tests mirror what skills shipped (there is no E2E for the key path on either surface). Invariants pinned explicitly: --allow-unsigned does not rescue ErrKeySigned; a key on a user-scope install and a malformed key are 400 before any fetch; a supplied key conflicting with a keyless pin, an unsigned exception, or a different pinned key is 403 and reaches no verifier (the mock carries no expectations, so a silently-ignored conflict shows up as an unexpected keyless call); key + allow_signer_change is 400; first-use installs pin Provenance.PublicKey and capture the bundle; a lock-pinned key is used with no flag supplied; ErrKeylessSigned under a key names "install without a public key"; git and local installs refuse a key; and public_key round-trips through the CLI flag, client DTO, and API handler.

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.

Changes

File Change
pkg/plugins/options.go InstallOptions.PublicKey
pkg/api/v1/plugins_types.go, plugins.go public_key on the install request, forwarded to InstallOptions
pkg/plugins/client/dto.go, client.go carry public_key so the CLI flag reaches the server
pkg/plugins/pluginsvc/verify.go validateInstallPublicKey, resolveKeyAnchor, verifyOCIInstallWithKey, classifyKeyVerifyError, keySignedInstallError, lockedAnchorDescription; git/local key refusals
pkg/plugins/pluginsvc/install.go validate a supplied key at the single install entry point, before any resolve or fetch
cmd/thv/app/ai_plugin_install.go --public-key; key-pinned install output
cmd/thv/app/ai_plugin_info.go key-pinned trust rendering, extracted into printAIPluginTrustState (the added cases pushed the renderer over the gocyclo limit)
docs/cli, docs/server task docs output

Does this introduce a user-facing change?

Yes. thv ai-plugin install --public-key <cosign.pub> installs a plugin signed with a cosign key pair into a project, pinning the key in the lock entry so later installs need no flag. Refusals that previously read as opaque signature failures now name the input that fixes them. Nothing changes for keyless or unsigned installs.

Special notes for reviewers

  • Two PRs follow. PR 2 mirrors Honour a pinned cosign key on sync and upgrade #6478: a key branch in verifyStoredSignature (offline re-verify via VerifyBundleOfflineWithKey), adoptionTrust refusing ErrKeySigned with an actionable message, and the keyed signer-change transitions on upgrade. PR 3 restores --key on thv ai-plugin push and is the one that closes --key push signing has no install-time verification path (plugins) #6442 — restoring the publish flag any earlier would recreate exactly the publish-only dead end the issue was opened about.
  • Until PR 2 lands, a key-pinned plugin is reported as drift by thv ai-plugin sync. verifyStoredSignature still hands entry.Provenance to the keyless VerifyBundleOffline, which cannot check a key-pair bundle. That is expected, is the sync half of the same feature, and is what PR 2 fixes. A lock-driven reinstall is already correct: it goes through verifyOCIInstall, so it verifies against the pinned key.
  • No catalog arm in resolveKeyAnchor, unlike skillsvc: a plugin install presents no catalog-declared provenance expectation, so first use has nothing but the supplied key to weigh.
  • docs/arch/14-plugins-system.md still documents plugin signing as keyless-only. The trust-model rewrite belongs with PR 3, so the doc flips when the feature is complete rather than describing a half-built path.
  • TestAIPluginPushSigningFlags still asserts --key is absent from push; only its comment moved, to point at PR 3 rather than at "the change that makes key verification work".

Generated with Claude Code

@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Sep 7, 2026
@samuv samuv self-assigned this Sep 7, 2026
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.96552% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.61%. Comparing base (4f26111) to head (9ceccbb).

Files with missing lines Patch % Lines
pkg/plugins/pluginsvc/verify.go 93.75% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6524      +/-   ##
==========================================
+ Coverage   78.57%   78.61%   +0.03%     
==========================================
  Files         776      776              
  Lines       76516    76626     +110     
==========================================
+ Hits        60122    60238     +116     
+ Misses      16389    16383       -6     
  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.

Plugins had no key surface at all: a cosign key-pair-signed artifact
could only fail install-time verification, and `--allow-unsigned` could
not rescue it because the artifact is genuinely signed. Skills gained
the install-side key path in #6447; this transliterates it onto plugins,
so a key-signed plugin can be installed project-scoped by supplying the
matching public key, which is then pinned in the lock entry and reused.

The key is the only trust anchor a key-pair bundle can have — no
certificate, no transparency log — so it has to come from outside the
artifact, and dispatch is lock-first: a key-pinned entry verifies
against the key the lock records, and every disagreement with a supplied
key is refused rather than resolved by precedence.

Scope is install-only, matching skills v1: no `--public-key` on upgrade
or sync, and no in-place re-anchor.

Part of #6442

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

Signed-off-by: Samuele Verzi <samu@stacklok.com>
@samuv
samuv force-pushed the samuv/plugins-install-public-key branch from e9c23dd to 9ceccbb Compare September 8, 2026 08:21
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Sep 8, 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 9ceccbb against 4f26111. The install-time public-key trust-anchor path, lock-first conflict handling, and API/CLI propagation are sound. The key-aware sync/upgrade work and trust-model documentation are explicitly and safely deferred to stacked #6525 and #6528; #6524 alone does not re-enable key signing. No blocking findings.

@samuv
samuv merged commit 1e80f9d into main Sep 8, 2026
48 checks passed
@samuv
samuv deleted the samuv/plugins-install-public-key branch September 8, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

--key push signing has no install-time verification path (plugins)

2 participants