feat: require account password for passkey enrollment and revocation

A hijacked session must not be able to silently add a passkey as a
persistent backdoor or remove the victim's credentials. Registration
(begin) and deletion now verify the account password server-side,
reusing the existing PASSWORD_REQUIRED / PASSWORD_INCORRECT errors.

The password is used instead of TOTP step-up so the guard also protects
deployments that never configured a TOTP encryption key. The password
key in both request bodies is covered by the audit middleware's
key-substring redaction, so no credential material reaches audit_logs.

Frontend: the add-passkey form gains a current-password field, and the
delete confirmation is now a dialog with a password input (replacing
window.confirm), mirroring the TOTP disable dialog. Backend error
messages (e.g. wrong password) are surfaced instead of the generic
failure toast. Rename remains password-free as it is cosmetic.
This commit is contained in:
shaw
2026-07-28 14:12:46 +08:00
parent 97f44b21bb
commit 38ef8dc069
8 changed files with 302 additions and 31 deletions
+63 -4
View File
@@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { get, post, patch, remove, credentialGet } = vi.hoisted(() => ({
const { get, post, patch, remove, credentialGet, credentialCreate } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
remove: vi.fn(),
credentialGet: vi.fn()
credentialGet: vi.fn(),
credentialCreate: vi.fn()
}))
vi.mock('@/api/client', () => ({
@@ -24,7 +25,7 @@ class FakePublicKeyCredential {
rawId = Uint8Array.from([1, 2, 3]).buffer
type = 'public-key'
authenticatorAttachment = 'platform'
response = {
response: Record<string, unknown> = {
authenticatorData: Uint8Array.from([4, 5]).buffer,
clientDataJSON: Uint8Array.from([6, 7]).buffer,
signature: Uint8Array.from([8, 9]).buffer,
@@ -36,6 +37,17 @@ class FakePublicKeyCredential {
}
}
class FakeRegistrationCredential extends FakePublicKeyCredential {
constructor() {
super()
this.response = {
attestationObject: Uint8Array.from([12, 13]).buffer,
clientDataJSON: Uint8Array.from([6, 7]).buffer,
getTransports: () => ['internal']
}
}
}
describe('passkey api', () => {
beforeEach(() => {
get.mockReset()
@@ -43,6 +55,7 @@ describe('passkey api', () => {
patch.mockReset()
remove.mockReset()
credentialGet.mockReset()
credentialCreate.mockReset()
vi.stubGlobal('PublicKeyCredential', FakePublicKeyCredential)
Object.defineProperty(window, 'PublicKeyCredential', {
@@ -51,7 +64,7 @@ describe('passkey api', () => {
})
Object.defineProperty(navigator, 'credentials', {
configurable: true,
value: { get: credentialGet }
value: { get: credentialGet, create: credentialCreate }
})
})
@@ -105,4 +118,50 @@ describe('passkey api', () => {
}
})
})
it('sends the account password when beginning registration', async () => {
post
.mockResolvedValueOnce({
data: {
session_token: 'register-session',
options: {
publicKey: {
challenge: 'AQID',
user: { id: 'BAU', name: 'user@example.com', displayName: 'user' }
}
}
}
})
.mockResolvedValueOnce({
data: { id: 3, name: 'Laptop', created_at: '2026-07-28T00:00:00Z', backup: false }
})
credentialCreate.mockResolvedValue(new FakeRegistrationCredential())
await passkeyAPI.register('Laptop', 'hunter2')
expect(post).toHaveBeenNthCalledWith(1, '/user/passkeys/register/begin', {
password: 'hunter2'
})
expect(post).toHaveBeenNthCalledWith(2, '/user/passkeys/register/finish', {
session_token: 'register-session',
name: 'Laptop',
credential: expect.objectContaining({
response: {
attestationObject: 'DA0',
clientDataJSON: 'Bgc',
transports: ['internal']
}
})
})
})
it('sends the account password when revoking a credential', async () => {
remove.mockResolvedValue({ data: null })
await passkeyAPI.remove(12, 'hunter2')
expect(remove).toHaveBeenCalledWith('/user/passkeys/12', {
data: { password: 'hunter2' }
})
})
})
+5 -4
View File
@@ -122,10 +122,11 @@ async function login(): Promise<AuthResponse> {
return data
}
async function register(name: string): Promise<PasskeyCredentialSummary> {
async function register(name: string, password: string): Promise<PasskeyCredentialSummary> {
requirePasskeySupport()
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
'/user/passkeys/register/begin'
'/user/passkeys/register/begin',
{ password }
)
const credential = await navigator.credentials.create({
publicKey: creationOptionsFromJSON(begin.options.publicKey)
@@ -153,8 +154,8 @@ async function rename(id: number, name: string): Promise<void> {
await apiClient.patch(`/user/passkeys/${id}`, { name })
}
async function remove(id: number): Promise<void> {
await apiClient.delete(`/user/passkeys/${id}`)
async function remove(id: number, password: string): Promise<void> {
await apiClient.delete(`/user/passkeys/${id}`, { data: { password } })
}
export const passkeyAPI = {