Unverified Commit 54bc6ef2 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

fix(api): clear bound passkeys on password change to close a takeover foothold

handleChangePassword revoked other sessions but never cleared webauthn_credentials, and enrollment needs no step-up. A passkey planted through a transiently-hijacked session needs no password, so it survived the reset + session-revoke as a standing login foothold. Add DeleteAllPasskeyCredentialsForUser and call it in the change-password remediation so every passkey is unbound alongside the session revoke. Removing zero rows is a successful no-op. Email-OTP remains the fallback factor, so this never locks anyone out; the user re-enrolls a passkey afterward if they want one.
parent 7278cd7c
Loading
Loading
Loading
Loading
+11 −0
Changes for internal/api/api_test.go: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -359,6 +359,17 @@ func (f *fakeRepo) DeletePasskeyCredential(_ context.Context, userID, id string)
	return ErrNotFound
}

// DeleteAllPasskeyCredentialsForUser mirrors PGRepo: unbind every passkey the user holds,
// and removing zero is a successful no-op (never ErrNotFound).
func (f *fakeRepo) DeleteAllPasskeyCredentialsForUser(_ context.Context, userID string) error {
	for id, c := range f.passkeyCreds {
		if c.UserID == userID {
			delete(f.passkeyCreds, id)
		}
	}
	return nil
}

// fakePasskeyVerifier is the hermetic PasskeyVerifier: it performs no real attestation
// crypto, so it exercises the enrollment STATE MACHINE (challenge persistence, consume,
// conflict, audit) without go-webauthn. BeginRegistration returns a fixed options blob
+10 −0
Changes for internal/api/handlers_auth.go: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -218,6 +218,16 @@ func (a *API) handleChangePassword(w http.ResponseWriter, r *http.Request) {
		return
	}

	// Revoking sessions is not enough: a passkey needs no password, so one planted
	// through a transiently-hijacked session would outlive the reset as a standing login
	// foothold. A password change is a possible-compromise signal, so unbind every passkey
	// as part of the same remediation. The user re-enrolls afterward if they want one; the
	// email-OTP factor stays available in the meantime, so this never locks anyone out.
	if err := a.Repo.DeleteAllPasskeyCredentialsForUser(r.Context(), u.ID); err != nil {
		writeError(w, r, err)
		return
	}

	a.audit(r, u.Username, "auth.password_change", "")
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+7 −0
Changes for internal/api/handlers_auth_test.go: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -244,6 +244,10 @@ func TestHandleChangePasswordSuccess(t *testing.T) {
	// no felis_session cookie, so keep="" and every session of u1 is revoked — the
	// safe direction the handler documents.
	repo.sessions["other-device"] = &fakeSession{userID: "u1", expiresAt: api.now().Add(time.Hour)}
	// A bound passkey for u1: the change must unbind it too. A passkey planted through a
	// hijacked session needs no password, so it would otherwise survive the reset as a
	// standing login foothold.
	repo.passkeyCreds["pk1"] = PasskeyCredential{ID: "pk1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k"}

	w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
		`{"current_password":"old-password","new_password":"brand-new-password"}`, jsonHeader)
@@ -261,6 +265,9 @@ func TestHandleChangePasswordSuccess(t *testing.T) {
	if !repo.sessions["other-device"].revoked {
		t.Fatalf("other sessions should be revoked on a password change")
	}
	if len(repo.passkeyCreds) != 0 {
		t.Fatalf("password change left %d passkeys, want 0 — a planted passkey must not survive remediation", len(repo.passkeyCreds))
	}
}

// TestHandleChangePasswordContentTypeGuard pins the defense-in-depth guard on the
+11 −0
Changes for internal/api/pgrepo.go: 11 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -997,3 +997,14 @@ func (p *PGRepo) DeletePasskeyCredential(ctx context.Context, userID, id string)
	}
	return nil
}

// DeleteAllPasskeyCredentialsForUser unbinds every passkey a user holds. Unlike the
// single-credential delete this does NOT report ErrNotFound on zero rows: removing all of
// a user's passkeys when they have none is a successful no-op, since "the user holds no
// passkeys" is exactly the intended post-condition. The change-password flow calls it so a
// passkey planted through a transiently-hijacked session cannot survive the remediation.
func (p *PGRepo) DeleteAllPasskeyCredentialsForUser(ctx context.Context, userID string) error {
	_, err := p.db.ExecContext(ctx,
		`DELETE FROM webauthn_credentials WHERE user_id = $1`, userID)
	return err
}
+6 −0
Changes for internal/api/repo.go: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -291,6 +291,12 @@ type Repo interface {
	// can only unbind their OWN credential. No matching (user, id) row → ErrNotFound,
	// so a stale or cross-user id cannot silently no-op as success.
	DeletePasskeyCredential(ctx context.Context, userID, id string) error
	// DeleteAllPasskeyCredentialsForUser unbinds every passkey a user holds. The
	// change-password flow calls it so a passkey planted via a transiently-hijacked
	// session does not survive the remediation (password reset + session revoke) as a
	// standing login foothold. 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

	// ---- player game-login: username-collision reclaim (spec §B3) ----