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

@@ -317,3 +317,81 @@ func TestPasskeyDiscoverableLoginFaceSeparation(t *testing.T) {
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
}
}
// beginDiscoverableLogin runs the from-zero begin and returns the stashed login_id, so the
// counter/clone tests below need not re-inline the begin ceremony each time.
func beginDiscoverableLogin(t *testing.T, eh http.Handler) string {
t.Helper()
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
loginID, _ := acctBody(t, w)["login_id"].(string)
if loginID == "" {
t.Fatalf("begin returned empty login_id: %s", w.Body.String())
}
return loginID
}
// TestPasskeyDiscoverableLoginAdvancesSignCount proves the success half of task #40 item 5: a
// verified from-zero assertion advances the stored signature counter to the value the
// authenticator reported and stamps last_used_at. Without this the stored counter would sit at
// the enrollment-time 0 forever, leaving the clone check below no moving baseline to judge a
// later regression against.
func TestPasskeyDiscoverableLoginAdvancesSignCount(t *testing.T) {
api, repo, v := seedDiscoverableLoginAPI(t)
// A clean (non-clone) assertion reporting an advanced counter.
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, SignCount: 42}
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)
if w.Code != http.StatusOK {
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
got := repo.passkeyCreds["row1"]
if got.SignCount != 42 {
t.Errorf("stored SignCount = %d, want 42 (advanced to the asserted counter)", got.SignCount)
}
if got.LastUsedAt == nil || !got.LastUsedAt.Equal(frozenNow) {
t.Errorf("stored LastUsedAt = %v, want %v (stamped on a successful assertion)", got.LastUsedAt, frozenNow)
}
}
// TestPasskeyDiscoverableLoginCloneRejected proves the fail-closed half of task #40 item 5: a
// verified assertion carrying a CloneWarning (signature-counter regression — a possible cloned
// authenticator) is refused. The refusal (1) collapses into the same passkey_login_invalid
// envelope as any other finish failure so a prober gets no clone oracle, (2) mints NO session,
// (3) does NOT advance or stamp the stored credential, and (4) is audited distinctly for the
// operator under the resolved account.
func TestPasskeyDiscoverableLoginCloneRejected(t *testing.T) {
api, repo, v := seedDiscoverableLoginAPI(t)
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, SignCount: 3, CloneWarning: true}
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)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("clone finish: code = %d body %s, want 400 passkey_login_invalid (opaque refusal)", w.Code, w.Body.String())
}
if len(repo.sessions) != 0 {
t.Errorf("clone refusal must mint no session, got %d", len(repo.sessions))
}
if len(w.Result().Cookies()) != 0 {
t.Errorf("clone refusal must set no session cookie, got %v", w.Result().Cookies())
}
// The stored credential is untouched: still at enrollment-time counter 0, never stamped.
if got := repo.passkeyCreds["row1"]; got.SignCount != 0 || got.LastUsedAt != nil {
t.Errorf("clone refusal must not advance/stamp the credential, got SignCount=%d LastUsedAt=%v", got.SignCount, got.LastUsedAt)
}
// Audited distinctly, under the resolved account, so the operator sees the clone signal.
if n := len(repo.audits); n != 1 || repo.audits[0].Action != "auth.passkey_clone_rejected" {
t.Fatalf("want exactly 1 auth.passkey_clone_rejected audit, got %+v", repo.audits)
}
if repo.audits[0].Actor != "player" {
t.Errorf("clone audit actor = %q, want player (the resolved account)", repo.audits[0].Actor)
}
}