Require a discovery capability for key signing - #6560
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6560 +/- ##
==========================================
- Coverage 78.89% 78.83% -0.06%
==========================================
Files 778 779 +1
Lines 77598 77641 +43
==========================================
- Hits 61219 61209 -10
- Misses 16374 16427 +53
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Context for reviewers: this came out of the review on #6528, which restores Worth a second opinion on two judgement calls, both argued in the PR body:
|
JAORMX
left a comment
There was a problem hiding this comment.
Fresh review of exact head 11c47dd against main 5b66987.\n\nThe private-key signing-oracle fix is sound. The capability is independently generated with crypto/rand, written only to the owner-protected discovery record, never disclosed by /health, and checked in constant time before the key-bearing skills push reaches the service. It fails closed for missing, wrong, or unconfigured capability; a loopback or IPC-facing backend is deliberately not treated as authorization, covering the reverse-proxy bypass.\n\nDiscovery-derived clients attach the capability only for --key pushes, never inherit it for TOOLHIVE_API_URL/manual endpoints, reject unsafe plaintext remote transport, and suppress redirects so the bearer credential cannot be replayed. Existing keyless and unsigned pushes remain unaffected. Architecture, API/transport, UX, and security panel checks found no merge-blocking issue.\n\nNo blocking findings. Exact-head CI is green (the superseded cancelled size check is non-actionable) and is reported separately from this approval.
JAORMX
left a comment
There was a problem hiding this comment.
Panel review of 11c47dda071c431af4bb3aab688ef4deafab3853 against main 5b669870017ee4b1ea39ea1b95a03f4c5b47aeab.
Blocking — the 403 remediation names an API field, not a usable CLI option
pkg/api/v1/key_signing_transport.go:63-67 directs a rejected key-signing push to use identity_token. The CLI relays this server response unchanged (pkg/skills/client/client.go:451-452), but its supported option is --identity-token (cmd/thv/app/skill_push.go:43-47). A thv skill push --key user who follows the error is sent to a nonexistent option/argument rather than a working safe path. Use CLI-valid wording such as --identity-token / omit --key for automatic keyless signing, while documenting the JSON identity_token field separately for direct API consumers.
Non-blocking — exported discovery struct change is source-incompatible
Adding KeySigningCapability in the middle of exported discovery.ServerInfo (pkg/server/discovery/discovery.go:31-52) breaks downstream unkeyed composite literals. Consider retaining the public struct's shape and serializing the capability through an internal discovery-file DTO or a backward-compatible accessor; otherwise document the package-level breaking change and migration.
The security, architecture, protocol, reuse, and duplication reviewers found the authorization boundary itself sound: the capability is generated independently, restricted to discovery, constant-time checked, withheld from health/logs, and protected from insecure/redirected transport. CI is green at this head.
|
Both points taken; the blocker is fixed in 668364a (now 9d52481 after the stack was rebased onto current Blocking — 403 named the JSON fieldAgreed, and the mechanism was exactly as you describe: the CLI relays the server body verbatim, so
"Drop Non-blocking —
|
POST /api/v1beta/skills/push accepts a cosign private-key path that THIS process opens, so an arbitrary caller could have the server sign and publish an artifact with any key it can read — a signature indistinguishable from a legitimate release. The default API mode assigns every request a synthetic local identity with no credential check, and a non-loopback bind gets no Origin allowlist either, so an unauthenticated remote caller reaches the handler. Accept a key only from a caller on this machine: an IPC listener vouches for its peer, and a TCP peer must be loopback. Anything else fails closed and is pointed at keyless signing, whose credential is short-lived and scoped. The decision rests on the listener and on RemoteAddr, never on a header. A key was never reliably usable remotely anyway: the path has to exist on the server's filesystem. Signed-off-by: Samuele Verzi <samu@stacklok.com>
Signed-off-by: Samuele Verzi <samu@stacklok.com>
The 403 for a key-bearing push without the discovery capability told the caller to sign keylessly "with identity_token". That is the JSON request field, and the CLI relays the server body verbatim, so a user of thv skill push --key was pointed at an option that does not exist. Offer the two runnable paths first (drop --key, or --identity-token) and keep the request field for direct API callers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
668364a to
9d52481
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Follow-up panel review completed against 9d5248151d36df661b923b5b63caf82e92ce9b27.
The prior blocker is resolved: the 403 path now names the runnable CLI alternatives (--identity-token or dropping --key) while retaining identity_token guidance for direct API callers, and tests cover all three terms. Security and API panel checks found no new merge blockers in the capability generation, discovery-only delivery, constant-time enforcement, or redirect/transport protections.
Exact-head CI is green.
Summary
POST /api/v1beta/skills/pushacceptskey, a path to a cosign private key that thethv serveprocess opens and signs with. Without independent authorization, the management API can become a signing oracle: an untrusted caller can ask the server to publish an artifact signed by any readable key.Checking only the backend peer address is not sufficient. A public reverse proxy can forward an untrusted request over loopback or a UNIX socket, making the caller appear local to the API server.
This change:
/healthintentionally exposes.TOOLHIVE_API_URLand manually constructed clients do not inherit it./health, is refused over an unprotected remote transport, and is never followed across redirects.This was found while reviewing #6528, which restores the same
--keypath for plugins. The plugin branch will need to adopt the same capability after rebasing.Type of change
Test plan
task test)task test-e2e)task lint-fix)task docs)Regression coverage includes fail-closed capability checks, a real reverse proxy whose backend sees a loopback peer, discovery round trips, health-header non-disclosure, client header scoping, and redirect refusal.
Changes
Does this introduce a user-facing change?
Yes.
thv skill push --keynow requires the automatically discovered local ToolHive server. A key-bearing push throughTOOLHIVE_API_URL, a manually constructed client, or a caller without the protected discovery capability receives 403. Keyless signing with an OIDC identity token and unsigned pushes are unchanged. The 403 body names the runnable alternatives: drop--key(the CLI acquires an identity token itself) or pass--identity-token; direct API callers are told to sendidentity_tokenin place ofkey.Special notes for reviewers
/healthreturns it publicly for instance verification.request.keyis set.pkg/server/discovery:ServerInfogains aKeySigningCapabilityfield. Every literal in this repo is keyed, andgo vet'scompositescheck already rejects unkeyed literals of a struct imported from another package, so any downstream code this could break is codego vetrefuses today. A separate discovery-file DTO would keep the exported shape byte-identical but would also mean a secondWrite/Readsignature to thread the capability through; I judged that a worse trade for a field addition. Migration, if anyone does hit it: switch the literal to keyed fields.identity_token", the JSON field, while the CLI relays the server body verbatim and only knows--identity-token. Reworded in 9d52481 (originally 668364a before the rebase onto currentmain) so a terminal user gets a runnable path first; the test now pins--identity-token,drop --key, andidentity_tokenall appearing.🤖 Generated with Claude Code