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
View File
@@ -392,6 +392,13 @@ type Repo interface {
// unbinding leaves a re-enrollable credential. Removing zero rows is success, not an
// error — an account with no passkeys is the intended post-condition either way.
DeleteAllPasskeyCredentialsForUser(ctx context.Context, userID string) error
// AdvanceCredentialSignCount records a successful assertion on the passkey identified by
// credentialID (base64url): it sets the stored signature counter to newSignCount and stamps
// last_used_at. credential_id is UNIQUE, so exactly one row is updated; a missing row (the
// credential was unbound mid-ceremony) is a successful no-op, never an error. The login doors
// call it only after clone policy allows the assertion, so for a counter-keeping authenticator
// the stored counter only ever moves forward — the baseline a later regression is judged against.
AdvanceCredentialSignCount(ctx context.Context, credentialID string, newSignCount uint32, usedAt time.Time) error
// ---- player game-login: username-collision reclaim (spec §B3) ----