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:
9 files changed
+297
-17
No files matched your search
@@ -1150,6 +1150,18 @@ func (p *PGRepo) PasskeyCredentialsForUser(ctx context.Context, userID string) (
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AdvanceCredentialSignCount records a successful assertion on the passkey identified by
|
||||
// credentialID: it advances the stored signature counter to newSignCount and stamps
|
||||
// last_used_at. credential_id is UNIQUE so exactly one row is touched; a missing row (the
|
||||
// credential was unbound mid-ceremony) affects zero rows and is a successful no-op, never an
|
||||
// error — the assertion is already cryptographically complete by the time this runs.
|
||||
func (p *PGRepo) AdvanceCredentialSignCount(ctx context.Context, credentialID string, newSignCount uint32, usedAt time.Time) error {
|
||||
_, err := p.db.ExecContext(ctx,
|
||||
`UPDATE webauthn_credentials SET sign_count = $2, last_used_at = $3 WHERE credential_id = $1`,
|
||||
credentialID, int64(newSignCount), usedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeletePasskeyCredential removes the passkey row id, scoped to userID so a caller can
|
||||
// only unbind their OWN credential. No matching (user, id) row → ErrNotFound via a zero
|
||||
// RowsAffected, so a stale or cross-user id cannot silently no-op as success.
|
||||
|
||||
Reference in new issue
Block a user