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.
|
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).
|
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
|
Factor-read custody, automatic renewal and mounted-file delivery were restored
|
||||||
and account-portal registrations inherit it. Configured token-list credentials
|
on 2026-09-13 through RPF-WP-0040 / CCR-2026-0023. KeyCape uses adminTokenFile;
|
||||||
return HTTP 401, so enabling this setting now would replace the OTP prompt with
|
per-user provider lookup and mounted renewal passed. Historical resolver incident
|
||||||
a lookup failure. No live policy has been changed.
|
lanes remain separate; there is no missing-owner gate for this service lane.
|
||||||
|
|
||||||
Credential owner: railiance-platform / OpenBao, route
|
The live provider has `mfa-passthru-phase1`, so a positive validation value alone
|
||||||
`net-kingdom-privacyidea-admin-token`. Its concrete delivery and renewal contract
|
can mean directory-password success. KeyCape accepts AAL2 only when a successful
|
||||||
is unpublished (`resolvable: false`). Obtain an owner-approved realm-scoped
|
response identifies a TOTP/HOTP token by serial and type. Static-password tokens,
|
||||||
factor-read credential through the native custody path, with renewal and
|
missing factor evidence and unsuccessful status cannot grant AAL2.
|
||||||
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.
|
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
|
1. Verify the deployed provider accepts the raw JWT and returns authoritative
|
||||||
count/tokens results for controlled accounts with and without a factor.
|
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 {
|
if tok.Active == nil {
|
||||||
return false, fmt.Errorf("privacyidea: token missing active state")
|
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
|
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) {
|
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)
|
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 domain.ErrMFAFailed
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -215,10 +227,15 @@ type tokenListResponse struct {
|
||||||
type tokenEntry struct {
|
type tokenEntry struct {
|
||||||
Serial string `json:"serial"`
|
Serial string `json:"serial"`
|
||||||
Active *bool `json:"active"`
|
Active *bool `json:"active"`
|
||||||
|
RolloutState *string `json:"rollout_state"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateResponse models the privacyIDEA POST /validate/check response envelope.
|
// validateResponse models the privacyIDEA POST /validate/check response envelope.
|
||||||
type validateResponse struct {
|
type validateResponse struct {
|
||||||
|
Detail struct {
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"detail"`
|
||||||
Result struct {
|
Result struct {
|
||||||
Status bool `json:"status"`
|
Status bool `json:"status"`
|
||||||
Value bool `json:"value"`
|
Value bool `json:"value"`
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ func tokenListResponse(tokens []map[string]interface{}) string {
|
||||||
tokenJSON += ","
|
tokenJSON += ","
|
||||||
}
|
}
|
||||||
active, _ := t["active"].(bool)
|
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 += "]"
|
tokenJSON += "]"
|
||||||
return fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":%s,"count":%d}}}`, tokenJSON, len(tokens))
|
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.
|
// validateResponse builds a privacyIDEA /validate/check JSON response.
|
||||||
func validateResponse(success bool) string {
|
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) {
|
adapter := privacyidea.New(cfg, &mockHTTPClient{doFn: func(req *http.Request) (*http.Response, error) {
|
||||||
seen = append(seen, req.Header.Get("Authorization"))
|
seen = append(seen, req.Header.Get("Authorization"))
|
||||||
if req.URL.Path == "/validate/check" {
|
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
|
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" {
|
if req.Header.Get("Authorization") != "service-jwt" {
|
||||||
t.Fatal("provider requires raw 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")
|
required, err := adapter.HasEnrolledFactor(context.Background(), "alice")
|
||||||
if err != nil || !required {
|
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.
|
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.
|
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