Require confirmed enrollment and genuine OTP evidence for MFA
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
3a36f1a507
commit
122a0d1369
7 changed files with 165 additions and 20 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}})
|
||||
|
|
|
|||
101
src/internal/adapters/privacyidea/enrollment_state_test.go
Normal file
101
src/internal/adapters/privacyidea/enrollment_state_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue