Ship the live-registration check both blocked tasks depend on
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
2026-09-08 14:30:43 +02:00
|
|
|
package authclient
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
2026-09-08 16:46:02 +02:00
|
|
|
"errors"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
Ship the live-registration check both blocked tasks depend on
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
2026-09-08 14:30:43 +02:00
|
|
|
"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{}
|
2026-09-08 16:46:02 +02:00
|
|
|
|
|
|
|
|
type verifyTransport func(*http.Request) (*http.Response, error)
|
|
|
|
|
|
|
|
|
|
func (f verifyTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
|
|
|
|
|
|
|
|
|
func TestVerifyClientDoesNotConfuseFailuresWithRefusals(t *testing.T) {
|
|
|
|
|
for _, target := range []string{"scope", "predecessor"} {
|
|
|
|
|
for _, failure := range []string{"transport", "server", "invalid_token", "unrelated_refusal", "malformed_refusal"} {
|
|
|
|
|
t.Run(target+"/"+failure, func(t *testing.T) {
|
|
|
|
|
c, _, _ := provider(t)
|
|
|
|
|
original := c.HTTP.Transport
|
|
|
|
|
o := verifyOpts()
|
|
|
|
|
if target == "predecessor" {
|
|
|
|
|
o.PreviousNamed, o.Previous = true, "old-secret"
|
|
|
|
|
}
|
|
|
|
|
c.HTTP.Transport = verifyTransport(func(r *http.Request) (*http.Response, error) {
|
|
|
|
|
if r.Method == http.MethodPost && r.URL.Path == "/token" {
|
|
|
|
|
body, _ := io.ReadAll(r.Body)
|
|
|
|
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
|
|
|
|
form, _ := url.ParseQuery(string(body))
|
|
|
|
|
_, password, _ := r.BasicAuth()
|
|
|
|
|
password, _ = url.QueryUnescape(password)
|
|
|
|
|
negative := target == "scope" && form.Get("scope") != o.Scope || target == "predecessor" && password == o.Previous
|
|
|
|
|
if negative {
|
|
|
|
|
status, payload := 503, `{"error":"unavailable"}`
|
|
|
|
|
switch failure {
|
|
|
|
|
case "transport":
|
|
|
|
|
return nil, errors.New("transport failed")
|
|
|
|
|
case "invalid_token":
|
|
|
|
|
status, payload = 200, `{"token_type":"Bearer","access_token":"invalid","expires_in":900}`
|
|
|
|
|
case "unrelated_refusal":
|
|
|
|
|
status, payload = 400, `{"error":"invalid_profile_usage","feature":"client_id","description":"must not disclose this"}`
|
|
|
|
|
case "malformed_refusal":
|
|
|
|
|
status, payload = 400, `not-json-sensitive-body`
|
|
|
|
|
}
|
|
|
|
|
return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(payload)), Request: r}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return original.RoundTrip(r)
|
|
|
|
|
})
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
if err := runVerify(context.Background(), c, o, &out); err == nil {
|
|
|
|
|
t.Fatal("a failed negative check was accepted as issuer refusal")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(out.String(), "PASS exchange and JWKS signature for granted scopes") {
|
|
|
|
|
t.Fatalf("test did not reach the negative checks: %s", out.String())
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(out.String(), "expected token-endpoint refusal was not proved") {
|
|
|
|
|
t.Fatalf("wrong failure: %s", out.String())
|
|
|
|
|
}
|
|
|
|
|
for _, value := range []string{o.Secret, o.Previous, "must not disclose this", "not-json-sensitive-body", "eyJ"} {
|
|
|
|
|
if value != "" && strings.Contains(out.String(), value) {
|
|
|
|
|
t.Fatal("verification disclosed input or provider payload")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|