diff --git a/README.md b/README.md index 876570a..5435b16 100644 --- a/README.md +++ b/README.md @@ -267,3 +267,12 @@ Part of the **NetKingdom** domain. Tracked in the Custodian State Hub under domain `netkingdom`, repo slug `key-cape`. See `CLAUDE.md` for agent session protocol and workplan conventions. + + +## Native authentication commands + +`keycape service-token` exchanges a custody-injected client secret for a verified +JWT; `keycape login` performs public-client browser PKCE login. Both save tokens +to a new private file outside Git and never print token values. See +[Native caller authentication](docs/native-authentication.md) for usage, supported +bindings, renewal, and the distinction from OpenBao login. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index e31eb72..644344f 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -17,12 +17,12 @@ | workplan | KEY-WP-0006 | finished | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md | | workplan | KEY-WP-0007 | finished | — | workplans/KEY-WP-0007-user-engine-portal-oidc-client.md | | workplan | KEY-WP-0008 | finished | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md | -| workplan | KEY-WP-0009 | finished | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | +| workplan | KEY-WP-0009 | blocked | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | | workplan | KEY-WP-0010 | finished | — | workplans/KEY-WP-0010-openbao-operator-loopback-callback.md | | workplan | KEY-WP-0011 | finished | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md | | workplan | KEY-WP-0012 | finished | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md | | workplan | KEY-WP-0013 | blocked | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md | -| workplan | KEY-WP-0014 | proposed | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md | +| workplan | KEY-WP-0014 | blocked | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md | | task | ADHOC-2026-09-05-T01 | done | — | workplans/ADHOC-2026-09-05.md | | task | KEY-WP-0001-T01 | done | — | workplans/KEY-WP-0001-keycape-implementation.md | | task | KEY-WP-0001-T02 | done | — | workplans/KEY-WP-0001-keycape-implementation.md | @@ -83,7 +83,7 @@ | task | KEY-WP-0009-T01 | done | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | | task | KEY-WP-0009-T02 | done | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | | task | KEY-WP-0009-T03 | done | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | -| task | KEY-WP-0009-T04 | done | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | +| task | KEY-WP-0009-T04 | wait | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md | | task | KEY-WP-0010-T01 | done | — | workplans/KEY-WP-0010-openbao-operator-loopback-callback.md | | task | KEY-WP-0011-T01 | done | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md | | task | KEY-WP-0011-T02 | done | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md | @@ -93,6 +93,8 @@ | task | KEY-WP-0012-T03 | done | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md | | task | KEY-WP-0013-T01 | done | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md | | task | KEY-WP-0013-T02 | wait | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md | -| task | KEY-WP-0014-T01 | todo | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md | -| task | KEY-WP-0014-T02 | todo | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md | +| task | KEY-WP-0014-T01 | done | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md | +| task | KEY-WP-0014-T02 | done | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md | +| task | KEY-WP-0014-T03 | done | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md | +| task | KEY-WP-0014-T04 | wait | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md | | intake | KEY-IN-0001 | closed | — | intakes/intakes.md | diff --git a/docs/approval-engine-provisioning-request.yaml b/docs/approval-engine-provisioning-request.yaml new file mode 100644 index 0000000..e945303 --- /dev/null +++ b/docs/approval-engine-provisioning-request.yaml @@ -0,0 +1,45 @@ +# Proposed non-secret admission packet. This is not executable authorization. +status: awaiting-custody-admission +owner: key-cape +resource_audience: approval-engine +issuer: https://kc.coulomb.social +registration_source: config/service-clients.example.yaml +requests: + - client_id: secrets-engine-approval + subject: service:secrets-engine + tenant: tenant:coulomb + scopes: [approval:read, approval:consume] + lifetime: 15m + proposed_openbao_path: platform/workloads/secrets-engine/approval-client + field: client_secret + proposed_kubernetes_secret: sso/keycape-secrets-engine-approval-client + kubernetes_key: client-secret + keycape_environment: KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET + custody_owner: railiance-platform + consumer: secrets-engine + - client_id: approval-engine-operator + subject: service:approval-engine-operator + tenant: tenant:coulomb + scopes: [approval:create, approval:read, approval:approve, approval:revoke, approval:supersede, approval:observe, approval:emit] + lifetime: 15m + proposed_openbao_path: platform/workloads/approval-engine/operator-client + field: client_secret + proposed_kubernetes_secret: sso/keycape-approval-engine-operator-client + kubernetes_key: client-secret + keycape_environment: KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET + custody_owner: railiance-platform + consumer: approval-engine-operator +human_registration: + status: awaiting-exact-callback + scopes: [openid, approval:approve] + mfa_required: true + client_type: public +verification: + - Validate the new signature against deployed JWKS and all exact claim bindings. + - Reject wrong secrets, operator consume, PEP lifecycle scopes, and human consume. + - Check KeyCape and consumer readiness without emitting secrets or tokens. + - Preserve existing registrations and signing key; record versions and image digest. +blockers: + - Admit exact custody paths, field delivery, consumer identities and lifecycle authority. + - Resolve attended first-provision authority through the custody owner. + - Supply exact human client ID and callback URI. diff --git a/docs/native-authentication.md b/docs/native-authentication.md new file mode 100644 index 0000000..1362690 --- /dev/null +++ b/docs/native-authentication.md @@ -0,0 +1,87 @@ +# Native caller authentication + +KeyCape owns the `keycape service-token` and `keycape login` commands. They +produce issuer-verified JWTs in a new mode-0600 JSON file outside Git worktrees. +Existing files and symlinks are refused. Token responses and provider error +bodies are never printed. Commands exit nonzero on verification or delivery +failure and remove incomplete output files. + +## Service exchange + +Have the existing custody mechanism inject the registered client secret into a +named environment variable, then run: + +```sh +keycape service-token --issuer https://kc.coulomb.social \ + --client-id rapp-qonto --scope qonto:read \ + --secret-env KEYCAPE_RAPP_QONTO_CLIENT_SECRET \ + --out /private/runtime/qonto-token.json +``` + +The output directory must already exist. No secret belongs on argv. The command +uses client_secret_basic with OAuth form encoding. The server must include the +matching decoding fix for secrets containing reserved characters. The Qonto +client ID and scope above follow the deployed KEY-WP-0006 registration. + +For approval-engine clients, set `--audience approval-engine` explicitly; the +expected audience otherwise defaults to the client ID. Both requested scopes +and audience must match the issuer's static registration. The output contains +`access_token`, `token_type`, and `expires_in`; a service exchange has no ID token. +Re-exchange before expiry for renewal. There is no refresh token or credential +cache. Client disablement stops new issuance; already-issued JWTs expire normally. + +## Browser login + +Use a registered **public PKCE** client with an exact literal-loopback callback: + +```sh +keycape login --issuer https://kc.coulomb.social \ + --client-id APPROVER_CLIENT_ID --audience approval-engine \ + --scope 'openid approval:approve' \ + --redirect-uri http://127.0.0.1:REGISTERED_PORT/callback \ + --out /private/runtime/approver-token.json +``` + +Replace the client ID and port with the approved registration. This command +prints a browser authorization URL and waits up to five minutes. The provider +handles password and MFA; KeyCape CLI never receives those credentials. Configure +`mfaRequired: true` on the approver registration. State, nonce and S256 PKCE are +fresh for each attempt. The listener binds before the URL is printed, validates +callback host/path/state, and closes when the attempt ends. + +Both tokens are verified against same-origin HTTPS discovery/JWKS. Access tokens +bind the expected resource audience and requested scopes. ID tokens bind the +client ID, nonce and matching subject. There is no insecure TLS switch and no +HTTP redirect following during credential exchange. Cross-origin discovery +endpoints and confidential browser clients are intentionally unsupported. + +## OpenBao login boundary + +The existing ops-warden login route runs +`bao login -no-print -method=oidc -path=netkingdom role=`. That produces an +**OpenBao token**, unlike KeyCape's new issuer JWT output. These commands are not +interchangeable. KeyCape owns browser authentication and issuer JWTs; OpenBao's +role mapping, token store and enforcement remain platform-owned. Do not replace +that route with `keycape login` without adapting and verifying its consumer +contract. No ops-warden route is retired by this change. + +## Qonto rotation boundary + +Native exchange is implemented. Rotation still needs a coordinated custody and +provider update, not a wrapper around a raw KV read. The reviewed sequence is: + +1. Resolve exact existing authority for + `platform/workloads/rapp-qonto/keycape-client`, field `client_secret`, and + `sso/keycape-rapp-qonto-client`, key `client-secret`. +2. Generate the successor inside the approved custody execution transport; + retain the predecessor only there for bounded rollback and denial checks. +3. CAS-update that KV field while preserving siblings, update the matching + Kubernetes delivery reference, and restart KeyCape in the agreed window. +4. Verify the new `qonto:read` exchange, predecessor rejection and excess-scope + denial without exposing tokens; verify downstream readiness. +5. On failure, reconcile both custodians to the same version and reload KeyCape + before declaring rollback complete. Record versions and outcomes, never values. + +No general rotation command is shipped until that cross-owner transaction has +an admitted execution and rollback contract. Calling secrets-engine's KV +rotation alone would leave the provider and consumers inconsistent. diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go index 050fa3e..17636cc 100644 --- a/src/cmd/keycape/main.go +++ b/src/cmd/keycape/main.go @@ -21,6 +21,7 @@ import ( "keycape/internal/adapters/authelia" "keycape/internal/adapters/lldap" "keycape/internal/adapters/privacyidea" + "keycape/internal/authclient" "keycape/internal/config" "keycape/internal/domain" servererrors "keycape/internal/server/errors" @@ -31,6 +32,14 @@ import ( const version = "0.1.0" func main() { + if len(os.Args) > 1 && (os.Args[1] == "login" || os.Args[1] == "service-token") { + if err := authclient.Run(context.Background(), os.Args[1:], os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + log := zerolog.New(os.Stdout).With().Timestamp().Logger() // ----------------------------------------------------------------- diff --git a/src/internal/authclient/cli.go b/src/internal/authclient/cli.go new file mode 100644 index 0000000..79e3f11 --- /dev/null +++ b/src/internal/authclient/cli.go @@ -0,0 +1,227 @@ +package authclient + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// 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") + } + mode := args[0] + if mode != "login" && mode != "service-token" { + return errors.New("unknown authentication command") + } + fs := flag.NewFlagSet(mode, 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 audience (defaults to client ID)") + scope := fs.String("scope", "", "space-separated registered scopes") + secretEnv := fs.String("secret-env", "", "environment variable containing service client secret") + out := fs.String("out", "", "new private JSON token file outside Git") + redirect := fs.String("redirect-uri", "", "exact registered HTTP loopback callback (login only)") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if fs.NArg() != 0 || *id == "" || *out == "" || strings.TrimSpace(*scope) == "" { + return errors.New("client-id, scope and out are required; positional arguments are not accepted") + } + if *audience == "" { + *audience = *id + } + if mode == "login" && (*secretEnv != "" || !hasScope(*scope, "openid")) { + return errors.New("login requires openid scope and a public PKCE client") + } + if mode == "service-token" && (*secretEnv == "" || *redirect != "") { + return errors.New("service-token requires secret-env and does not accept redirect-uri") + } + c, err := New(*issuer) + if err != nil { + return err + } + file, err := reserveOutput(*out) + if err != nil { + return err + } + success := false + defer func() { + file.Close() + if !success { + os.Remove(file.Name()) + } + }() + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + d, err := c.Discover(ctx) + if err != nil { + return err + } + var tokens Tokens + if mode == "service-token" { + secret := os.Getenv(*secretEnv) + if secret == "" { + return errors.New("client secret environment variable is empty") + } + tokens, err = c.Exchange(ctx, d, url.Values{"grant_type": {"client_credentials"}, "scope": {*scope}}, *id, secret, *audience, "") + } else { + tokens, err = c.Login(ctx, d, *id, *audience, *scope, *redirect, stderr) + } + if err != nil { + return err + } + if err = json.NewEncoder(file).Encode(tokens); err != nil { + return errors.New("could not write token file") + } + if err = file.Sync(); err != nil { + return errors.New("could not sync token file") + } + if err = file.Close(); err != nil { + return errors.New("could not close token file") + } + success = true + fmt.Fprintln(stderr, "Verified tokens saved to the requested private file.") + return nil +} + +func reserveOutput(path string) (*os.File, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return nil, errors.New("invalid output path") + } + parent, err := filepath.EvalSymlinks(filepath.Dir(absolute)) + if err != nil { + return nil, errors.New("output directory must already exist") + } + for dir := parent; ; dir = filepath.Dir(dir) { + marker := filepath.Join(dir, ".git") + info, statErr := os.Lstat(marker) + if statErr == nil { + // A worktree uses a .git file; normal repositories have .git/HEAD. + // An empty directory alone is not a Git repository. + if !info.IsDir() { + return nil, errors.New("token output must be outside Git worktrees") + } + if _, err := os.Lstat(filepath.Join(marker, "HEAD")); err == nil { + return nil, errors.New("token output must be outside Git worktrees") + } else if !os.IsNotExist(err) { + return nil, errors.New("could not inspect Git directory") + } + } else if !os.IsNotExist(statErr) { + return nil, errors.New("could not inspect output directory") + } + if filepath.Dir(dir) == dir { + break + } + } + file, err := os.OpenFile(filepath.Join(parent, filepath.Base(absolute)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return nil, errors.New("output file must be new and writable") + } + return file, nil +} + +// Login binds the exact registered IPv4/IPv6 loopback address before offering +// the browser URL. State, nonce and PKCE are independently generated per attempt. +func (c *Client) Login(ctx context.Context, d Discovery, id, audience, scope, redirect string, output io.Writer) (Tokens, error) { + callback, err := url.Parse(redirect) + if err != nil || callback.Scheme != "http" || callback.User != nil || callback.RawQuery != "" || callback.Fragment != "" || callback.Port() == "" || callback.Port() == "0" || callback.Path == "" { + return Tokens{}, errors.New("redirect-uri must be an exact HTTP loopback URL with a fixed port and path") + } + ip := net.ParseIP(callback.Hostname()) + if ip == nil || !ip.IsLoopback() { + return Tokens{}, errors.New("callback must use a literal loopback IP address") + } + listener, err := net.Listen("tcp", callback.Host) + if err != nil { + return Tokens{}, errors.New("could not bind registered callback") + } + defer listener.Close() + state, err := randomValue() + if err != nil { + return Tokens{}, err + } + nonce, err := randomValue() + if err != nil { + return Tokens{}, err + } + verifier, err := randomValue() + if err != nil { + return Tokens{}, err + } + challenge := sha256.Sum256([]byte(verifier)) + type callbackResult struct { + code string + err error + } + result := make(chan callbackResult, 1) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if r.Method != http.MethodGet || r.URL.Path != callback.Path || r.Host != callback.Host { + http.Error(w, "Invalid callback", 400) + return + } + q, err := url.ParseQuery(r.URL.RawQuery) + if err != nil || len(q["state"]) != 1 || subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state)) != 1 { + http.Error(w, "Invalid callback state", 400) + return + } + var value callbackResult + if q.Get("error") != "" { + value.err = errors.New("login was declined by the identity provider") + } else if len(q["code"]) != 1 || q.Get("code") == "" { + http.Error(w, "Missing authorization code", 400) + return + } else { + value.code = q.Get("code") + } + select { + case result <- value: + fmt.Fprintln(w, "Callback received. Return to the terminal.") + default: + http.Error(w, "Callback already received", 409) + } + }) + server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, MaxHeaderBytes: 16384} + defer server.Close() + serveErrors := make(chan error, 1) + go func() { serveErrors <- server.Serve(listener) }() + authorize, err := url.Parse(d.Authorization) + if err != nil { + return Tokens{}, errors.New("invalid authorization endpoint") + } + q := authorize.Query() + for name, value := range map[string]string{"client_id": id, "redirect_uri": redirect, "response_type": "code", "scope": scope, "state": state, "nonce": nonce, "code_challenge": base64.RawURLEncoding.EncodeToString(challenge[:]), "code_challenge_method": "S256"} { + q.Set(name, value) + } + authorize.RawQuery = q.Encode() + fmt.Fprintf(output, "Open this URL in your browser to authenticate and complete MFA:\n%s\n", authorize.String()) + select { + case <-ctx.Done(): + return Tokens{}, errors.New("login timed out or was cancelled") + case <-serveErrors: + return Tokens{}, errors.New("callback listener stopped") + case value := <-result: + if value.err != nil { + return Tokens{}, value.err + } + return c.Exchange(ctx, d, url.Values{"grant_type": {"authorization_code"}, "client_id": {id}, "code": {value.code}, "code_verifier": {verifier}, "redirect_uri": {redirect}, "scope": {scope}}, id, "", audience, nonce) + } +} diff --git a/src/internal/authclient/client.go b/src/internal/authclient/client.go new file mode 100644 index 0000000..dce5cee --- /dev/null +++ b/src/internal/authclient/client.go @@ -0,0 +1,221 @@ +// Package authclient implements caller-side KeyCape authentication. Credentials +// are delivered to a private file; errors never include response bodies or tokens. +package authclient + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strings" + "time" +) + +type Client struct { + HTTP *http.Client + Issuer string +} +type Discovery struct { + Issuer string `json:"issuer"` + Authorization string `json:"authorization_endpoint"` + Token string `json:"token_endpoint"` + JWKS string `json:"jwks_uri"` +} +type Tokens struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token,omitempty"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +func New(issuer string) (*Client, error) { + u, err := url.Parse(issuer) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return nil, errors.New("issuer must be an HTTPS URL without credentials, query or fragment") + } + return &Client{Issuer: issuer, HTTP: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil +} + +func (c *Client) request(ctx context.Context, method, endpoint string, form url.Values, id, secret string, out any) error { + req, err := http.NewRequestWithContext(ctx, method, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return errors.New("invalid provider endpoint") + } + if method == http.MethodPost { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if secret != "" { + req.SetBasicAuth(url.QueryEscape(id), url.QueryEscape(secret)) + } + response, err := c.HTTP.Do(req) + if err != nil { + return errors.New("provider request failed") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("provider rejected request (HTTP %d)", response.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(response.Body, 1024*1024+1)) + if err != nil || len(body) > 1024*1024 { + return errors.New("invalid provider response size") + } + if err := json.Unmarshal(body, out); err != nil { + return errors.New("invalid provider JSON response") + } + return nil +} + +func (c *Client) Discover(ctx context.Context) (Discovery, error) { + var d Discovery + if err := c.request(ctx, http.MethodGet, strings.TrimRight(c.Issuer, "/")+"/.well-known/openid-configuration", nil, "", "", &d); err != nil { + return d, err + } + if d.Issuer != c.Issuer { + return d, errors.New("discovery issuer mismatch") + } + base, _ := url.Parse(c.Issuer) + for _, endpoint := range []string{d.Authorization, d.Token, d.JWKS} { + u, err := url.Parse(endpoint) + if err != nil || u.Scheme != "https" || u.Host != base.Host || u.User != nil || u.Fragment != "" { + return d, errors.New("discovery endpoint must use the issuer HTTPS origin") + } + } + return d, nil +} + +// Verify checks RS256 using the discovered issuer's JWKS and exact claim bindings. +func (c *Client) Verify(ctx context.Context, d Discovery, token, audience, nonce string) (map[string]any, error) { + fail := errors.New("token signature or claim validation failed") + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fail + } + decode := base64.RawURLEncoding.DecodeString + header, err := decode(parts[0]) + if err != nil { + return nil, fail + } + var h struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + Crit []string `json:"crit"` + } + if json.Unmarshal(header, &h) != nil || h.Alg != "RS256" || h.Kid == "" || len(h.Crit) != 0 { + return nil, fail + } + var jwks struct { + Keys []struct{ Kty, Use, Alg, Kid, N, E string } `json:"keys"` + } + if err := c.request(ctx, http.MethodGet, d.JWKS, nil, "", "", &jwks); err != nil { + return nil, err + } + var pub *rsa.PublicKey + for _, k := range jwks.Keys { + if k.Kid != h.Kid { + continue + } + if pub != nil || k.Kty != "RSA" || (k.Alg != "" && k.Alg != "RS256") || (k.Use != "" && k.Use != "sig") { + return nil, fail + } + n, ne := decode(k.N) + e, ee := decode(k.E) + if ne != nil || ee != nil || len(e) == 0 || len(e) > 4 { + return nil, fail + } + pub = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())} + if pub.N.BitLen() < 2048 || pub.E < 3 || pub.E%2 == 0 { + return nil, fail + } + } + if pub == nil { + return nil, fail + } + sig, err := decode(parts[2]) + if err != nil { + return nil, fail + } + hash := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if rsa.VerifyPKCS1v15(pub, crypto.SHA256, hash[:], sig) != nil { + return nil, fail + } + payload, err := decode(parts[1]) + if err != nil { + return nil, fail + } + claims := map[string]any{} + if json.Unmarshal(payload, &claims) != nil { + return nil, fail + } + exp, okExp := claims["exp"].(float64) + iat, okIat := claims["iat"].(float64) + now := float64(time.Now().Unix()) + if claims["iss"] != c.Issuer || claims["aud"] != audience || !okExp || !okIat || exp <= now || iat > now+30 || iat >= exp { + return nil, fail + } + if sub, ok := claims["sub"].(string); !ok || sub == "" { + return nil, fail + } + if nonce != "" && claims["nonce"] != nonce { + return nil, fail + } + if nbf, exists := claims["nbf"]; exists { + if n, ok := nbf.(float64); !ok || n > now { + return nil, fail + } + } + return claims, nil +} + +func (c *Client) Exchange(ctx context.Context, d Discovery, form url.Values, id, secret, audience, nonce string) (Tokens, error) { + var result Tokens + if err := c.request(ctx, http.MethodPost, d.Token, form, id, secret, &result); err != nil { + return Tokens{}, err + } + if !strings.EqualFold(result.TokenType, "Bearer") || result.ExpiresIn <= 0 { + return Tokens{}, errors.New("invalid token response") + } + claims, err := c.Verify(ctx, d, result.AccessToken, audience, "") + if err != nil { + return Tokens{}, err + } + granted, _ := claims["scope"].(string) + for _, s := range strings.Fields(form.Get("scope")) { + if !hasScope(granted, s) { + return Tokens{}, errors.New("required scope absent from token") + } + } + if nonce != "" { + idClaims, err := c.Verify(ctx, d, result.IDToken, id, nonce) + if err != nil { + return Tokens{}, err + } + if idClaims["sub"] != claims["sub"] { + return Tokens{}, errors.New("token subject mismatch") + } + } + return result, nil +} +func hasScope(scopes, want string) bool { + for _, s := range strings.Fields(scopes) { + if s == want { + return true + } + } + return false +} +func randomValue() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/src/internal/authclient/client_test.go b/src/internal/authclient/client_test.go new file mode 100644 index 0000000..a813263 --- /dev/null +++ b/src/internal/authclient/client_test.go @@ -0,0 +1,254 @@ +package authclient + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "keycape/internal/domain" + "keycape/internal/server/oidc" + "keycape/internal/server/telemetry" +) + +type users struct{} + +func (users) LookupUser(context.Context, string) (*domain.User, error) { + return &domain.User{ID: "user:test", Username: "test"}, nil +} +func (users) LookupGroups(context.Context, string) ([]domain.Group, error) { return nil, nil } +func (users) ValidatePassword(context.Context, string, string) (bool, error) { return true, nil } +func (users) ListUsers(context.Context) ([]domain.User, error) { return nil, nil } + +func provider(t *testing.T) (*Client, Discovery, *oidc.TokenHandler) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + server := httptest.NewTLSServer(mux) + t.Cleanup(server.Close) + sessions := oidc.NewSessionStore() + h := &oidc.TokenHandler{Issuer: server.URL, SigningKey: key, TokenLifetime: 15 * time.Minute, Sessions: sessions, Users: users{}, Emitter: telemetry.NoopEmitter{}, ClientConfig: map[string]*domain.Client{ + "service:consumer": {ClientID: "service:consumer", ClientType: "confidential", ClientSecret: "special+%: secret", GrantTypes: []string{"client_credentials"}, AllowedScopes: []string{"approval:read"}, Audience: "approval-engine", ServiceSubject: "service:test", Tenant: "tenant:test"}, + "human": {ClientID: "human", AllowedScopes: []string{"openid", "approval:approve"}, Audience: "approval-engine"}, + }} + mux.Handle("/token", h) + keys := oidc.NewKeySet() + keys.AddKey("key-1", &key.PublicKey) + mux.Handle("/jwks", oidc.NewJWKSHandler(keys)) + d := Discovery{Issuer: server.URL, Authorization: server.URL + "/authorize", Token: server.URL + "/token", JWKS: server.URL + "/jwks"} + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(d) }) + mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + code := sessions.Create(&oidc.PKCESession{ClientID: q.Get("client_id"), Username: "test", Nonce: q.Get("nonce"), Scopes: strings.Fields(q.Get("scope")), PKCEChallenge: q.Get("code_challenge"), ExpiresAt: time.Now().Add(time.Minute)}) + target, _ := url.Parse(q.Get("redirect_uri")) + params := target.Query() + params.Set("state", q.Get("state")) + params.Set("code", code) + target.RawQuery = params.Encode() + http.Redirect(w, r, target.String(), 302) + }) + c, err := New(server.URL) + if err != nil { + t.Fatal(err) + } + c.HTTP.Transport = server.Client().Transport + return c, d, h +} + +func TestServiceExchangeAndClaimValidation(t *testing.T) { + c, _, h := provider(t) + ctx := context.Background() + d, err := c.Discover(ctx) + if err != nil { + t.Fatal(err) + } + form := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}} + token, err := c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", "") + if err != nil { + t.Fatal(err) + } + if _, err = c.Verify(ctx, d, token.AccessToken, "other", ""); err == nil { + t.Fatal("wrong audience accepted") + } + if _, err = c.Verify(ctx, d, token.AccessToken+"tampered", "approval-engine", ""); err == nil { + t.Fatal("tampering accepted") + } + if _, err = c.Exchange(ctx, d, form, "service:consumer", "wrong", "approval-engine", ""); err == nil || strings.Contains(err.Error(), "special") { + t.Fatal("wrong secret not safely rejected") + } + form.Set("scope", "approval:consume") + if _, err = c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", ""); err == nil { + t.Fatal("excess scope accepted") + } + form.Set("scope", "approval:read") + h.TokenLifetime = -time.Minute + if _, err = c.Exchange(ctx, d, form, "service:consumer", "special+%: secret", "approval-engine", ""); err == nil { + t.Fatal("expired response accepted") + } +} + +type urlWriter struct{ urls chan string } + +func (w urlWriter) Write(p []byte) (int, error) { + for _, line := range strings.Split(string(p), "\n") { + if strings.HasPrefix(line, "https://") { + w.urls <- line + } + } + return len(p), nil +} + +func TestBrowserLoginPKCEAndState(t *testing.T) { + c, d, _ := provider(t) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + redirect := "http://" + listener.Addr().String() + "/callback" + listener.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + urls := make(chan string, 1) + completed := make(chan error, 1) + go func() { + _, err := c.Login(ctx, d, "human", "approval-engine", "openid approval:approve", redirect, urlWriter{urls}) + completed <- err + }() + var address string + select { + case address = <-urls: + case err := <-completed: + t.Fatal(err) + case <-ctx.Done(): + t.Fatal("no login URL") + } + // A forged callback must not consume the real login attempt. + res, err := http.Get(redirect + "?state=forged&code=forged") + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != 400 { + t.Fatal("forged state accepted") + } + browser := &http.Client{Transport: c.HTTP.Transport, Timeout: 5 * time.Second} + res, err = browser.Get(address) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, res.Body) + res.Body.Close() + select { + case err := <-completed: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("login did not finish") + } +} + +func TestLoginRejectsUnsafeCallbacks(t *testing.T) { + c, d, _ := provider(t) + for _, callback := range []string{"http://example.com:8000/callback", "http://localhost:8000/callback", "http://127.0.0.1:0/callback", "http://127.0.0.1:8000/callback?extra=yes"} { + if _, err := c.Login(context.Background(), d, "human", "approval-engine", "openid", callback, io.Discard); err == nil { + t.Fatalf("accepted %s", callback) + } + } +} + +func TestOutputProtection(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "token.json") + file, err := reserveOutput(path) + if err != nil { + t.Fatalf("reserve %s: %v", path, err) + } + file.Close() + info, _ := os.Stat(path) + if info.Mode().Perm() != 0600 { + t.Fatal("file not private") + } + if _, err = reserveOutput(path); err == nil { + t.Fatal("overwrote existing file") + } + link := filepath.Join(dir, "link") + if err = os.Symlink(path, link); err != nil { + t.Fatal(err) + } + if _, err = reserveOutput(link); err == nil { + t.Fatal("followed output symlink") + } + repo := filepath.Join(dir, "repo") + os.Mkdir(repo, 0700) + os.WriteFile(filepath.Join(repo, ".git"), []byte("gitdir: elsewhere"), 0600) + if _, err = reserveOutput(filepath.Join(repo, "token")); err == nil { + t.Fatal("allowed token in worktree") + } +} + +func TestDiscoveryAndRedirectBoundaries(t *testing.T) { + for _, issuer := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?secret=value"} { + if _, err := New(issuer); err == nil { + t.Fatal("unsafe issuer accepted") + } + } + mux := http.NewServeMux() + server := httptest.NewTLSServer(mux) + defer server.Close() + c, _ := New(server.URL) + c.HTTP.Transport = server.Client().Transport + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"issuer":%q,"authorization_endpoint":"https://evil.example/a","token_endpoint":"https://evil.example/t","jwks_uri":"https://evil.example/j"}`, server.URL) + }) + if _, err := c.Discover(context.Background()); err == nil { + t.Fatal("cross-origin discovery accepted") + } + mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "https://evil.example", 307) }) + var out any + if err := c.request(context.Background(), "POST", server.URL+"/redirect", nil, "id", "secret", &out); err == nil { + t.Fatal("followed credential redirect") + } +} + +func TestNonceMismatchAndCancelledLogin(t *testing.T) { + c, d, _ := provider(t) + form := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}} + token, err := c.Exchange(context.Background(), d, form, "service:consumer", "special+%: secret", "approval-engine", "") + if err != nil { + t.Fatal(err) + } + if _, err = c.Verify(context.Background(), d, token.AccessToken, "approval-engine", "required-nonce"); err == nil { + t.Fatal("missing nonce accepted") + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + redirect := "http://" + listener.Addr().String() + "/callback" + listener.Close() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err = c.Login(ctx, d, "human", "approval-engine", "openid", redirect, io.Discard); err == nil { + t.Fatal("cancelled login succeeded") + } + listener, err = net.Listen("tcp", strings.TrimPrefix(strings.TrimSuffix(redirect, "/callback"), "http://")) + if err != nil { + t.Fatal("listener not released") + } + listener.Close() +} diff --git a/src/internal/server/oidc/token.go b/src/internal/server/oidc/token.go index 05c7e37..f64ed5e 100644 --- a/src/internal/server/oidc/token.go +++ b/src/internal/server/oidc/token.go @@ -9,6 +9,7 @@ import ( "encoding/base64" "encoding/json" "net/http" + "net/url" "strings" "time" @@ -224,6 +225,12 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req Write(w, http.StatusUnauthorized) return } + clientID, idErr := url.QueryUnescape(clientID) + clientSecret, secretErr := url.QueryUnescape(clientSecret) + if idErr != nil || secretErr != nil { + profileerrors.InvalidProfileUsage("invalid client authentication encoding", "Authorization").Write(w, http.StatusUnauthorized) + return + } client, ok := h.ClientConfig[clientID] if !ok || client.ClientType != "confidential" || !containsString(client.GrantTypes, "client_credentials") { profileerrors.InvalidProfileUsage("invalid confidential client", "client_id"). diff --git a/workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md b/workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md index dbde726..d88422c 100644 --- a/workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md +++ b/workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md @@ -4,7 +4,7 @@ type: workplan title: "Provider capability declarations and bounded service identities" domain: infotech repo: key-cape -status: finished +status: blocked owner: codex topic_slug: netkingdom created: "2026-08-23" @@ -85,7 +85,7 @@ custody and platform owners and are not implied by this source registration. ```task id: KEY-WP-0009-T04 -status: done +status: wait priority: high state_hub_task_id: "6945c0c0-bbb6-552b-8d62-d806d3fd0018" ``` @@ -97,5 +97,8 @@ railiance-platform with revision-independent paths and exact ownership status. Both declarations pass NetKingdom's canonical validator. The full Go suite, vet, build, `gofmt`, YAML parsing, and `git diff --check` pass using an explicit Go 1.23 toolchain and writable local cache; the workstation's default Go cache -is read-only and was not used. State Hub handoffs name only non-secret paths and -ownership facts. +is read-only and was not used. The earlier statement that State Hub handoffs were delivered was not supported +by receipt IDs. The 2026-09-05 audit found no matching handoff receipts in the +current Hub response; local implementation and validation were complete, but +notification delivery is unverified. KEY-WP-0014 records the correction and +remaining consumer handoff. Do not use this task as proof of message delivery. diff --git a/workplans/KEY-WP-0013-approval-engine-resource-audience.md b/workplans/KEY-WP-0013-approval-engine-resource-audience.md index 5476029..8c48cf7 100644 --- a/workplans/KEY-WP-0013-approval-engine-resource-audience.md +++ b/workplans/KEY-WP-0013-approval-engine-resource-audience.md @@ -45,3 +45,14 @@ Needs deployment-owned custody for both new secret references and an exact human callback registration. Deploy the implementation and registrations together, then prove live JWKS verification and denied excess scopes without logging values. Local signature proof is not live rollout evidence. See docs/approval-engine-auth-contract.md. + + +2026-09-05 follow-up: read-only deployment metadata shows the current image is +forgejo.coulomb.social/coulomb/key-cape:main-153258b and only the Qonto service +secret environment reference is present. The two approval clients are not +materialized through deployment environment references. Published a concrete +non-secret admission packet at docs/approval-engine-provisioning-request.yaml. +Custody routing has no exact admitted lane for these two clients. `warden plan` +returned founder_required but matched an unrelated generic database lane; that +mismatch is not authority to provision. Human callback clarification is pending. +No secrets were read or production resources changed. diff --git a/workplans/KEY-WP-0014-native-credential-lane-handoff.md b/workplans/KEY-WP-0014-native-credential-lane-handoff.md index c241001..bfcac66 100644 --- a/workplans/KEY-WP-0014-native-credential-lane-handoff.md +++ b/workplans/KEY-WP-0014-native-credential-lane-handoff.md @@ -4,7 +4,7 @@ type: workplan title: "Review native login and client credential lane handoffs" domain: infotech repo: key-cape -status: proposed +status: blocked owner: codex topic_slug: native-credential-lane-handoff created: "2026-09-05" @@ -13,14 +13,14 @@ state_hub_workstream_id: "0d003df3-f7d3-5063-8ca0-e1e33f7df74a" --- Source: ops-warden inbox message 0dd9c7bd-0ecd-42d1-806f-7cc4ba9730ed. -The issuer supports client_secret_basic exchange, but has no native exchange or -rotation CLI. Existing machine issuance ownership does not complete this handoff. +The native service exchange and public PKCE login commands are now implemented. +Cross-owner rotation and consumer-specific route cutover remain outstanding. ## Design owner command and custody boundaries ```task id: KEY-WP-0014-T01 -status: todo +status: done priority: medium state_hub_task_id: "0c0a0b61-c19e-5631-9cda-8b2dc0f47d8f" ``` @@ -35,7 +35,7 @@ retiring the proxy until replacement commands have equivalent verification. ```task id: KEY-WP-0014-T02 -status: todo +status: done priority: low state_hub_task_id: "d9a5de97-b7d5-5599-98c2-eaab32f51495" ``` @@ -43,3 +43,42 @@ state_hub_task_id: "d9a5de97-b7d5-5599-98c2-eaab32f51495" Ops-warden reports KEY-WP-0009-T04's claimed reply did not arrive. Verify prior receipts for all four named recipients before claiming successful notification. No outbound coordination messages were sent during the 2026-09-05 repo review. + + +## Implement and verify native caller commands + +```task +id: KEY-WP-0014-T03 +status: done +priority: high +state_hub_task_id: "4e46f474-a15e-594f-ae07-ad37a9667d89" +``` + +Implemented keycape service-token and login with HTTPS discovery, RS256/JWKS +verification, exact audience bindings, PKCE/state/nonce, private out-of-Git file +delivery and redacted errors. Service Basic authentication now form-decodes +credentials. Integration tests exercise the real token/JWKS handlers and browser +callback, including forged state, tampering, excess scope and unsafe output. +See docs/native-authentication.md for invocation and ownership boundaries. + +## Admit rotation and verify consumer handoff + +```task +id: KEY-WP-0014-T04 +status: wait +priority: high +state_hub_task_id: "e7632c71-9676-5837-a925-7f905e3015c5" +``` + +Qonto rotation requires admitted custody/provider transaction and rollback +semantics; see the exact review sequence in docs/native-authentication.md. +The current login proxy yields an OpenBao token, so it cannot be replaced by a +JWT-writing command without adapting its consumer contract. Verify that split +with the platform owner and ops-warden before route changes. No route was changed. + +2026-09-05 receipt audit: GET /messages/?from_agent=key-cape returned one +2026-03 image-publication message and no KEY-WP-0009 handoff receipts for +NetKingdom, secrets-engine, ops-warden or railiance-platform. Corrected the +unsupported statement in KEY-WP-0009-T04. This proves only that no receipts +are present in the current Hub response, not that no message ever existed. +No replacement outbound messages were sent.