Ship the live-registration check both blocked tasks depend on
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 45s

KEY-WP-0013-T02 and KEY-WP-0014-T04 stay blocked on custody and on ops-warden,
but each contains a KeyCape-owned piece that had been left as prose. T02 requires
proving "live JWKS verification and denied excess scopes without logging values";
T04 step 4 requires verifying a rotated secret, refusing its predecessor and
refusing excess scope. Both were describable and neither was runnable, so the
proof would have been improvised by hand against production at the moment custody
lands -- the worst possible time for it.

keycape verify-client does it in one command. Per registration it checks
discovery origin, the client_credentials exchange and its RS256 signature against
the deployed JWKS, exact sub/tenant/roles/principal_type, that every -deny-scope
is refused, and that the token carries no scope that was not requested. That last
check is a real gap: the caller commands prove every requested scope was granted,
never that nothing extra came back. -previous-secret-env additionally requires
the predecessor to be refused, and treats an unchanged secret as a rotation that
did not happen.

Nothing is written to disk and no value is printed. Failures name the claim, not
the observed value, so running this against production cannot turn a verification
into a disclosure. Every check runs before it reports, so one failure does not
hide the rest.

The exact invocation for each approval client is recorded in the verification
block of the provisioning packet, so custody admission hands back a command
rather than a description.

Tests cover the passing case, an over-broad registration, a live predecessor, an
identical rotation and output non-disclosure; neutering mustFail makes the suite
fail, so the checks have teeth.

Also recorded in KEY-WP-0014: read from ops-warden's catalog rather than waiting
for a reply, key-cape-oidc-login is asked-and-waiting on us since 2026-08-28 and
is a pointer lane with no programmatic consumers, and the rapp-qonto-keycape-client
blocker citing an absent native exchange command went stale when service-token
shipped on 2026-09-05.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV8zoCKpA1WRAxsKRYbdH

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1182213@bnt-lap001
Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
This commit is contained in:
tegwick 2026-09-08 14:30:43 +02:00
parent 54b7687903
commit 2a8735173b
8 changed files with 525 additions and 4 deletions

View file

