Skip to content

Honour a pinned cosign key on sync and upgrade - #6478

Open
samuv wants to merge 3 commits into
t3code/key-verify/03-skills-install-public-keyfrom
t3code/key-verify/04-skills-sync-upgrade-key
Open

Honour a pinned cosign key on sync and upgrade#6478
samuv wants to merge 3 commits into
t3code/key-verify/03-skills-install-public-keyfrom
t3code/key-verify/04-skills-sync-upgrade-key

Conversation

@samuv

@samuv samuv commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

#6447 taught install to verify a key-signed skill and pin the key in the lock file. Nothing downstream read that pin back. This PR makes the lock-driven operations honour the anchor they already record — no new flag, no new trust decision, just the recorded key doing its job.

  • sync re-verified everything through the keyless path, which refuses a key-pinned entry outright. Sync reads a refusal as drift it can heal by reinstalling, so a key-pinned skill reported as modified on every run and never settled, and --check failed permanently on a project that was in fact intact. It now re-verifies against the pinned key offline.
  • upgrade could not advance a key-pinned entry at all. The signer-change guard probes the candidate for a certificate identity to compare, and a key-pair bundle has none — so the probe returned "key-signed" and the plan failed with a signature error naming none of the real situation. The apply step would have worked all along: it reads the anchor from the lock. Only the guard stood in the way.
  • sync --adopt attempted a key-signed install and failed opaquely. Adoption back-fills trust from what the bundle reveals; a key-pair bundle reveals no identity and does not carry the key. It is now refused with the route that can anchor it.

Part of #6442. --public-key remains install-onlyupgrade and sync take no key of their own, and in-place re-anchoring is still deliberately not offered.

Where the remedy had to be chosen carefully

SignerChangeBlocked makes the CLI print "use --allow-signer-change", so the status can only be used where that advice works. For a key-pinned entry it works when the candidate moved to keyless signing or lost its signature — dropping the recorded key and re-verifying keylessly is the key-to-keyless move resolveKeyAnchor supports. It does not work when the candidate is signed by a different key: --allow-signer-change drops the key, takes the keyless path, and hits ErrKeySigned one step later. That arm reports a failure naming the uninstall-and-reinstall route instead.

This is the same trap as the stale install-time guidance fixed in #6447 — a status that advertises a remedy the code then refuses.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Changes

File Change
pkg/skills/verifier/offline.go ResultFromBundle reports ErrKeySigned for a certificate-less bundle, decided from its shape rather than from a failed identity read
pkg/skills/skillsvc/sync.go verifyStoredKeySignature for key-pinned entries; describeLockAnchor; adoption refuses key-signed installs
pkg/skills/skillsvc/upgrade.go guardKeyedSignerChange: the pinned key applied to the candidate, blocked arms split by whether the printed remedy works
docs/arch/12-skills-system.md What the pinned key does on sync, upgrade and adopt, and what stays out of scope

Test plan

  • Unit tests added/updated
  • task test passes with no failures
  • task lint-fix clean for every file touched (the 6 remaining staticcheck hits are pre-existing, in untouched cmd/thv-operator/ files)

New coverage: the sync key branch including the reference fallback and the fail-closed case when no reference is recorded anywhere; every arm of the keyed upgrade guard, including that the keyless probe is never reached and that an undecodable pinned key fails rather than reporting a signer change; the adoption refusal, including that it writes no lock entry; and ResultFromBundle's classification against a genuinely key-signed bundle produced by the signer package.

Does this introduce a user-facing change?

Yes. A skill installed with --public-key now syncs, upgrades and reports correctly instead of showing permanent drift and refusing to upgrade. Adopting a key-signed install is refused with an actionable message rather than an opaque verification error.

Special notes for reviewers

Why the lock's reference, not the install record's. The offline key check reconstructs the signed payload from a reference, so that reference is part of what is being verified. The lock is the authority on what the project is pinned to; the install record is the thing being checked against it. It falls back to the install record only when the entry records no resolved reference, and fails closed when neither has one.

