diff --git a/docs/optional-mfa.md b/docs/optional-mfa.md index dc5d158..9327553 100644 --- a/docs/optional-mfa.md +++ b/docs/optional-mfa.md @@ -18,21 +18,32 @@ administrative tokenlist permission: a user-role token only lists its own factor regardless of the requested username. Never replace it with a self-service JWT. See the [provider API](https://privacyidea.readthedocs.io/en/stable/modules/api/token.html). -## Deployment gate — not yet enabled +## Deployment gate — optional client policy not yet enabled -Live inspection on 2026-09-13 found `requireForAll: true`; both the demo-company -and account-portal registrations inherit it. Configured token-list credentials -return HTTP 401, so enabling this setting now would replace the OTP prompt with -a lookup failure. No live policy has been changed. +Factor-read custody, automatic renewal and mounted-file delivery were restored +on 2026-09-13 through RPF-WP-0040 / CCR-2026-0023. KeyCape uses adminTokenFile; +per-user provider lookup and mounted renewal passed. Historical resolver incident +lanes remain separate; there is no missing-owner gate for this service lane. -Credential owner: railiance-platform / OpenBao, route -`net-kingdom-privacyidea-admin-token`. Its concrete delivery and renewal contract -is unpublished (`resolvable: false`). Obtain an owner-approved realm-scoped -factor-read credential through the native custody path, with renewal and -revocation ownership. Do not put credentials in chat, arguments, work records, -or config examples. The older refresh-pi-token-live.sh needs review before use. +The live provider has `mfa-passthru-phase1`, so a positive validation value alone +can mean directory-password success. KeyCape accepts AAL2 only when a successful +response identifies a TOTP/HOTP token by serial and type. Static-password tokens, +missing factor evidence and unsuccessful status cannot grant AAL2. + +privacyIDEA's `active` flag does not imply completed enrollment. Its verification +policy sets `rollout_state=verify` while leaving `active` unchanged. KeyCape +therefore recognizes `enrolled` and the provider's legacy empty state as enrolled; +`verify`, `clientwait` and `pending` remain incomplete. Missing/unknown/broken +states and incomplete result pages fail closed. Existing verified factors still +require OTP while an additional token is pending. Mandatory and explicit AAL2 +policies remain mandatory throughout onboarding. + +Current provider self-service policy allows TOTP enrollment/deletion/disabling, +but possession confirmation is not yet required. Review and exercise that +transition plus authenticated recovery before enabling the optional client. + +Remaining live acceptance: -After credential delivery: 1. Verify the deployed provider accepts the raw JWT and returns authoritative count/tokens results for controlled accounts with and without a factor. diff --git a/src/internal/adapters/privacyidea/adapter.go b/src/internal/adapters/privacyidea/adapter.go index 5358e8f..bb65b6f 100644 --- a/src/internal/adapters/privacyidea/adapter.go +++ b/src/internal/adapters/privacyidea/adapter.go @@ -99,8 +99,20 @@ func (a *PrivacyIDEAAdapter) hasActiveToken(ctx context.Context, userID string) if tok.Active == nil { return false, fmt.Errorf("privacyidea: token missing active state") } - if *tok.Active { + if !*tok.Active { + continue + } + if tok.RolloutState == nil { + return false, fmt.Errorf("privacyidea: token missing enrollment state") + } + switch *tok.RolloutState { + case "", "enrolled": // Empty is the provider's legacy completed-token state. return true, nil + case "verify", "clientwait", "pending": + // Creation alone is not possession-confirmed enrollment. + continue + default: + return false, fmt.Errorf("privacyidea: unsupported token enrollment state") } } if *parsed.Result.Value.Count > len(parsed.Result.Value.Tokens) { @@ -153,7 +165,7 @@ func (a *PrivacyIDEAAdapter) ValidateMFAToken(ctx context.Context, userID, token return fmt.Errorf("privacyidea: decode validate response: %w", err) } - if !parsed.Result.Status || !parsed.Result.Value { + if !parsed.Result.Status || !parsed.Result.Value || strings.TrimSpace(parsed.Detail.Serial) == "" || (parsed.Detail.Type != "totp" && parsed.Detail.Type != "hotp") { return domain.ErrMFAFailed } return nil @@ -213,12 +225,17 @@ type tokenListResponse struct { // tokenEntry represents a single token entry in the token list response. type tokenEntry struct { - Serial string `json:"serial"` - Active *bool `json:"active"` + Serial string `json:"serial"` + Active *bool `json:"active"` + RolloutState *string `json:"rollout_state"` } // validateResponse models the privacyIDEA POST /validate/check response envelope. type validateResponse struct { + Detail struct { + Serial string `json:"serial"` + Type string `json:"type"` + } `json:"detail"` Result struct { Status bool `json:"status"` Value bool `json:"value"` diff --git a/src/internal/adapters/privacyidea/adapter_test.go b/src/internal/adapters/privacyidea/adapter_test.go index 49a56c6..59f7179 100644 --- a/src/internal/adapters/privacyidea/adapter_test.go +++ b/src/internal/adapters/privacyidea/adapter_test.go @@ -61,7 +61,7 @@ func tokenListResponse(tokens []map[string]interface{}) string { tokenJSON += "," } active, _ := t["active"].(bool) - tokenJSON += fmt.Sprintf(`{"serial":"TOK%d","active":%v}`, i, active) + tokenJSON += fmt.Sprintf(`{"serial":"TOK%d","active":%v,"rollout_state":"enrolled"}`, i, active) } tokenJSON += "]" return fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":%s,"count":%d}}}`, tokenJSON, len(tokens)) @@ -69,7 +69,7 @@ func tokenListResponse(tokens []map[string]interface{}) string { // validateResponse builds a privacyIDEA /validate/check JSON response. func validateResponse(success bool) string { - return fmt.Sprintf(`{"result":{"status":true,"value":%v}}`, success) + return fmt.Sprintf(`{"result":{"status":true,"value":%v},"detail":{"serial":"TESTOTP","type":"totp"}}`, success) } // --------------------------------------------------------------------------- diff --git a/src/internal/adapters/privacyidea/credential_file_test.go b/src/internal/adapters/privacyidea/credential_file_test.go index b4d2cb5..0173152 100644 --- a/src/internal/adapters/privacyidea/credential_file_test.go +++ b/src/internal/adapters/privacyidea/credential_file_test.go @@ -22,7 +22,7 @@ func TestCredentialFileRenewalAndFailureRecovery(t *testing.T) { adapter := privacyidea.New(cfg, &mockHTTPClient{doFn: func(req *http.Request) (*http.Response, error) { seen = append(seen, req.Header.Get("Authorization")) if req.URL.Path == "/validate/check" { - return jsonResponse(`{"result":{"status":true,"value":true}}`), nil + return jsonResponse(`{"result":{"status":true,"value":true},"detail":{"serial":"TESTOTP","type":"totp"}}`), nil } return jsonResponse(`{"result":{"status":true,"value":{"tokens":[],"count":0}}}`), nil }}) diff --git a/src/internal/adapters/privacyidea/enrollment_state_test.go b/src/internal/adapters/privacyidea/enrollment_state_test.go new file mode 100644 index 0000000..6f3b883 --- /dev/null +++ b/src/internal/adapters/privacyidea/enrollment_state_test.go @@ -0,0 +1,101 @@ +package privacyidea_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "keycape/internal/adapters/privacyidea" +) + +func TestEnrollmentStateRequiresPossessionConfirmation(t *testing.T) { + for _, tc := range []struct { + name, state string + required, failure bool + }{ + {"confirmed", `"enrolled"`, true, false}, + {"legacy completed", `""`, true, false}, + {"awaiting OTP", `"verify"`, false, false}, + {"awaiting device", `"clientwait"`, false, false}, + {"backend pending", `"pending"`, false, false}, + {"missing state", `null`, false, true}, + {"unknown state", `"future-state"`, false, true}, + {"broken enrollment", `"broken"`, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + body := fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":[{"active":true,"rollout_state":%s}],"count":1}}}`, tc.state) + client := &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) { return jsonResponse(body), nil }} + a := privacyidea.New(testConfig(), client) + required, err := a.HasEnrolledFactor(context.Background(), "alice") + if required != tc.required || (err != nil) != tc.failure { + t.Fatalf("required=%v, error=%v", required, err) + } + }) + } +} + +func TestEnrollmentConfirmationChangesDecisionOnNextLookup(t *testing.T) { + state := "verify" + client := &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) { + return jsonResponse(fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":[{"active":true,"rollout_state":%q}],"count":1}}}`, state)), nil + }} + a := privacyidea.New(testConfig(), client) + for _, step := range []struct { + state string + required bool + }{{"verify", false}, {"enrolled", true}, {"verify", false}} { + state = step.state + got, err := a.HasEnrolledFactor(context.Background(), "alice") + if err != nil || got != step.required { + t.Fatalf("state %s: required=%v error=%v", state, got, err) + } + } +} + +func TestPendingEnrollmentCannotHideExistingFactor(t *testing.T) { + for _, tc := range []struct { + name, tokens string + count int + required, failure bool + }{ + {"existing verified factor", `[{"active":true,"rollout_state":"verify"},{"active":true,"rollout_state":"enrolled"}]`, 2, true, false}, + {"unseen page is uncertain", `[{"active":true,"rollout_state":"verify"}]`, 2, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + client := &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) { + return jsonResponse(fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":%s,"count":%d}}}`, tc.tokens, tc.count)), nil + }} + got, err := privacyidea.New(testConfig(), client).HasEnrolledFactor(context.Background(), "alice") + if got != tc.required || (err != nil) != tc.failure { + t.Fatalf("required=%v error=%v", got, err) + } + }) + } +} + +func TestPasswordPassthroughCannotGrantAAL2(t *testing.T) { + for _, tc := range []struct { + name, detail string + accepted bool + }{ + {"password passthrough", `{}`, false}, + {"static password token", `{"serial":"STATIC","type":"spass"}`, false}, + {"missing serial", `{"type":"totp"}`, false}, + {"blank serial", `{"serial":" ","type":"totp"}`, false}, + {"missing factor type", `{"serial":"OTP"}`, false}, + {"unknown factor type", `{"serial":"OTP","type":"future"}`, false}, + {"TOTP confirmed", `{"serial":"OTP","type":"totp"}`, true}, + {"HOTP confirmed", `{"serial":"OTP","type":"hotp"}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + client := &mockHTTPClient{doFn: func(*http.Request) (*http.Response, error) { + return jsonResponse(fmt.Sprintf(`{"result":{"status":true,"value":true},"detail":%s}`, tc.detail)), nil + }} + err := privacyidea.New(testConfig(), client).ValidateMFAToken(context.Background(), "alice", "submitted-value") + if (err == nil) != tc.accepted { + t.Fatalf("accepted=%v, error=%v", err == nil, err) + } + }) + } +} diff --git a/src/internal/adapters/privacyidea/optional_mfa_test.go b/src/internal/adapters/privacyidea/optional_mfa_test.go index 3735ed1..13316d5 100644 --- a/src/internal/adapters/privacyidea/optional_mfa_test.go +++ b/src/internal/adapters/privacyidea/optional_mfa_test.go @@ -34,7 +34,7 @@ func TestEnrollmentLookupFiltersBeforePagination(t *testing.T) { if req.Header.Get("Authorization") != "service-jwt" { t.Fatal("provider requires raw JWT") } - return jsonResponse(`{"result":{"status":true,"value":{"tokens":[{"active":true}],"count":20}}}`), nil + return jsonResponse(`{"result":{"status":true,"value":{"tokens":[{"active":true,"rollout_state":"enrolled"}],"count":20}}}`), nil }}) required, err := adapter.HasEnrolledFactor(context.Background(), "alice") if err != nil || !required { diff --git a/workplans/KEY-WP-0035-optional-mfa.md b/workplans/KEY-WP-0035-optional-mfa.md index f70b93f..9441059 100644 --- a/workplans/KEY-WP-0035-optional-mfa.md +++ b/workplans/KEY-WP-0035-optional-mfa.md @@ -76,3 +76,19 @@ All Go regression/conformance suites pass, including five new renewal/validation Consumer source 632b1f1 deployed and Ready 1/1; CI, four provider HTTP checks and twelve live browser checks passed. See docs/credential-renewal-release-2026-09-13.md. T02/T03 retain actual credential delivery and live recovery/policy acceptance. 2026-09-13 custody activation supersedes the earlier owner-handoff gate: the new factor service is live and renewable. T02/T03 now track effective policy/onboarding acceptance, not missing credential ownership. + +## Require confirmed enrollment and actual OTP evidence + +```task +id: KEY-WP-0035-T05 +status: progress +priority: high +``` + +Live provider source inspection found active tokens can remain in verification +state; live policy inspection found password passthrough enabled for users without +factors. Distinguish pending enrollment from completed enrollment and require +TOTP/HOTP serial/type evidence before AAL2. Cover pending/confirmed/cancelled, +existing-factor plus pending enrollment, missing/unknown state, incomplete pages, +password passthrough and static-password token rejection. All Go suites pass +locally; publish and verify the guarded issuer replacement before completion.