@ -22,9 +22,12 @@ import (
// Run executes a caller command. Output contains only instructions or status.
func Run(ctx context.Context, args []string, stderr io.Writer) error {
if len(args) == 0 {
return errors.New("expected login or service-token")
return errors.New("expected login, service-token or verify-client")
}
mode := args[0]
if mode == "verify-client" {
return verifyClient(ctx, args[1:], stderr)
}
if mode != "login" && mode != "service-token" {
return errors.New("unknown authentication command")
}

View file

@ -0,0 +1,231 @@
package authclient
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/url"
"os"
"sort"
"strings"
"time"
)
// verifyClient proves a live service registration without disclosing anything.
//
// It exists because two blocked tasks need the same evidence and neither can
// produce it by hand safely. KEY-WP-0013-T02 must "prove live JWKS verification
// and denied excess scopes without logging values" once custody materializes the
// approval client secrets, and KEY-WP-0014-T04 step 4 must verify a rotated
// secret, reject its predecessor and deny excess scope before a rotation is
// declared complete. The custody owner performs the writes; this is the check
// they can run afterwards.
//
// Nothing is written to disk and no token, secret or claim value is ever
// printed. A mismatch reports the claim name only: an operator running this
// against production must not turn a verification into a disclosure.
func verifyClient(ctx context.Context, args []string, stderr io.Writer) error {
fs := flag.NewFlagSet("verify-client", flag.ContinueOnError)
fs.SetOutput(stderr)
issuer := fs.String("issuer", "", "HTTPS issuer")
id := fs.String("client-id", "", "registered client ID")
audience := fs.String("audience", "", "expected access-token audience (defaults to client ID)")
scope := fs.String("scope", "", "space-separated scopes the registration grants")
secretEnv := fs.String("secret-env", "", "environment variable holding the current client secret")
previousEnv := fs.String("previous-secret-env", "", "environment variable holding the predecessor secret, which must be rejected")
denyScope := fs.String("deny-scope", "", "space-separated scopes the registration must refuse")
expectSubject := fs.String("expect-subject", "", "exact required sub claim")
expectTenant := fs.String("expect-tenant", "", "exact required tenant claim")
expectRoles := fs.String("expect-roles", "", "space-separated roles that must all be present")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 || *id == "" || *secretEnv == "" || strings.TrimSpace(*scope) == "" {
return errors.New("client-id, secret-env and scope are required; positional arguments are not accepted")
}
if *audience == "" {
*audience = *id
}
secret := os.Getenv(*secretEnv)
if secret == "" {
return fmt.Errorf("environment variable %s is empty", *secretEnv)
}
c, err := New(*issuer)
if err != nil {
return err
}
return runVerify(ctx, c, verifyOptions{
ClientID: *id,
Secret: secret,
Previous: os.Getenv(*previousEnv),
PreviousNamed: *previousEnv != "",
Audience: *audience,
Scope: *scope,
DenyScope: *denyScope,
Subject: *expectSubject,
Tenant: *expectTenant,
Roles: *expectRoles,
}, stderr)
}
// verifyOptions is the resolved, secret-bearing input to a verification run.
// Secrets arrive as values here and never leave this package.
type verifyOptions struct {
ClientID string
Secret string
Previous string
PreviousNamed bool
Audience string
Scope string
DenyScope string
Subject string
Tenant string
Roles string
}
func runVerify(ctx context.Context, c *Client, o verifyOptions, stderr io.Writer) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
report := &checks{out: stderr}
d, err := c.Discover(ctx)
report.record("discovery and endpoint origin", err)
if err != nil {
return report.result()
}
// The positive exchange. Exchange already verifies RS256 against the
// discovered JWKS and binds issuer, audience, expiry and requested scope.
form := url.Values{"grant_type": {"client_credentials"}, "scope": {o.Scope}}
tokens, err := c.Exchange(ctx, d, form, o.ClientID, o.Secret, o.Audience, "")
report.record("exchange and JWKS signature for granted scopes", err)
if err == nil {
claims, verr := c.Verify(ctx, d, tokens.AccessToken, o.Audience, "")
report.record("access-token claim bindings", verr)
if verr == nil {
report.record("principal_type is service", exactClaim(claims, "principal_type", "service"))
if o.Subject != "" {
report.record("sub matches the registration", exactClaim(claims, "sub", o.Subject))
}
if o.Tenant != "" {
report.record("tenant matches the registration", exactClaim(claims, "tenant", o.Tenant))
}
if strings.TrimSpace(o.Roles) != "" {
report.record("roles present", hasAllRoles(claims, o.Roles))
}
report.record("granted scope carries no excess grant", noExcessScope(claims, o.Scope))
}
}
// Excess-scope denial. A success here is the failure: the registration
// handed out a grant it must not have.
for _, s := range strings.Fields(o.DenyScope) {
excess := url.Values{"grant_type": {"client_credentials"}, "scope": {s}}
_, err := c.Exchange(ctx, d, excess, o.ClientID, o.Secret, o.Audience, "")
report.record("excess scope refused: "+s, mustFail(err))
}
// Predecessor rejection, for a rotation. Only meaningful after step 3 of the
// rotation sequence has replaced the value in both custodians.
if o.PreviousNamed {
if o.Previous == "" {
report.record("predecessor secret refused", errors.New("predecessor environment variable is empty"))
} else if o.Previous == o.Secret {
report.record("predecessor secret refused", errors.New("predecessor and current secret are identical; no rotation occurred"))
} else {
_, err := c.Exchange(ctx, d, form, o.ClientID, o.Previous, o.Audience, "")
report.record("predecessor secret refused", mustFail(err))
}
}
return report.result()
}
// checks accumulates outcomes so every check runs and the operator sees the
// whole picture, rather than stopping at the first failure and hiding the rest.
type checks struct {
out io.Writer
failed int
total int
}
func (c *checks) record(name string, err error) {
c.total++
if err != nil {
c.failed++
fmt.Fprintf(c.out, "FAIL %s: %v\n", name, err)
return
}
fmt.Fprintf(c.out, "PASS %s\n", name)
}
func (c *checks) result() error {
if c.failed > 0 {
return fmt.Errorf("%d of %d checks failed", c.failed, c.total)
}
fmt.Fprintf(c.out, "\nAll %d checks passed. No token, secret or claim value was written or printed.\n", c.total)
return nil
}
// exactClaim reports the claim name only. The observed value stays unprinted so
// running this against production cannot leak a subject or tenant.
func exactClaim(claims map[string]any, name, want string) error {
got, ok := claims[name].(string)
if !ok {
return fmt.Errorf("claim %q is absent or not a string", name)
}
if got != want {
return fmt.Errorf("claim %q does not equal the expected value (exact match, no alias)", name)
}
return nil
}
func hasAllRoles(claims map[string]any, want string) error {
raw, _ := claims["roles"].([]any)
present := map[string]bool{}
for _, r := range raw {
if s, ok := r.(string); ok {
present[s] = true
}
}
var missing []string
for _, r := range strings.Fields(want) {
if !present[r] {
missing = append(missing, r)
}
}
if len(missing) > 0 {
sort.Strings(missing)
return fmt.Errorf("roles absent: %s", strings.Join(missing, " "))
}
return nil
}
// noExcessScope catches the direction Exchange does not check: Exchange proves
// every requested scope was granted, not that nothing extra came back.
func noExcessScope(claims map[string]any, requested string) error {
granted, _ := claims["scope"].(string)
want := map[string]bool{}
for _, s := range strings.Fields(requested) {
want[s] = true
}
var extra []string
for _, s := range strings.Fields(granted) {
if !want[s] {
extra = append(extra, s)
}
}
if len(extra) > 0 {
sort.Strings(extra)
return fmt.Errorf("token carries scopes that were not requested: %s", strings.Join(extra, " "))
}
return nil
}
func mustFail(err error) error {
if err == nil {
return errors.New("the issuer accepted a request it must have refused")
}
return nil
}

