diff --git a/.planning/phases/19.4-oidc-provider-on-wristband/19.4-SECURITY-REVIEW.md b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-SECURITY-REVIEW.md new file mode 100644 index 00000000..daae8fed --- /dev/null +++ b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-SECURITY-REVIEW.md @@ -0,0 +1,114 @@ +# Phase 19.4 Security Review: OIDC provider on wristband + +**Date:** 2026-10-19 +**Level:** OWASP ASVS L1 (RESEARCH "Security Domain") +**Scope:** wristband's OIDC flavour (`summercms.go/modules/wristband/oidc_*.go` plus the flavour and kind checks in `authorize.go` and `token.go`), the new plugin `sm-oauthserver-plugin` (stores, key ring, host and pages, admin screens, commands, import, parity fixtures), and the sm-user-plugin additions (`RequestUser`, `EndRequestSession`, `AvatarURL`, `user.Deactivating`). +**Threats reviewed:** T-19.4-01..T-19.4-34 and T-19.4-SC from the threat models of plans 01, 02 and 03. T-19.4-35 belongs to plan 04's gate script, which does not exist yet; plan 04 reviews it. +**Result:** every threat is verified or fixed; four new findings (T-19.4-36..T-19.4-39) are accepted with reasons. No threat is left unresolved. + +Repositories and abbreviations used below: **W** = `summercms.go/modules/wristband`, **P** = `sm-oauthserver-plugin`, **U** = sm-user-plugin (`fonoteka.go/plugins/golem15/user`). + +## Method + +1. For each threat, read the code that implements the mitigation and record the file and line (grep-level check at L1, plus a read of the surrounding logic). +2. For each threat, find the test that pins the mitigation. Where the code was correct but no test pinned it, add the test as a separate commit in the repository it belongs to (no production code needed changing). +3. Cross-cutting sweeps: log and error paths, `no-store` on every credential-carrying response, consent replay, kind isolation, the cabana surface, branding injection, import output, parity fixtures. +4. Run the suites: W `go test -short ./modules/wristband/...`, P `go test ./...` on Docker (testcontainers Postgres), U `go vet` and `go test -short`. + +## Gaps fixed during the review + +All gaps were missing test pins; the code already implemented each mitigation. + +| Commit | Repo | Threats | What the new test pins | +|--------|------|---------|------------------------| +| eaa545c | summercms.go | T-19.4-02, T-19.4-09, T-19.4-13 | `TestOIDCCodeBindingSmoke`: a code is refused for another client or redirect URI and burned; authorize refuses `plain` and malformed challenges; verifier rules; 60 s default code TTL. `TestOIDCLogsCarryNoCredentials`: rejected exchanges log client id, IP and reason only, never secret, code or verifier; key parse errors quote no key. `TestOIDCSubjectIsRandomSmoke`: a public sub is a random UUIDv4. | +| b4223be | sm-oauthserver-plugin | T-19.4-15, T-19.4-23, T-19.4-27, T-19.4-32 | `TestAdminSurfaceHidesCredentials` (no Docker): only the clients and branding controllers, each behind its permission; none edits signing keys; no admin YAML or partial names `secret_hash` or a key column. `TestUserCascadeSmoke` extended: a deactivated user's unexpired session JWT signs nobody in at authorize, and the claims builder reports the user as gone. | +| f8eaf70 | sm-oauthserver-plugin | T-19.4-20, T-19.4-28 | `TestTokenLimitsSmoke`: the token route never parses a form over `token_body_limit`, the `oauth-token` bucket answers 429 after 20 attempts per client_id, discovery and JWKS are not throttled. `TestImportKeyDirErrorsNameNoKey` (no Docker): a mismatched pair, a missing key_dir and an unreadable key abort before any write, naming kid and file, never quoting key material. | + +## Threat register + +| Threat | Mitigation as built | Implementation | Test (repo) | Status | +|--------|---------------------|----------------|-------------|--------| +| T-19.4-01 | Unknown client, non-OIDC client and redirect mismatch are rendered through the host (400, framing and no-store headers), never redirected. Redirect URIs match byte for byte (constant-time compare); the port may vary only for `is_dev` clients on http `localhost`, `127.0.0.1` or `[::1]` with identical path, query and fragment. | W `oidc_authorize.go:80`, `:84`; W `oidc_redirect.go:92`, `:125`; W `oidc_authorize.go:366` (render headers) | `TestOIDCAuthorizeStartSmoke`, `TestPHPRedirectEncodingSmoke` (loopback table) (summercms.go) | verified | +| T-19.4-02 | S256 only; challenge `^[A-Za-z0-9_-]{43,128}$`; verifier 43-128 unreserved characters compared in constant time; the code is bound to client and redirect URI, lives 60 s, is consumed by one guarded UPDATE. | W `oidc_authorize.go:133-138`, `:439`; W `oidc_token.go:223-231`, `:489`; W `oidc_options.go:109`; P `classes/oidc_store.go:202` | `TestOIDCCodeBindingSmoke` (new), `TestOIDCReplayRevokesSmoke` (expired code, wrong verifier) (summercms.go) | fixed in commit eaa545c (test pin; code unchanged) | +| T-19.4-03 | The code is consumed before any other check; a replay of a consumed code revokes the access and refresh tokens issued from it, and the transaction commits although the answer is `invalid_grant`. | W `oidc_token.go:203-221`; P `classes/oidc_store.go:301` (`RevokeAccessTokensForCode`) | `TestOIDCReplayRevokesSmoke` (summercms.go), `TestOIDCFlowSmoke` (sm-oauthserver-plugin) | verified | +| T-19.4-04 | One-shot consent token: 32 random bytes, stored as SHA-256(token pipe user id) on the request row, read under `FOR UPDATE`, compared in constant time, cleared on use; the request row has no user column; every failure renders `expired_request`. | W `oidc_consent.go:16`, `:67`, `:135-146`, `:249` | `TestOIDCResumeDecideSwitchSmoke` (token reuse, another user's token) (summercms.go); `TestLoginHandoffAndSwitchSmoke` (sm-oauthserver-plugin) | verified | +| T-19.4-05 | MCP flavour refuses kind oidc at authorize and token; OIDC flavour accepts only kind oidc at authorize, resume, decide, token and userinfo; the plugin's stores filter kind in SQL. `openid` stays outside the MCP scope allowlist. | W `authorize.go:64`, `token.go:197`; W `oidc_authorize.go:80`, `:310`; W `oidc_token.go:172`; W `oidc_userinfo.go:47`; P `classes/oidc_store.go:76`, `classes/mcp_store.go:68` | `TestKindIsolationSmoke` (summercms.go); `TestMCPStoreSmoke` (sm-oauthserver-plugin) | verified | +| T-19.4-06 | Secret prefix gate, SHA-256 hex compared in constant time, a dummy comparison for unknown clients, one uniform `invalid_client` 401 for unknown, inactive, non-OIDC, unprefixed and wrong secrets; the `oauth-token` bucket throttles per client_id (see T-19.4-36). | W `oidc_token.go:157-188` (dummy compare `:169`); P `buckets.go:44` | `TestOIDCTokenErrorsSmoke` (summercms.go); `TestBucketKeysSmoke`, `TestTokenLimitsSmoke` (sm-oauthserver-plugin) | verified | +| T-19.4-07 | `aud` is the client id as a JSON string, `nonce` echoed when sent, `at_hash` over the issued access token, `exp` from `IDTokenTTL`; relying parties verify against the JWKS. | W `oidc_keys.go:232-243` | `TestOIDCTracerSmoke` (summercms.go); `TestRelyingPartyInterop` RS256 and ES256 with go-oidc (sm-oauthserver-plugin) | verified | +| T-19.4-08 | The server only signs; the JWS method is derived from the key type and a mismatch of `Alg` and key is refused; discovery lists exactly the ring's algorithms; every JWK carries `alg` and `use: sig`. | W `oidc_keys.go:165`, `:256` | `TestMixedKeyRingSmoke` (EC key labelled RS256 refused, HS256 refused, discovery algorithms) (summercms.go) | verified | +| T-19.4-09 | The only log line of the OIDC flavour carries client_id, client IP and a fixed reason; key material reaches the engine only as `crypto.Signer`; key parsing and signing errors are fixed texts; storage errors become a bare 500. | W `oidc_token.go:508`; W `oidc_keys.go:97-140`, `:249` | `TestOIDCLogsCarryNoCredentials` (new) (summercms.go) | fixed in commit eaa545c (test pin; code unchanged) | +| T-19.4-10 | Userinfo answers one uniform 401 (empty body, `Bearer error="invalid_token"`, no-store) for missing, malformed, unknown, expired, revoked, wrong-kind, inactive-client and gone-user cases. | W `oidc_userinfo.go:25-73`, `:87` | `TestOIDCUserInfoSmoke`, `TestOIDCReplayRevokesSmoke` (summercms.go) | verified | +| T-19.4-11 | Identity claims pass the `IdentityClaims` allowlist and never a protocol claim name; protocol claims are written after them and `sub` once more last; userinfo skips protocol names. | W `oidc_keys.go:207`, `:224-243`; W `oidc_userinfo.go:133` | `TestOIDCTracerSmoke` (the fake identity carries `iss` and `x_extra`; the id_token keeps the real `iss` and drops `x_extra`, userinfo carries no `iss`) (summercms.go) | verified | +| T-19.4-12 | Refresh is off by default (`refresh.enabled: false`); only an `OfflineAccess` client granted `offline_access` receives one; rotation on every use under `FOR UPDATE`; a replayed rotated token revokes the lineage and its access tokens. | W `oidc_token.go:286`, `:303-425`; P `classes/oidc_store.go:426`, `:462`; P `config/config.yaml:45` | `TestOIDCRefreshOptInSmoke` (summercms.go) | verified | +| T-19.4-13 | `sub` is a random UUIDv4 per (user, sector), inserted once (`ON CONFLICT DO NOTHING` then read back); pairwise clients get per-sector subs. | W `oidc_subject.go:67`; P `classes/oidc_store.go:374` | `TestOIDCSubjectIsRandomSmoke` (new), `TestPairwiseSubjectSmoke` (summercms.go) | fixed in commit eaa545c (test pin; code unchanged) | +| T-19.4-14 | Flavour dispatch at the top of `Authorize` and `Token`; MCP encoders untouched; OIDC-only handlers 404 on an MCP server. | W `authorize.go:35`, `token.go:67`, `server.go:179` | the full wristband suite incl. phase 08 MCP tests, `TestDefaultOptionsResourceEmpty` (summercms.go); `TestOAuthResourceExplicit` (fonoteka.go, run in plan 01) | verified | +| T-19.4-15 | Private PEM stored as `lagoon.Encrypted` with `json:"-"`, decrypted only inside the key ring; key reads and writes use a silent GORM logger; key write errors are reduced to the SQLSTATE; never an admin controller, field or column. | P `models/signing_key.go:21`; P `classes/keyring.go:207`, `:242`, `:273`; P `controllers/admin_registry.go:12` | `TestOIDCFlowSmoke` (stored value is ciphertext), `TestKeyRingFirstKeyRaceSmoke` (no key material in output), `TestAdminSurfaceHidesCredentials` (new) (sm-oauthserver-plugin) | verified | +| T-19.4-16 | Partial unique index `oas_keys_one_active`; first key inserted with `ON CONFLICT (active) WHERE active DO NOTHING`; the loser re-reads the winner. | P `updates/202610190001_create_golem15_oauthserver_tables.go:153`; P `classes/keyring.go:221-237` | `TestKeyRingFirstKeyRaceSmoke` (eight instances, one key) (sm-oauthserver-plugin) | verified | +| T-19.4-17 | The engine sets `X-Frame-Options: DENY`, `frame-ancestors 'none'`, `no-store, private` and `no-referrer` before the host renders a consent or error page. | W `oidc_authorize.go:366-377` | `assertRendered` in the authorize smokes (summercms.go); `TestConsentPageBrandingSmoke` (sm-oauthserver-plugin) | verified | +| T-19.4-18 | `html/template` everywhere (no `template.HTML`/`JS`/`URL` in the plugin); colours, font family, https-only font URL and custom CSS pass the sanitisers before `template.CSS`, and `brandingStyle` re-sanitises; custom CSS capped at 20000 and stripped of tag openers until stable; the danger colour is fixed. The admin form checks hex colours, font family and https font URL on save. Admin partials render through cabana's `html/template`. | P `classes/host.go:8`; P `classes/branding.go:146`, `:191`, `:208`; P `controllers/branding.go` (`checkBranding`) | `TestConsentPageBrandingSmoke` (`