feat(passkey): advance sign_count, reject clone-warned assertions

Both login doors (username-first and discoverable) now run a shared applyAssertionCounter after a verified assertion. A signature-counter regression — go-webauthn's CloneWarning, the possible-cloned-authenticator signal — is refused fail-closed with the same opaque passkey_login_invalid envelope any other finish failure returns (no clone oracle to a prober) and audited distinctly as auth.passkey_clone_rejected under the resolved account. A clean assertion advances the stored sign_count to the asserted value and stamps last_used_at, before any session is minted.

Counter-less/synced authenticators report 0 and never warn, so they pass through and simply re-stamp 0; the check gates only counter-keeping hardware authenticators, where a rollback is the meaningful signal. Email-OTP and username-first passkey remain fallbacks, so a rejected clone is never bricked.

Adds Repo.AdvanceCredentialSignCount (pgrepo UPDATE by credential_id) and surfaces CloneWarning from the internal/passkey adapter's FinishLogin/FinishDiscoverableLogin. Proven by real-crypto adapter tests (a counter regression still verifies but flags CloneWarning), handler tests (advance-and-stamp on success, fail-closed on clone), and a symmetric test on each door so both call sites of the shared helper are covered.
This commit is contained in:
flyemoji committed 2026-07-05 16:02:30 +09:00
1 parent 0dbd557a7a
commit 9e1df12975
9 files changed
+297 -17

No files matched your search

+15 -1
View File
@@ -157,7 +157,8 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
// stable username so a nil email never matters.
return PasskeyUser{ID: u.ID, Name: u.Username, DisplayName: u.Username, Credentials: creds}, nil
}
if _, err := a.Passkey.FinishDiscoverableLogin(resolve, sessionData, bytes.NewReader(req.Assertion)); err != nil {
va, err := a.Passkey.FinishDiscoverableLogin(resolve, sessionData, bytes.NewReader(req.Assertion))
if err != nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
@@ -171,6 +172,19 @@ 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.audit(r, resolved.Username, "auth.passkey_clone_rejected", va.CredentialID)
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
writeError(w, r, err)
return
}
token, err := newSessionToken()
if err != nil {