View file

@ -0,0 +1,162 @@
package authclient
import (
"bytes"
"context"
"strings"
"testing"
"keycape/internal/domain"
)
// The verification command is the evidence two blocked tasks will rest on
// (KEY-WP-0013-T02 rollout proof, KEY-WP-0014-T04 rotation step 4), so the
// tests that matter are the ones proving it FAILS when the issuer misbehaves.
// A checker that always passes is worse than no checker.
func verifyOpts() verifyOptions {
return verifyOptions{
ClientID: "service:consumer",
Secret: "special+%: secret",
Audience: "approval-engine",
Scope: "approval:read",
DenyScope: "approval:consume approval:approve",
Subject: "service:test",
Tenant: "tenant:test",
}
}
func TestVerifyClientPassesOnCorrectRegistration(t *testing.T) {
c, _, _ := provider(t)
var out bytes.Buffer
if err := runVerify(context.Background(), c, verifyOpts(), &out); err != nil {
t.Fatalf("correct registration failed verification: %v\n%s", err, out.String())
}
for _, want := range []string{
"PASS discovery and endpoint origin",
"PASS exchange and JWKS signature for granted scopes",
"PASS principal_type is service",
"PASS sub matches the registration",
"PASS tenant matches the registration",
"PASS excess scope refused: approval:consume",
"PASS excess scope refused: approval:approve",
} {
if !strings.Contains(out.String(), want) {
t.Errorf("missing check %q in:\n%s", want, out.String())
}
}
}
// The whole point of the command: it must not print what it verified. An
// operator runs this against production, so a leaked subject, tenant or token
// would turn a verification into a disclosure.
func TestVerifyClientNeverPrintsSecretsOrTokens(t *testing.T) {
c, _, _ := provider(t)
o := verifyOpts()
o.Tenant = "tenant:wrong" // force a mismatch so the failure path is covered too
var out bytes.Buffer
if err := runVerify(context.Background(), c, o, &out); err == nil {
t.Fatal("wrong tenant accepted")
}
text := out.String()
for _, forbidden := range []string{"special+%: secret", "eyJ", "tenant:test", "service:test"} {
if strings.Contains(text, forbidden) {
t.Errorf("output disclosed %q:\n%s", forbidden, text)
}
}
if !strings.Contains(text, `FAIL tenant matches the registration`) {
t.Errorf("mismatch not reported by claim name:\n%s", text)
}
}
func TestVerifyClientFailsWhenExcessScopeIsGranted(t *testing.T) {
c, _, h := provider(t)
// A registration that hands out the grant it must refuse.
h.ClientConfig["service:consumer"].AllowedScopes = []string{"approval:read", "approval:consume"}
var out bytes.Buffer
err := runVerify(context.Background(), c, verifyOpts(), &out)
if err == nil {
t.Fatal("over-broad registration passed verification")
}
if !strings.Contains(out.String(), "FAIL excess scope refused: approval:consume") {
t.Errorf("over-broad grant not reported:\n%s", out.String())
}
if !strings.Contains(out.String(), "PASS excess scope refused: approval:approve") {
t.Errorf("unrelated denial should still pass:\n%s", out.String())
}
}
func TestVerifyClientRejectsUnrotatedAndAcceptedPredecessor(t *testing.T) {
c, _, h := provider(t)
// Rotation that never happened: predecessor equals current.
o := verifyOpts()
o.PreviousNamed, o.Previous = true, o.Secret
var same bytes.Buffer
if err := runVerify(context.Background(), c, o, &same); err == nil {
t.Fatal("identical predecessor accepted as a rotation")
}
if !strings.Contains(same.String(), "no rotation occurred") {
t.Errorf("identical secret not diagnosed:\n%s", same.String())
}
// A real predecessor that the issuer still honours: the dangerous case, an
// old secret left valid after rotation.
o.Previous = "previous secret"
h.ClientConfig["service:consumer"].ClientSecret = "previous secret"
var stale bytes.Buffer
if err := runVerify(context.Background(), c, o, &stale); err == nil {
t.Fatal("issuer honouring the predecessor passed verification")
}
if !strings.Contains(stale.String(), "FAIL predecessor secret refused") {
t.Errorf("live predecessor not reported:\n%s", stale.String())
}
// The rotated state: predecessor differs and is refused.
h.ClientConfig["service:consumer"].ClientSecret = o.Secret
var good bytes.Buffer
if err := runVerify(context.Background(), c, o, &good); err != nil {
t.Fatalf("rotated registration failed: %v\n%s", err, good.String())
}
if !strings.Contains(good.String(), "PASS predecessor secret refused") {
t.Errorf("predecessor rejection not confirmed:\n%s", good.String())
}
}
func TestVerifyClientRequiresCompleteArguments(t *testing.T) {
for _, args := range [][]string{
{"-client-id", "x", "-scope", "a"}, // no secret-env
{"-secret-env", "X", "-scope", "a"}, // no client-id
{"-client-id", "x", "-secret-env", "X"}, // no scope
{"-client-id", "x", "-secret-env", "X", "-scope", "a", "extra"}, // positional
} {
if err := verifyClient(context.Background(), args, &bytes.Buffer{}); err == nil {
t.Errorf("incomplete arguments accepted: %v", args)
}
}
}
// noExcessScope is the check Exchange does not perform: Exchange proves every
// requested scope was granted, never that nothing extra came back.
func TestNoExcessScopeCatchesUnrequestedGrants(t *testing.T) {
if err := noExcessScope(map[string]any{"scope": "approval:read"}, "approval:read"); err != nil {
t.Errorf("exact grant rejected: %v", err)
}
err := noExcessScope(map[string]any{"scope": "approval:read approval:consume"}, "approval:read")
if err == nil || !strings.Contains(err.Error(), "approval:consume") {
t.Errorf("unrequested grant not caught: %v", err)
}
}
func TestHasAllRolesNamesWhatIsMissing(t *testing.T) {
claims := map[string]any{"roles": []any{"secrets-engine"}}
if err := hasAllRoles(claims, "secrets-engine"); err != nil {
t.Errorf("present role rejected: %v", err)
}
err := hasAllRoles(claims, "secrets-engine approval-operator")
if err == nil || !strings.Contains(err.Error(), "approval-operator") {
t.Errorf("missing role not named: %v", err)
}
}
var _ = domain.Client{}