fix(passkey): 断言时按凭据校验 UV,并把存储的 BE/BS 标志交给校验器,云同步 passkey 可以登录 (#9)
This commit is contained in:
13 files changed
+319
-96
No files matched your search
+23
-10
@@ -134,17 +134,30 @@ func (a *API) authFailure(r *http.Request, door, reason string, u *StaffUser) {
|
||||
a.auditEntry(r, e)
|
||||
}
|
||||
|
||||
// passkeyCloneRejected records an assertion refused for a regressed signature
|
||||
// counter. It keeps its own action so a cloned authenticator stands out from
|
||||
// ordinary failures, and counts as a failure of its door. u nil: a signed-in
|
||||
// step-up, attributed to the caller.
|
||||
func (a *API) passkeyCloneRejected(r *http.Request, door string, u *StaffUser, credentialID string) {
|
||||
metrics.AuthFailuresTotal.WithLabelValues(door, "clone_rejected").Inc()
|
||||
if u == nil {
|
||||
a.audit(r, "auth.passkey_clone_rejected", credentialID)
|
||||
return
|
||||
// passkeyAssertionRejected records an assertion applyAssertion refused on policy: a
|
||||
// regressed signature counter (auth.passkey_clone_rejected) or a user the credential
|
||||
// or the assertion did not verify (auth.passkey_uv_rejected). Each keeps its own action
|
||||
// so it stands out from ordinary failures, and counts as a failure of its door. u nil:
|
||||
// a signed-in step-up, attributed to the caller. It reports false, recording nothing,
|
||||
// for any other error, which the caller answers as a fault.
|
||||
func (a *API) passkeyAssertionRejected(r *http.Request, door string, u *StaffUser, credentialID string, err error) bool {
|
||||
var reason string
|
||||
switch {
|
||||
case errors.Is(err, errPasskeyClonedAuthenticator):
|
||||
reason = "clone_rejected"
|
||||
case errors.Is(err, errPasskeyUserNotVerified):
|
||||
reason = "uv_rejected"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
a.auditAccount(r, u, "auth.passkey_clone_rejected", credentialID)
|
||||
metrics.AuthFailuresTotal.WithLabelValues(door, reason).Inc()
|
||||
action := "auth.passkey_" + reason
|
||||
if u == nil {
|
||||
a.audit(r, action, credentialID)
|
||||
} else {
|
||||
a.auditAccount(r, u, action, credentialID)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isOTPRefusal reports whether err is a refused code (wrong, spent, or the
|
||||
|
||||
@@ -149,7 +149,7 @@ func TestMigrateForcePasskey(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
|
||||
repo.links[uuid] = "u1"
|
||||
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
|
||||
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", UserVerified: true, CreatedAt: frozenNow}
|
||||
|
||||
mk, mailer, _ := migrateEnv(repo)
|
||||
ih := mk(src).InternalHandler()
|
||||
@@ -178,7 +178,7 @@ func TestMigratePasskeyConfirm(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
|
||||
repo.links[uuid] = "u1"
|
||||
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
|
||||
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", UserVerified: true, CreatedAt: frozenNow}
|
||||
|
||||
mk, _, v := migrateEnv(repo)
|
||||
ih := mk(src).InternalHandler()
|
||||
@@ -227,7 +227,7 @@ func TestMigratePasskeyCloneRejected(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
|
||||
repo.links[uuid] = "u1"
|
||||
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
|
||||
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", UserVerified: true, CreatedAt: frozenNow}
|
||||
|
||||
mk, _, v := migrateEnv(repo)
|
||||
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, CloneWarning: true}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -161,11 +162,11 @@ type VerifiedCredential struct {
|
||||
// and whether that counter regressed (a possible clone). Like VerifiedCredential it carries
|
||||
// no secret. SignCount is a raw ceremony fact, NOT a policy verdict; CloneWarning IS the
|
||||
// verifier's regression verdict, but the refuse-vs-allow decision is the handler's. Clone
|
||||
// policy therefore lives in one place with the stored counter (applyAssertionCounter, which
|
||||
// both login doors call). SignCount is legitimately 0 for authenticators that keep no counter.
|
||||
// policy therefore lives in one place with the stored counter (applyAssertion, which
|
||||
// every assertion door calls). SignCount is legitimately 0 for authenticators that keep no counter.
|
||||
//
|
||||
// applyAssertionCounter is that single consumer: it refuses a CloneWarning fail-closed and,
|
||||
// on success, advances the stored counter and stamps last_used_at. This is the stable seam
|
||||
// applyAssertion is that single consumer: it refuses an unverified user or a CloneWarning
|
||||
// fail-closed and, on success, advances the stored counter and stamps last_used_at. This is the stable seam
|
||||
// output the production adapter (internal/passkey) produces and its Oracle test asserts on,
|
||||
// so handler and adapter agree on shape without either reshaping the other.
|
||||
type VerifiedAssertion struct {
|
||||
@@ -177,9 +178,10 @@ type VerifiedAssertion struct {
|
||||
// assertion and structurally never raise it. The login handlers refuse it fail-closed.
|
||||
CloneWarning bool
|
||||
// UserVerified records that a PIN/biometric (not mere presence) was performed
|
||||
// during the assertion ceremony. The verifier enforces UV=required at BeginLogin,
|
||||
// so this is always true for a successful assertion; persisting it makes the
|
||||
// guarantee auditable and survives a future policy that permits UV=preferred.
|
||||
// during this assertion. The verifier asks for UV=required today, so a successful
|
||||
// assertion carries it; applyAssertion checks it anyway, together with the
|
||||
// credential's stored bind-time flag, so a later UV=preferred policy or a door that
|
||||
// asks for less cannot let a presence-only assertion through.
|
||||
UserVerified bool
|
||||
}
|
||||
|
||||
@@ -188,22 +190,40 @@ type VerifiedAssertion struct {
|
||||
var errPasskeyUnavailable = newError(http.StatusServiceUnavailable, "passkey_unavailable",
|
||||
"passkey subsystem is not configured")
|
||||
|
||||
// errPasskeyClonedAuthenticator is the internal signal from applyAssertionCounter that a
|
||||
// errPasskeyClonedAuthenticator is the internal signal from applyAssertion that a
|
||||
// verified assertion carried a clone warning (its signature counter did not advance past the
|
||||
// stored value). It never reaches the client verbatim: the login doors map it to the generic
|
||||
// passkey_login_invalid envelope — no clone oracle to a prober — and audit it distinctly.
|
||||
var errPasskeyClonedAuthenticator = errors.New("passkey assertion rejected: clone warning")
|
||||
|
||||
// applyAssertionCounter is the single consumer of a verified assertion's signature-counter
|
||||
// facts, shared by the username-first (handlePasskeyLoginFinish) and discoverable
|
||||
// (handlePasskeyLoginDiscoverableFinish) login doors so clone policy lives in one place with
|
||||
// the stored counter. A CloneWarning fails closed with errPasskeyClonedAuthenticator;
|
||||
// otherwise it advances the stored counter to the asserted value and stamps last_used_at.
|
||||
// Counter-less/synced authenticators report 0 and never warn, so they pass through and simply
|
||||
// re-stamp 0 — the check gates only counter-keeping authenticators, where a rollback is the
|
||||
// meaningful clone signal. It runs BEFORE the session is minted, so a clone or a persist
|
||||
// failure denies the login rather than leaving an advanced counter with no session.
|
||||
func (a *API) applyAssertionCounter(ctx context.Context, va VerifiedAssertion) error {
|
||||
// errPasskeyUserNotVerified is the internal signal from applyAssertion that the assertion,
|
||||
// or the credential it came from, did not verify the user. Like the clone signal it is
|
||||
// answered with the opaque envelope and audited distinctly.
|
||||
var errPasskeyUserNotVerified = errors.New("passkey assertion rejected: user not verified")
|
||||
|
||||
// applyAssertion is the single consumer of a verified assertion, shared by the
|
||||
// username-first and discoverable login doors and the step-up confirmation, so assertion
|
||||
// policy lives in one place with the stored credential. creds are the account's bound
|
||||
// passkeys the verifier checked the assertion against.
|
||||
//
|
||||
// Every one of those doors grants a session or confirms one, so each needs user
|
||||
// verification, per credential (migration 0009): the credential must have verified the
|
||||
// user when it was bound, and this assertion must have verified the user now. Either
|
||||
// missing fails closed with errPasskeyUserNotVerified. A credential bound presence-only
|
||||
// (a pre-0009 row, or a future UV=preferred enrollment) therefore cannot sign in; its
|
||||
// owner still has the email-code door.
|
||||
//
|
||||
// A CloneWarning fails closed with errPasskeyClonedAuthenticator; otherwise it advances
|
||||
// the stored counter to the asserted value and stamps last_used_at. Counter-less/synced
|
||||
// authenticators report 0 and never warn, so they pass through and simply re-stamp 0 — the
|
||||
// check gates only counter-keeping authenticators, where a rollback is the meaningful clone
|
||||
// signal. It runs BEFORE the session is minted, so a refusal or a persist failure denies
|
||||
// the login rather than leaving an advanced counter with no session.
|
||||
func (a *API) applyAssertion(ctx context.Context, va VerifiedAssertion, creds []PasskeyCredential) error {
|
||||
bound := slices.IndexFunc(creds, func(c PasskeyCredential) bool { return c.CredentialID == va.CredentialID })
|
||||
if !va.UserVerified || bound < 0 || !creds[bound].UserVerified {
|
||||
return errPasskeyUserNotVerified
|
||||
}
|
||||
if va.CloneWarning {
|
||||
return errPasskeyClonedAuthenticator
|
||||
}
|
||||
@@ -710,12 +730,11 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
// Clone policy + counter advance, in one place shared with the discoverable door. A
|
||||
// regressed counter is refused with the same opaque envelope (no clone oracle) but audited
|
||||
// distinctly; a successful assertion advances the stored counter and stamps last_used_at.
|
||||
if err := a.applyAssertionCounter(r.Context(), va); err != nil {
|
||||
if errors.Is(err, errPasskeyClonedAuthenticator) {
|
||||
a.passkeyCloneRejected(r, "passkey", u, va.CredentialID)
|
||||
// UV + clone policy + counter advance, in one place shared with the discoverable door. A
|
||||
// refusal is answered with the same opaque envelope (no oracle) but audited distinctly; a
|
||||
// successful assertion advances the stored counter and stamps last_used_at.
|
||||
if err := a.applyAssertion(r.Context(), va, creds); err != nil {
|
||||
if a.passkeyAssertionRejected(r, "passkey", u, va.CredentialID, err) {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
|
||||
@@ -148,6 +148,7 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
|
||||
// the session below is minted for the account the assertion actually resolved AND verified to
|
||||
// — not anything the client supplied (the body carries only a challenge handle).
|
||||
var resolved *StaffUser
|
||||
var resolvedCreds []PasskeyCredential
|
||||
resolve := func(userHandle []byte) (PasskeyUser, error) {
|
||||
u, err := a.Repo.UserByID(r.Context(), string(userHandle))
|
||||
if err != nil {
|
||||
@@ -164,7 +165,7 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
|
||||
if err != nil {
|
||||
return PasskeyUser{}, err
|
||||
}
|
||||
resolved = u
|
||||
resolved, resolvedCreds = u, creds
|
||||
// Name/DisplayName are cosmetic at assertion time (nothing is shown to the user); use the
|
||||
// stable username so a nil email never matters.
|
||||
return PasskeyUser{ID: u.ID, Name: u.Username, DisplayName: u.Username, Credentials: creds}, nil
|
||||
@@ -187,12 +188,11 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
// Same clone policy + counter advance as the username-first door (applyAssertionCounter): a
|
||||
// regressed counter is refused with the identical opaque envelope but audited under the
|
||||
// resolved account; a successful assertion advances the stored counter and stamps last_used_at.
|
||||
if err := a.applyAssertionCounter(r.Context(), va); err != nil {
|
||||
if errors.Is(err, errPasskeyClonedAuthenticator) {
|
||||
a.passkeyCloneRejected(r, "passkey_discoverable", resolved, va.CredentialID)
|
||||
// Same UV + clone policy + counter advance as the username-first door (applyAssertion): a
|
||||
// refusal gets the identical opaque envelope but is audited under the resolved account; a
|
||||
// successful assertion advances the stored counter and stamps last_used_at.
|
||||
if err := a.applyAssertion(r.Context(), va, resolvedCreds); err != nil {
|
||||
if a.passkeyAssertionRejected(r, "passkey_discoverable", resolved, va.CredentialID, err) {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
|
||||
@@ -44,7 +44,7 @@ func seedDiscoverableLoginAPI(t *testing.T) (*API, *fakeRepo, *fakePasskeyVerifi
|
||||
Role: "user", EmailVerified: true,
|
||||
}
|
||||
repo.passkeyCreds["row1"] = PasskeyCredential{
|
||||
ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow,
|
||||
ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", UserVerified: true, CreatedAt: frozenNow,
|
||||
}
|
||||
v := &fakePasskeyVerifier{
|
||||
options: json.RawMessage(`{"publicKey":{"challenge":"ZGlzYw"}}`),
|
||||
|
||||
@@ -46,7 +46,7 @@ func seedLoginPasskeyAPI(t *testing.T) (*API, *fakeRepo, *fakePasskeyVerifier) {
|
||||
Role: "user", EmailVerified: true,
|
||||
}
|
||||
repo.passkeyCreds["row1"] = PasskeyCredential{
|
||||
ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow,
|
||||
ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", UserVerified: true, CreatedAt: frozenNow,
|
||||
}
|
||||
v := &fakePasskeyVerifier{
|
||||
options: json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`),
|
||||
@@ -494,7 +494,7 @@ func TestPasskeyLoginAllowsStaff(t *testing.T) {
|
||||
Role: "admin", EmailVerified: true,
|
||||
}
|
||||
repo.passkeyCreds["row1"] = PasskeyCredential{
|
||||
ID: "row1", UserID: "a1", CredentialID: "cred-a1", PublicKey: "k", CreatedAt: frozenNow,
|
||||
ID: "row1", UserID: "a1", CredentialID: "cred-a1", PublicKey: "k", UserVerified: true, CreatedAt: frozenNow,
|
||||
}
|
||||
v := &fakePasskeyVerifier{
|
||||
options: json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`),
|
||||
@@ -534,7 +534,7 @@ func TestPasskeyLoginFaceSeparation(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestPasskeyLoginFinishCloneRejected is the username-first mirror of the discoverable door's
|
||||
// clone refusal (task #40 item 5). Both doors share applyAssertionCounter, but each WIRES it
|
||||
// clone refusal (task #40 item 5). Both doors share applyAssertion, but each WIRES it
|
||||
// independently, so this proves the username-first finish also fails closed on a CloneWarning:
|
||||
// the same opaque passkey_login_invalid envelope (no clone oracle), no session minted, the stored
|
||||
// credential left untouched at its enrollment-time counter, and a distinct auth.passkey_clone_
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// uvCases are the ways an assertion can lack user verification (#9): the credential
|
||||
// was bound presence-only (a pre-0009 row, or a future UV=preferred enrollment), this
|
||||
// assertion itself did not verify the user, or it names a credential the account does
|
||||
// not hold. Each must be refused at every door that grants or confirms a session.
|
||||
var uvCases = []struct {
|
||||
name string
|
||||
storedUV bool
|
||||
assertUV bool
|
||||
assertCred string
|
||||
}{
|
||||
{"credential bound without UV", false, true, ""},
|
||||
{"assertion without UV", true, false, ""},
|
||||
{"credential not bound to the account", true, true, "cred-elsewhere"},
|
||||
}
|
||||
|
||||
func TestPasskeyLoginRefusesUnverifiedUser(t *testing.T) {
|
||||
for _, tc := range uvCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
api, repo, v := seedLoginPasskeyAPI(t)
|
||||
row := repo.passkeyCreds["row1"]
|
||||
row.UserVerified = tc.storedUV
|
||||
repo.passkeyCreds["row1"] = row
|
||||
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: tc.assertUV, SignCount: 3}
|
||||
if tc.assertCred != "" {
|
||||
v.assertion.CredentialID = tc.assertCred
|
||||
}
|
||||
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
|
||||
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/passkey/login/finish", finishBody("[email protected]", "YXNzZXJ0"), jsonHeader)
|
||||
assertUVRefused(t, w, repo, "player")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyDiscoverableLoginRefusesUnverifiedUser(t *testing.T) {
|
||||
for _, tc := range uvCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
api, repo, v := seedDiscoverableLoginAPI(t)
|
||||
row := repo.passkeyCreds["row1"]
|
||||
row.UserVerified = tc.storedUV
|
||||
repo.passkeyCreds["row1"] = row
|
||||
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: tc.assertUV, SignCount: 3}
|
||||
if tc.assertCred != "" {
|
||||
v.assertion.CredentialID = tc.assertCred
|
||||
}
|
||||
eh := api.ExternalHandler()
|
||||
|
||||
loginID := beginDiscoverableLogin(t, eh)
|
||||
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish",
|
||||
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
|
||||
assertUVRefused(t, w, repo, "player")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReauthByPasskeyRefusesUnverifiedUser(t *testing.T) {
|
||||
for _, tc := range uvCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := reauthFixture(t)
|
||||
pv := f.api.Passkey.(*fakePasskeyVerifier)
|
||||
f.repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1",
|
||||
UserVerified: tc.storedUV, CreatedAt: frozenNow}
|
||||
pv.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: tc.assertUV, SignCount: 3}
|
||||
if tc.assertCred != "" {
|
||||
pv.assertion.CredentialID = tc.assertCred
|
||||
}
|
||||
if w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
|
||||
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
f.repo.audits = nil
|
||||
w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"cred-1"}}`, jsonCookie(laptopTok))
|
||||
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
|
||||
t.Fatalf("finish = %d (%s), want 400 passkey_login_invalid", w.Code, w.Body.String())
|
||||
}
|
||||
if !f.reauthAt(laptopTok).IsZero() {
|
||||
t.Fatal("an unverified assertion marked the session reauthenticated")
|
||||
}
|
||||
if got := f.repo.passkeyCreds["row1"]; got.SignCount != 0 || got.LastUsedAt != nil {
|
||||
t.Errorf("refusal advanced the credential: SignCount=%d LastUsedAt=%v", got.SignCount, got.LastUsedAt)
|
||||
}
|
||||
if n := len(f.repo.audits); n != 1 || f.repo.audits[0].Action != "auth.passkey_uv_rejected" {
|
||||
t.Fatalf("want exactly 1 auth.passkey_uv_rejected audit, got %+v", f.repo.audits)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertUVRefused checks a sign-in door's refusal: the opaque envelope every finish
|
||||
// failure shares, no session, the stored credential untouched, and one distinct audit
|
||||
// under the account.
|
||||
func assertUVRefused(t *testing.T, rec *httptest.ResponseRecorder, repo *fakeRepo, actor string) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusBadRequest || decodeErr(t, rec) != "passkey_login_invalid" {
|
||||
t.Fatalf("finish = %d (%s), want 400 passkey_login_invalid", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(repo.sessions) != 0 || len(rec.Result().Cookies()) != 0 {
|
||||
t.Fatalf("refusal minted a session: sessions=%d cookies=%v", len(repo.sessions), rec.Result().Cookies())
|
||||
}
|
||||
if got := repo.passkeyCreds["row1"]; got.SignCount != 0 || got.LastUsedAt != nil {
|
||||
t.Errorf("refusal advanced the credential: SignCount=%d LastUsedAt=%v", got.SignCount, got.LastUsedAt)
|
||||
}
|
||||
if n := len(repo.audits); n != 1 || repo.audits[0].Action != "auth.passkey_uv_rejected" || repo.audits[0].Actor != actor {
|
||||
t.Fatalf("want exactly 1 auth.passkey_uv_rejected audit by %s, got %+v", actor, repo.audits)
|
||||
}
|
||||
}
|
||||
@@ -329,13 +329,12 @@ func (a *API) finishStepUpPasskey(w http.ResponseWriter, r *http.Request, p *Pri
|
||||
a.authFailure(r, door, "bad_assertion", nil)
|
||||
return invalid()
|
||||
}
|
||||
// Same clone policy as the login door (applyAssertionCounter): a rolled-back
|
||||
// counter fails closed with the opaque envelope, so a step-up never accepts an
|
||||
// authenticator that login refuses. A clean assertion advances the stored
|
||||
// sign-count, keeping the clone signal meaningful for the next login.
|
||||
if err := a.applyAssertionCounter(r.Context(), va); err != nil {
|
||||
if errors.Is(err, errPasskeyClonedAuthenticator) {
|
||||
a.passkeyCloneRejected(r, door, nil, va.CredentialID)
|
||||
// Same UV and clone policy as the login door (applyAssertion): an unverified
|
||||
// user or a rolled-back counter fails closed with the opaque envelope, so a
|
||||
// step-up never accepts an authenticator that login refuses. A clean assertion
|
||||
// advances the stored sign-count, keeping the clone signal meaningful.
|
||||
if err := a.applyAssertion(r.Context(), va, creds); err != nil {
|
||||
if a.passkeyAssertionRejected(r, door, nil, va.CredentialID, err) {
|
||||
return invalid()
|
||||
}
|
||||
writeError(w, r, err)
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestGuardedChangesNeedARecentReauth(t *testing.T) {
|
||||
t.Run(tc.name+"/"+g.name, func(t *testing.T) {
|
||||
f := reauthFixture(t)
|
||||
f.api.Mailer = &captureMailer{}
|
||||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
||||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", UserVerified: true, CreatedAt: frozenNow}
|
||||
if tc.proof >= 0 {
|
||||
f.setReauth(laptopTok, f.api.now().Add(-tc.proof))
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestPasskeyAloneNeedsReauth(t *testing.T) {
|
||||
func TestAccessCallerNeedsNoReauth(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.staff["op"] = &StaffUser{ID: "u1", Username: "op", Role: "admin", Email: "[email protected]", EmailVerified: true}
|
||||
repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
||||
repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", UserVerified: true, CreatedAt: frozenNow}
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.Passkey = &fakePasskeyVerifier{}
|
||||
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "admin", EmailVerified: true}}
|
||||
@@ -203,7 +203,7 @@ func getReauthStatus(t *testing.T, f *sessionsFixture, tok string) reauthStatusB
|
||||
func TestReauthStatusNamesTheFactors(t *testing.T) {
|
||||
f := reauthFixture(t)
|
||||
f.api.Mailer = &captureMailer{}
|
||||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
||||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", UserVerified: true, CreatedAt: frozenNow}
|
||||
f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow}
|
||||
|
||||
steve := getReauthStatus(t, f, laptopTok)
|
||||
@@ -350,7 +350,7 @@ func TestReauthByEmailNeedsAVerifiedAddress(t *testing.T) {
|
||||
func TestReauthByPasskey(t *testing.T) {
|
||||
f := reauthFixture(t)
|
||||
pv := f.api.Passkey.(*fakePasskeyVerifier)
|
||||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", SignCount: 4, CreatedAt: frozenNow}
|
||||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", SignCount: 4, UserVerified: true, CreatedAt: frozenNow}
|
||||
finish := func() int {
|
||||
t.Helper()
|
||||
if w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
|
||||
@@ -367,7 +367,7 @@ func TestReauthByPasskey(t *testing.T) {
|
||||
t.Fatalf("bad assertion = %d, want 400", code)
|
||||
}
|
||||
pv.failErr = nil
|
||||
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 2, CloneWarning: true}
|
||||
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 2, CloneWarning: true, UserVerified: true}
|
||||
if code := finish(); code != http.StatusBadRequest {
|
||||
t.Fatalf("cloned authenticator = %d, want 400", code)
|
||||
}
|
||||
@@ -375,7 +375,7 @@ func TestReauthByPasskey(t *testing.T) {
|
||||
t.Fatal("a failed assertion marked the session")
|
||||
}
|
||||
|
||||
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5}
|
||||
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5, UserVerified: true}
|
||||
if code := finish(); code != http.StatusOK {
|
||||
t.Fatalf("finish = %d, want 200", code)
|
||||
}
|
||||
@@ -396,8 +396,8 @@ func TestReauthByPasskey(t *testing.T) {
|
||||
func TestReauthPasskeyChallengeIsItsOwnPurpose(t *testing.T) {
|
||||
f := reauthFixture(t)
|
||||
pv := f.api.Passkey.(*fakePasskeyVerifier)
|
||||
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5}
|
||||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
||||
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5, UserVerified: true}
|
||||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", UserVerified: true, CreatedAt: frozenNow}
|
||||
if err := f.repo.CreatePasskeyChallenge(t.Context(), "m1", "u1", passkeyPurposeMigrate, []byte("s"), f.api.now().Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -466,7 +466,7 @@ func onlyNotice(t *testing.T, m *noticeMailer) (to, subject, body string) {
|
||||
|
||||
func TestRemovingAPasskeyMailsTheAccount(t *testing.T) {
|
||||
f, mailer := noticeFixture(t)
|
||||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
|
||||
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", UserVerified: true, CreatedAt: frozenNow}
|
||||
if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", fromIP(asCookie(laptopTok))); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user