mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
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:
@@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user