Require confirmed enrollment and genuine OTP evidence for MFA
All checks were successful
Authentication acceptance / acceptance (push) Successful in 1m10s
Build and Publish Container Image / build-and-push (push) Successful in 41s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 16:37:08 +02:00
parent 3a36f1a507
commit 122a0d1369
7 changed files with 165 additions and 20 deletions

View file

@ -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.

View file

@ -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"`

View file

@ -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)
}
// ---------------------------------------------------------------------------

View file

@ -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
}})

View file

@ -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)
}
})
}
}

View file

@ -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 {

View file

@ -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.