No new trust decision anywhere. Every path here consumes an anchor that a previous install already verified and recorded. The one place that would have needed a new one — adoption — refuses instead.

Production diff is 130 insertions across 3 files, inside the repo's guideline.

Generated with Claude Code

samuv and others added 3 commits August 31, 2026 23:11
Back-filling provenance from a stored bundle assumed the bundle carried a
certificate. A key-signed one does not: the trust anchor lives outside the
artifact, which is the whole shape of the key-pair flow. Handed one, the
keyless verification underneath failed on the absent certificate and reported
a broken signature — for a bundle that is perfectly intact and verifies fine
against its key.

Report it as ErrKeySigned instead, decided from the bundle's own shape rather
than from a failed attempt to read an identity out of it. Callers that need to
treat "there is nothing here to observe" differently from "this signature is
damaged" can now tell the two apart, which the next change needs to explain
itself to the user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Samuele Verzi <samu@stacklok.com>
Sync re-verified every stored bundle through the keyless path, which refuses a
key-pinned entry outright. Sync reads that refusal as drift it can heal by
reinstalling, so a key-pinned skill reported as modified on every run and never
settled, and `--check` failed permanently on a project that was in fact intact.
The trust anchor was recorded correctly and simply never consulted.

Re-verify against the pinned key instead, reconstructing the signed payload
from the lock's reference rather than the install record's: the lock is the
authority on what the project is pinned to, and the install record is the thing
being checked against it. A missing bundle now names the key as the anchor it
could not satisfy — the keyless wording named an empty signer identity, since
a key entry records none.

Adoption refuses a key-signed install rather than attempting it. Adoption
back-fills trust from what the bundle reveals, and a key-pair bundle reveals no
identity and does not carry the key, so there is nothing to record; sync takes
no public key of its own to supply one. Recording it as unsigned instead would
file a false trust decision about an artifact that is signed, so the refusal
names the one path that can anchor it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Samuele Verzi <samu@stacklok.com>
A key-pinned entry could not be upgraded at all. The signer-change guard probes
the candidate for a certificate identity to compare against the recorded one,
and a key-pair bundle has none — so the probe returned "this is key-signed" and
the plan failed with a signature error that named none of the real situation.
The apply step would have worked: it reads the anchor from the lock, so the
pinned key was there all along. Only the guard stood in the way.

Apply the pinned key to the candidate directly. Verifying against it IS the
evidence the signer has not changed, which is the same claim the keyless guard
makes by comparing identities. No new key is accepted here — the lock supplies
it, and `--public-key` remains install-only.

Split the blocked arms by whether the remedy the CLI prints actually works,
because it prints one. A candidate that moved to keyless signing or lost its
signature blocks as a signer change, and `--allow-signer-change` genuinely
resolves those: dropping the recorded key and re-verifying keylessly is the
supported key-to-keyless move. A candidate signed by a different key gets a
failure naming the uninstall-and-reinstall route instead, because re-anchoring
in place is not offered and pointing at `--allow-signer-change` would send the
caller into a refusal one step later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Samuele Verzi <samu@stacklok.com>
@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label Aug 31, 2026
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.18182% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 78.10%. Comparing base (56fa28e) to head (4db6dd3).

Files with missing lines Patch % Lines
pkg/skills/skillsvc/sync.go 96.15% 1 Missing ⚠️
Additional details and impacted files
@@                                Coverage Diff                                 @@
##           t3code/key-verify/03-skills-install-public-key    #6478      +/-   ##
==================================================================================
+ Coverage                                           78.07%   78.10%   +0.03%     
==================================================================================
  Files                                                 768      768              
  Lines                                               74551    74604      +53     
==================================================================================
+ Hits                                                58205    58270      +65     
+ Misses                                              16341    16329      -12     
  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 self-assigned this Aug 31, 2026
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.

1 participant