Verify key-signed skill installs against a public key - #6447
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6447 +/- ##
==========================================
- Coverage 78.11% 78.07% -0.04%
==========================================
Files 767 768 +1
Lines 74395 74551 +156
==========================================
+ Hits 58110 58205 +95
- Misses 16280 16341 +61
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
JAORMX
left a comment
There was a problem hiding this comment.
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.
5201699 to
d9b0cb4
Compare
JAORMX
left a comment
There was a problem hiding this comment.
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.
d9b0cb4 to
7c97d12
Compare
7c97d12 to
3b16c8d
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Re-reviewed the force-updated head against its declared stacked base across Spec, Standards, Security, Architecture, UX, DevEx, and Reuse. No new duplicate inline comments were added. The two existing blockers remain in the current diff: online key verification is not bound to the requested artifact digest (discussion_r3892856253), and the missing-public-key error/help/docs still direct users to republish keylessly instead of using --public-key (discussion_r3895952200). CI is green.
3b16c8d to
4bf2c2a
Compare
4bf2c2a to
3b16c8d
Compare
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>
b4347db to
33986b2
Compare
Key-pair verification accepted a signature that was never made for the artifact being installed. Signature manifests are discovered by a tag derived from the digest under verification, and core binds each candidate to the digest of the layer it was retrieved from — which proves the signature covers that blob intact, but says nothing about which artifact the blob describes. The artifact is named only inside cosign's simple-signing payload, and nothing read it. So a valid signature was transplantable. Copying artifact A's signature layer into "sha256-<B>.sig" made it discoverable as B's, and it still verified: B was then accepted under whatever key legitimately signed A, with no key compromise and no forgery anywhere. A registry-side attacker able to push a tag was enough. Reconstruct the payload digest from the requested reference and require each candidate to sign exactly those bytes. Comparing digests rather than parsing the payload keeps the check on the bytes that were actually signed — a payload edited to name B no longer hashes to the layer digest the signature verified against. The offline path already worked this way; this brings the online one into line with it. Report attached-but-unbound material distinctly from unsigned. The artifact does carry signature material, and calling it unsigned would invite an --allow-unsigned override for what is really a rejected trust claim. The keyless path shares the same layer-digest binding and needs the same fix, but not the same check: attestation bundles derive their digest elsewhere, so they need a distinction this path does not. Left for a separate change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Install-time verification of key-pair signatures now exists, but the errors and docs still described the world before it. A first install of a key-signed skill that omitted --public-key fell through the keyless path and was told key-pair signatures cannot be verified and to republish keylessly — sending the caller away from the feature that would have installed it. The push help and architecture docs repeated the same advice. Direct that caller to --public-key instead, and split the message by what the entry pins. Where a keyless identity is already pinned, --public-key is NOT the remedy: a key pair carries no certificate identity that could satisfy the pin, so it is refused rather than allowed to displace it, and naming the flag would walk the caller into that conflict one step later. That arm names the pinned signer and says re-anchoring means removing the entry. Drop the remedy from ErrKeySigned itself. The sentinel is shared by the skills and plugins surfaces, which no longer offer the same one — plugins has no --public-key yet — so it states the situation and leaves the advice to the caller that knows the surface and the entry. Document the trust boundary the verification rests on: dispatch is decided by the lock entry rather than the artifact, and a signature is bound to the artifact its payload names rather than to the manifest it happens to be attached to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
33986b2 to
56fa28e
Compare
Summary
A skill pushed with
thv skill push --keycould 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-keyonthv skill install. The CLI reads thecosign.pubfile 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.allow_signer_change— is an error. Silently preferring either anchor is how a mistyped--public-keyinstalls 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'sErrKeySigned: 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).Part of #6442. Scoped to install;
--public-keyonupgrade/sync --adoptfollows in the next PR.Two fixes this exposed
publicKeyshared 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.validateProvenanceran 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.Review fixes
Key verification was not bound to the artifact being installed (CWE-345/347, raised by @JAORMX). The signature covers cosign's simple-signing payload, and
RetrieveBundlesbinds each candidate to the digest of the layer it came from — which proves the signature covers that blob intact, but says nothing about which artifact the blob describes. Signature manifests are discovered by a tag derived from the digest under verification, so attachment was the only link, and it is forgeable by anyone who can push a tag: copying artifact A's signature layer intosha256-<B>.sigmade it discoverable as B's, and it still verified. B was then accepted under whatever key legitimately signed A — no key compromise, no forgery.VerifyOCIWithKeynow reconstructs the payload digest from the requested reference and requires each candidate to sign exactly those bytes. Comparing digests rather than parsing the payload keeps the check on the bytes that were actually signed: a payload edited to name B no longer hashes to the layer digest the signature verified against. The offline path already worked this way. Attached-but-unbound material is reported distinctly from unsigned, so it cannot invite an--allow-unsignedoverride for what is really a rejected trust claim.The regression test transplants a real signature between two artifacts in an in-process registry. Without the fix it returns a nil error — the hole was live, not theoretical.
Missing-key guidance pointed away from this feature (also @JAORMX). A first install that omitted
--public-keywas told key-pair signatures cannot be verified and to republish keylessly. That is now split by what the entry pins, because the obvious advice is wrong in one of the two cases: with no anchor recorded,--public-keyis exactly the missing input; with a keyless identity pinned,resolveKeyAnchorrefuses a supplied key rather than letting it displace the pin, so naming the flag would walk the caller into a conflict one step later. That arm names the pinned signer and says re-anchoring means removing the entry. The remedy is gone fromErrKeySigneditself, which is shared with the plugins surface where--public-keydoes not exist yet.Type of change
Changes
pkg/skills/verifier/publickey.goEncodePublicKey/DecodePublicKeybetweencosign.puband the stored base64 DER SPKIpkg/skills/verifier/oci.go,errors.goErrKeylessSignedand theonlyKeylessSigneddiagnosispkg/skills/skillsvc/verify.goresolveKeyAnchor, the key verification branch, conflict refusals, key-path classificationpkg/skills/skillsvc/install.gopkg/skills/lockfile/validation.gopublicKey; syntactic checks before the anchor decodepkg/skills/options.go,pkg/api/v1/skills*.go,pkg/skills/client/*PublicKeythrough the option, DTO, and clientcmd/thv/app/skill_install.go--public-keyflag and PEM readpkg/skills/verifier/oci.gobundleSignsPayload: binds each key-path candidate to the reconstructed payload digestpkg/skills/verifier/errors.goErrKeySignedstates the situation and prescribes no remedy (shared with plugins)cmd/thv/app/skill_push.go,docs/--keyhelp and architecture docs describe the supported install flowTest plan
task testpasses with no failurestask lint-fixclean for every file touched (the 6 remainingstaticcheckhits are pre-existing, in untouchedcmd/thv-operator/files)task docsregeneratedNew 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;onlyKeylessSignedincluding the invariant that it andonlyKeySignednever both hold.Does this introduce a user-facing change?
Yes.
thv skill install --public-key <cosign.pub>installs a skill signed withthv 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_changecannot 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_unsignedis 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.Size. 510 insertions across 14 files of production code, over the repo's 400-line/10-file guideline; 423/13 of that is the original scope and the rest is the two review fixes above. Splitting the
ErrKeylessSigneddiagnosis out would fit the cap but would ship the key path without the message for its most common failure, and the binding fix is not separable from the path it protects. Happy to split if you'd prefer.Overlap with toolhive-core#263. That PR fixes the same binding gap at the source and is still open; v0.0.42 is the latest release, so this closes the hole at the version we actually depend on. #263 inverts the contract this rests on —
Bundle.DigestHexbecomes the artifact digest — so on bump the comparison can never hold andTestVerifyOCIWithKeyRoundTripfails with it. That tripwire is deliberate, and the code says to delete the helper rather than loosen the comparison.The keyless path shares the same gap.
VerifyBundlebinds to the same layer digest, so this predates the PR; activating the key path only newly exposes it. Not fixed here because attestation bundles derive their digest elsewhere and need a distinction the key path does not — worth its own change, and #263 may land first.Generated with Claude Code