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

+7 -4
View File
@@ -192,10 +192,11 @@ func (v *Verifier) BeginLogin(user api.PasskeyUser) (json.RawMessage, []byte, er
// reported. go-webauthn checks the challenge, RP id, and origin against server-held values,
// that the asserted credential id is one the user actually holds (it returns
// protocol.ErrorUnknownCredential otherwise), and the signature against the stored COSE
// public key. It does NOT decide clone/regression policy here: the returned SignCount is
// the raw ceremony fact, and the handler — which holds the previously-stored counter —
// decides whether a non-increase is a cloned-authenticator signal. The verified credential
// id is returned base64url so the handler can look up the exact row to update.
// public key, and runs go-webauthn's UpdateCounter so a signature counter that fails to
// advance past the stored value raises CloneWarning. It does NOT decide clone policy here:
// the returned SignCount and CloneWarning are raw ceremony facts, and the handler — the one
// consumer, holding the stored counter — decides (it refuses, fail-closed). The verified
// credential id is returned base64url so the handler can look up the exact row to update.
func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, assertion io.Reader) (api.VerifiedAssertion, error) {
var session webauthn.SessionData
if err := json.Unmarshal(sessionData, &session); err != nil {
@@ -212,6 +213,7 @@ func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, asserti
return api.VerifiedAssertion{
CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID),
SignCount: cred.Authenticator.SignCount,
CloneWarning: cred.Authenticator.CloneWarning,
}, nil
}
@@ -274,6 +276,7 @@ func (v *Verifier) FinishDiscoverableLogin(resolveUser func(userHandle []byte) (
return api.VerifiedAssertion{
CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID),
SignCount: cred.Authenticator.SignCount,
CloneWarning: cred.Authenticator.CloneWarning,
}, nil
}