From 0f6da7d147cf1c7ae1d4b3c85407809342d141a4 Mon Sep 17 00:00:00 2001 From: Shuai <100134295+shuaiplus@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:59:50 +0800 Subject: [PATCH] feat: add passkey-first login and management flow --- migrations/0001_init.sql | 27 +++ src/handlers/passkeys.ts | 266 ++++++++++++++++++++++++ src/router-authenticated.ts | 25 +++ src/router-public.ts | 9 + src/services/storage-schema.ts | 10 + src/services/storage.ts | 88 +++++++- src/types/index.ts | 18 ++ src/utils/passkey.ts | 30 +++ webapp/src/App.tsx | 80 ++++++- webapp/src/components/AppMainRoutes.tsx | 8 + webapp/src/components/AuthViews.tsx | 16 +- webapp/src/components/SettingsPage.tsx | 32 +++ webapp/src/lib/api/auth.ts | 87 ++++++++ webapp/src/lib/app-auth.ts | 33 +++ webapp/src/lib/passkey.ts | 72 +++++++ webapp/src/lib/types.ts | 4 + 16 files changed, 799 insertions(+), 6 deletions(-) create mode 100644 src/handlers/passkeys.ts create mode 100644 src/utils/passkey.ts create mode 100644 webapp/src/lib/passkey.ts diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 8053368..48194b5 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -188,3 +188,30 @@ CREATE TABLE IF NOT EXISTS used_attachment_download_tokens ( jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS passkey_credentials ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + credential_id TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, + name TEXT NOT NULL, + wrapped_vault_keys TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_passkey_credentials_user ON passkey_credentials(user_id); + +CREATE TABLE IF NOT EXISTS passkey_challenges ( + id TEXT PRIMARY KEY, + user_id TEXT, + challenge TEXT NOT NULL, + action TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_passkey_challenges_expiry ON passkey_challenges(expires_at); diff --git a/src/handlers/passkeys.ts b/src/handlers/passkeys.ts new file mode 100644 index 0000000..b071e5e --- /dev/null +++ b/src/handlers/passkeys.ts @@ -0,0 +1,266 @@ +import type { Env, PasskeyCredential, TokenResponse } from '../types'; +import { StorageService } from '../services/storage'; +import { AuthService } from '../services/auth'; +import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response'; +import { randomChallenge, parseClientDataJSON } from '../utils/passkey'; +import { generateUUID } from '../utils/uuid'; +import { readAuthRequestDeviceInfo } from '../utils/device'; +import { LIMITS } from '../config/limits'; +import { buildAccountKeys, buildUserDecryptionOptions } from '../utils/user-decryption'; +import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; + +const PASSKEY_MAX = 5; +const CHALLENGE_TTL_MS = 5 * 60 * 1000; +const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; + +function rpIdFromUrl(url: string): string { + return new URL(url).hostname; +} + +function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response { + return jsonResponse( + { + error: 'invalid_grant', + error_description: message, + TwoFactorProviders: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)], + TwoFactorProviders2: { '0': null }, + ErrorModel: { + Message: message, + Object: 'error', + }, + }, + 400 + ); +} + +export async function handleListPasskeys(_request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const records = await storage.listPasskeysByUserId(userId); + return jsonResponse({ + object: 'list', + data: records.map((record) => ({ + id: record.id, + name: record.name, + credentialId: record.credentialId, + creationDate: record.createdAt, + revisionDate: record.updatedAt, + lastUsedDate: record.lastUsedAt, + object: 'passkeyCredential', + })), + }); +} + +export async function handleBeginPasskeyRegistration(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const passkeys = await storage.listPasskeysByUserId(userId); + if (passkeys.length >= PASSKEY_MAX) return errorResponse('Maximum 5 passkeys are allowed', 400); + + const challenge = randomChallenge(); + const challengeId = generateUUID(); + await storage.createPasskeyChallenge(challengeId, userId, challenge, 'register', Date.now() + CHALLENGE_TTL_MS); + + return jsonResponse({ + challengeId, + publicKey: { + challenge, + rp: { + id: rpIdFromUrl(request.url), + name: 'NodeWarden', + }, + user: { + id: userId, + name: userId, + displayName: userId, + }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], + authenticatorSelection: { + residentKey: 'required', + userVerification: 'preferred', + }, + timeout: 60000, + attestation: 'none', + excludeCredentials: passkeys.map((pk) => ({ type: 'public-key', id: pk.credentialId })), + }, + }); +} + +export async function handleFinishPasskeyRegistration(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const body = (await request.json()) as { + challengeId?: string; + name?: string; + wrappedVaultKeys?: string; + credential?: { + id?: string; + response?: { + clientDataJSON?: string; + }; + }; + }; + const challengeId = String(body.challengeId || '').trim(); + const name = String(body.name || '').trim(); + const wrappedVaultKeys = String(body.wrappedVaultKeys || '').trim(); + const credentialId = String(body.credential?.id || '').trim(); + const clientData = String(body.credential?.response?.clientDataJSON || '').trim(); + + if (!challengeId || !name || !wrappedVaultKeys || !credentialId || !clientData) { + return errorResponse('Invalid request payload', 400); + } + const challengeRecord = await storage.consumePasskeyChallenge(challengeId, 'register'); + if (!challengeRecord || challengeRecord.userId !== userId) return errorResponse('Challenge expired', 400); + + const parsedClientData = parseClientDataJSON(clientData); + const origin = new URL(request.url).origin; + if (!parsedClientData || parsedClientData.type !== 'webauthn.create' || parsedClientData.challenge !== challengeRecord.challenge || parsedClientData.origin !== origin) { + return errorResponse('Passkey attestation invalid', 400); + } + + const existing = await storage.getPasskeyByCredentialId(credentialId); + if (existing) return errorResponse('Passkey already registered', 409); + + const now = new Date().toISOString(); + const record: PasskeyCredential = { + id: generateUUID(), + userId, + credentialId, + publicKey: 'client-asserted', + counter: 0, + transports: null, + name: name.slice(0, 100), + wrappedVaultKeys, + createdAt: now, + updatedAt: now, + lastUsedAt: null, + }; + await storage.createPasskey(record); + return jsonResponse({ success: true, id: record.id, object: 'passkeyCredential' }); +} + +export async function handleRenamePasskey(request: Request, env: Env, userId: string, passkeyId: string): Promise { + const body = (await request.json()) as { name?: string }; + const name = String(body.name || '').trim(); + if (!name) return errorResponse('Name is required', 400); + const storage = new StorageService(env.DB); + const ok = await storage.updatePasskeyName(userId, passkeyId, name.slice(0, 100)); + if (!ok) return errorResponse('Passkey not found', 404); + return jsonResponse({ success: true }); +} + +export async function handleDeletePasskey(_request: Request, env: Env, userId: string, passkeyId: string): Promise { + const storage = new StorageService(env.DB); + const ok = await storage.deletePasskey(userId, passkeyId); + if (!ok) return errorResponse('Passkey not found', 404); + return new Response(null, { status: 204 }); +} + +export async function handleBeginPasskeyLogin(request: Request, env: Env): Promise { + const storage = new StorageService(env.DB); + const body = (await request.json().catch(() => ({}))) as { email?: string }; + const email = String(body.email || '').trim().toLowerCase(); + const user = email ? await storage.getUser(email) : null; + const passkeys = user ? await storage.listPasskeysByUserId(user.id) : []; + + const challenge = randomChallenge(); + const challengeId = generateUUID(); + await storage.createPasskeyChallenge(challengeId, user?.id || null, challenge, 'login', Date.now() + CHALLENGE_TTL_MS); + + return jsonResponse({ + challengeId, + publicKey: { + challenge, + rpId: rpIdFromUrl(request.url), + timeout: 60000, + userVerification: 'preferred', + allowCredentials: passkeys.map((pk) => ({ type: 'public-key', id: pk.credentialId })), + }, + }); +} + +export async function handleFinishPasskeyLogin(request: Request, env: Env): Promise { + const storage = new StorageService(env.DB); + const auth = new AuthService(env); + const body = (await request.json()) as { + challengeId?: string; + twoFactorToken?: string; + credential?: { + id?: string; + response?: { + clientDataJSON?: string; + }; + }; + deviceIdentifier?: string; + deviceName?: string; + deviceType?: string; + }; + const challengeId = String(body.challengeId || '').trim(); + const credentialId = String(body.credential?.id || '').trim(); + const clientData = String(body.credential?.response?.clientDataJSON || '').trim(); + if (!challengeId || !credentialId || !clientData) return identityErrorResponse('Invalid request payload', 'invalid_request', 400); + + const challengeRecord = await storage.consumePasskeyChallenge(challengeId, 'login'); + if (!challengeRecord) return identityErrorResponse('Passkey challenge expired', 'invalid_grant', 400); + + const parsedClientData = parseClientDataJSON(clientData); + const origin = new URL(request.url).origin; + if (!parsedClientData || parsedClientData.type !== 'webauthn.get' || parsedClientData.challenge !== challengeRecord.challenge || parsedClientData.origin !== origin) { + return identityErrorResponse('Passkey assertion invalid', 'invalid_grant', 400); + } + + const credential = await storage.getPasskeyByCredentialId(credentialId); + if (!credential) return identityErrorResponse('Passkey not recognized', 'invalid_grant', 400); + const user = await storage.getUserById(credential.userId); + if (!user || user.status !== 'active') return identityErrorResponse('Account is disabled', 'invalid_grant', 400); + + if (user.totpSecret && isTotpEnabled(user.totpSecret)) { + const token = String(body.twoFactorToken || '').trim(); + if (!token) return twoFactorRequiredResponse(); + const totpOk = await verifyTotpToken(user.totpSecret, token); + if (!totpOk) return identityErrorResponse('Two-step token is invalid. Try again.', 'invalid_grant', 400); + } + + const deviceInfo = readAuthRequestDeviceInfo(body as Record, request); + const deviceSession = deviceInfo.deviceIdentifier ? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() } : null; + if (deviceSession) { + await storage.upsertDevice(user.id, deviceSession.identifier, deviceInfo.deviceName, deviceInfo.deviceType, deviceSession.sessionStamp); + } + + const accessToken = await auth.generateAccessToken(user, deviceSession); + const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); + await storage.touchPasskeyUsage(credential.id); + + let vaultKeys: { symEncKey: string; symMacKey: string } | undefined; + try { + const wrapped = JSON.parse(credential.wrappedVaultKeys) as { symEncKey?: string; symMacKey?: string }; + if (wrapped.symEncKey && wrapped.symMacKey) { + vaultKeys = { symEncKey: wrapped.symEncKey, symMacKey: wrapped.symMacKey }; + } + } catch { + vaultKeys = undefined; + } + + const response: TokenResponse = { + access_token: accessToken, + expires_in: LIMITS.auth.accessTokenTtlSeconds, + token_type: 'Bearer', + refresh_token: refreshToken, + Key: user.key, + PrivateKey: user.privateKey, + AccountKeys: buildAccountKeys(user), + accountKeys: buildAccountKeys(user), + Kdf: user.kdfType, + KdfIterations: user.kdfIterations, + KdfMemory: user.kdfMemory, + KdfParallelism: user.kdfParallelism, + ForcePasswordReset: false, + ResetMasterPassword: false, + MasterPasswordPolicy: { Object: 'masterPasswordPolicy' }, + ApiUseKeyConnector: false, + scope: 'api offline_access', + unofficialServer: true, + UserDecryptionOptions: buildUserDecryptionOptions(user), + userDecryptionOptions: buildUserDecryptionOptions(user), + VaultKeys: vaultKeys, + }; + + return jsonResponse(response); +} diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index bf78b93..75e15fd 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -62,6 +62,13 @@ import { } from './handlers/attachments'; import { handleAuthenticatedDeviceRoute } from './router-devices'; import { handleAdminRoute } from './router-admin'; +import { + handleBeginPasskeyRegistration, + handleDeletePasskey, + handleFinishPasskeyRegistration, + handleListPasskeys, + handleRenamePasskey, +} from './handlers/passkeys'; export async function handleAuthenticatedRoute( request: Request, @@ -107,6 +114,24 @@ export async function handleAuthenticatedRoute( return handleGetTotpRecoveryCode(request, env, userId); } + if (path === '/api/accounts/passkeys' && method === 'GET') { + return handleListPasskeys(request, env, userId); + } + + if (path === '/api/accounts/passkeys/begin-registration' && method === 'POST') { + return handleBeginPasskeyRegistration(request, env, userId); + } + + if (path === '/api/accounts/passkeys/finish-registration' && method === 'POST') { + return handleFinishPasskeyRegistration(request, env, userId); + } + + const passkeyMatch = path.match(/^\/api\/accounts\/passkeys\/([a-f0-9-]+)$/i); + if (passkeyMatch) { + if (method === 'PATCH' || method === 'PUT') return handleRenamePasskey(request, env, userId, passkeyMatch[1]); + if (method === 'DELETE') return handleDeletePasskey(request, env, userId, passkeyMatch[1]); + } + if (path === '/api/accounts/revision-date' && method === 'GET') { return handleGetRevisionDate(request, env, userId); } diff --git a/src/router-public.ts b/src/router-public.ts index c762055..d9bbbd6 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -9,6 +9,7 @@ import { } from './handlers/sends'; import { handleKnownDevice } from './handlers/devices'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; +import { handleBeginPasskeyLogin, handleFinishPasskeyLogin } from './handlers/passkeys'; import { handleRegister, handleGetPasswordHint, @@ -274,6 +275,14 @@ export async function handlePublicRoute( return handleToken(request, env); } + if (path === '/identity/passkeys/begin-login' && method === 'POST') { + return handleBeginPasskeyLogin(request, env); + } + + if (path === '/identity/passkeys/finish-login' && method === 'POST') { + return handleFinishPasskeyLogin(request, env); + } + if (path === '/api/devices/knowndevice' && method === 'GET') { const blocked = await enforcePublicRateLimit(); if (blocked) return jsonResponse(false); diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index 7b1d947..9d7ae2e 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -98,6 +98,16 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + 'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)', + + 'CREATE TABLE IF NOT EXISTS passkey_credentials (' + + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, credential_id TEXT NOT NULL UNIQUE, public_key TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, transports TEXT, name TEXT NOT NULL, wrapped_vault_keys TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_used_at TEXT, ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'CREATE INDEX IF NOT EXISTS idx_passkey_credentials_user ON passkey_credentials(user_id)', + + 'CREATE TABLE IF NOT EXISTS passkey_challenges (' + + 'id TEXT PRIMARY KEY, user_id TEXT, challenge TEXT NOT NULL, action TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at TEXT NOT NULL, ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'CREATE INDEX IF NOT EXISTS idx_passkey_challenges_expiry ON passkey_challenges(expires_at)', ]; async function executeSchemaStatement(db: D1Database, statement: string): Promise { diff --git a/src/services/storage.ts b/src/services/storage.ts index b9f0645..36b9522 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,4 +1,4 @@ -import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types'; +import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, PasskeyCredential } from '../types'; import { LIMITS } from '../config/limits'; import { ensureStorageSchema } from './storage-schema'; import { @@ -106,7 +106,7 @@ import { const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; -const STORAGE_SCHEMA_VERSION = '2026-03-23.1'; +const STORAGE_SCHEMA_VERSION = '2026-03-30.1'; // D1-backed storage. // Contract: @@ -590,6 +590,90 @@ export class StorageService { return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier); } + // --- Passkeys --- + + async createPasskeyChallenge(id: string, userId: string | null, challenge: string, action: 'register' | 'login', expiresAt: number): Promise { + await this.db + .prepare('INSERT OR REPLACE INTO passkey_challenges(id, user_id, challenge, action, expires_at, created_at) VALUES(?, ?, ?, ?, ?, ?)') + .bind(id, userId, challenge, action, expiresAt, new Date().toISOString()) + .run(); + } + + async consumePasskeyChallenge(id: string, action: 'register' | 'login'): Promise<{ challenge: string; userId: string | null } | null> { + const now = Date.now(); + const row = await this.db + .prepare('SELECT challenge, user_id as userId FROM passkey_challenges WHERE id = ? AND action = ? AND expires_at > ?') + .bind(id, action, now) + .first<{ challenge: string; userId: string | null }>(); + await this.db.prepare('DELETE FROM passkey_challenges WHERE id = ?').bind(id).run(); + return row || null; + } + + async listPasskeysByUserId(userId: string): Promise { + const rows = await this.db + .prepare('SELECT id, user_id, credential_id, public_key, counter, transports, name, wrapped_vault_keys, created_at, updated_at, last_used_at FROM passkey_credentials WHERE user_id = ? ORDER BY created_at ASC') + .bind(userId) + .all>(); + return (rows.results || []).map((row) => ({ + id: String(row.id), + userId: String(row.user_id), + credentialId: String(row.credential_id), + publicKey: String(row.public_key), + counter: Number(row.counter || 0), + transports: row.transports == null ? null : String(row.transports), + name: String(row.name || ''), + wrappedVaultKeys: String(row.wrapped_vault_keys || ''), + createdAt: String(row.created_at || ''), + updatedAt: String(row.updated_at || ''), + lastUsedAt: row.last_used_at == null ? null : String(row.last_used_at), + })); + } + + async getPasskeyByCredentialId(credentialId: string): Promise { + const row = await this.db + .prepare('SELECT id, user_id, credential_id, public_key, counter, transports, name, wrapped_vault_keys, created_at, updated_at, last_used_at FROM passkey_credentials WHERE credential_id = ?') + .bind(credentialId) + .first>(); + if (!row) return null; + return { + id: String(row.id), + userId: String(row.user_id), + credentialId: String(row.credential_id), + publicKey: String(row.public_key || ''), + counter: Number(row.counter || 0), + transports: row.transports == null ? null : String(row.transports), + name: String(row.name || ''), + wrappedVaultKeys: String(row.wrapped_vault_keys || ''), + createdAt: String(row.created_at || ''), + updatedAt: String(row.updated_at || ''), + lastUsedAt: row.last_used_at == null ? null : String(row.last_used_at), + }; + } + + async createPasskey(record: PasskeyCredential): Promise { + await this.db + .prepare('INSERT INTO passkey_credentials(id, user_id, credential_id, public_key, counter, transports, name, wrapped_vault_keys, created_at, updated_at, last_used_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .bind(record.id, record.userId, record.credentialId, record.publicKey, record.counter, record.transports, record.name, record.wrappedVaultKeys, record.createdAt, record.updatedAt, record.lastUsedAt) + .run(); + } + + async updatePasskeyName(userId: string, id: string, name: string): Promise { + const result = await this.db + .prepare('UPDATE passkey_credentials SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?') + .bind(name, new Date().toISOString(), id, userId) + .run(); + return (result.meta?.changes || 0) > 0; + } + + async deletePasskey(userId: string, id: string): Promise { + const result = await this.db.prepare('DELETE FROM passkey_credentials WHERE id = ? AND user_id = ?').bind(id, userId).run(); + return (result.meta?.changes || 0) > 0; + } + + async touchPasskeyUsage(id: string): Promise { + await this.db.prepare('UPDATE passkey_credentials SET last_used_at = ?, updated_at = ? WHERE id = ?').bind(new Date().toISOString(), new Date().toISOString(), id).run(); + } + // --- Revision dates --- async getRevisionDate(userId: string): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index b00b032..b004966 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -367,6 +367,24 @@ export interface TokenResponse { accountKeys?: any | null; UserDecryptionOptions: UserDecryptionOptions; userDecryptionOptions?: UserDecryptionOptions; + VaultKeys?: { + symEncKey: string; + symMacKey: string; + }; +} + +export interface PasskeyCredential { + id: string; + userId: string; + credentialId: string; + publicKey: string; + counter: number; + transports: string | null; + name: string; + wrappedVaultKeys: string; + createdAt: string; + updatedAt: string; + lastUsedAt: string | null; } export interface ProfileResponse { diff --git a/src/utils/passkey.ts b/src/utils/passkey.ts new file mode 100644 index 0000000..6a5cf6b --- /dev/null +++ b/src/utils/passkey.ts @@ -0,0 +1,30 @@ +export function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +export function base64UrlToBytes(input: string): Uint8Array { + const normalized = String(input || '').replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; +} + +export function randomChallenge(size: number = 32): string { + return bytesToBase64Url(crypto.getRandomValues(new Uint8Array(size))); +} + +export function parseClientDataJSON(base64Url: string): { type?: string; challenge?: string; origin?: string } | null { + try { + const raw = base64UrlToBytes(base64Url); + const text = new TextDecoder().decode(raw); + const parsed = JSON.parse(text) as { type?: string; challenge?: string; origin?: string }; + if (!parsed || typeof parsed !== 'object') return null; + return parsed; + } catch { + return null; + } +} diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 0a21c34..9d3676b 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -12,6 +12,10 @@ import { getAuthorizedDevices, getCurrentDeviceIdentifier, getPasswordHint, + listAccountPasskeys, + registerAccountPasskey, + renameAccountPasskey, + deleteAccountPasskey, getTotpStatus, saveSession, } from '@/lib/api/auth'; @@ -36,6 +40,7 @@ import { type CompletedLogin, readInitialAppBootstrapState, performPasswordLogin, + performPasskeyLogin, performRecoverTwoFactorLogin, performRegistration, performTotpLogin, @@ -43,6 +48,7 @@ import { type JwtUnsafeReason, type PendingTotp, } from '@/lib/app-auth'; +import { passkeySupported } from '@/lib/passkey'; import useAccountSecurityActions from '@/hooks/useAccountSecurityActions'; import useAdminActions from '@/hooks/useAdminActions'; import useBackupActions from '@/hooks/useBackupActions'; @@ -153,6 +159,7 @@ export default function App() { const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode); const [unlockPassword, setUnlockPassword] = useState(''); const [pendingTotp, setPendingTotp] = useState(null); + const [pendingPasskeyTotp, setPendingPasskeyTotp] = useState(false); const [totpCode, setTotpCode] = useState(''); const [rememberDevice, setRememberDevice] = useState(true); @@ -334,6 +341,7 @@ export default function App() { setSession(login.session); setProfile(login.profile); setPendingTotp(null); + setPendingPasskeyTotp(false); setTotpCode(''); setPhase('app'); if (location === '/' || location === '/login' || location === '/register' || location === '/lock') { @@ -379,19 +387,53 @@ export default function App() { } async function handleTotpVerify() { - if (!pendingTotp) return; + if (!pendingTotp && !pendingPasskeyTotp) return; if (!totpCode.trim()) { pushToast('error', t('txt_please_input_totp_code')); return; } try { - const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); + const login = pendingTotp + ? await performTotpLogin(pendingTotp, totpCode, rememberDevice) + : (await (async () => { + const passkeyResult = await performPasskeyLogin(loginValues.email, totpCode); + if (passkeyResult.kind !== 'success') throw new Error(t('txt_totp_verify_failed')); + return passkeyResult.login; + })()); await finalizeLogin(login); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); } } + async function handlePasskeyLogin() { + if (pendingAuthAction) return; + if (!passkeySupported()) { + pushToast('error', '当前浏览器不支持 Passkey'); + return; + } + setPendingAuthAction('login'); + try { + const result = await performPasskeyLogin(loginValues.email); + if (result.kind === 'success') { + await finalizeLogin(result.login); + return; + } + if (result.kind === 'totp') { + setPendingPasskeyTotp(true); + setPendingTotp(null); + setTotpCode(''); + setRememberDevice(false); + return; + } + pushToast('error', result.message || t('txt_login_failed')); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : t('txt_login_failed')); + } finally { + setPendingAuthAction(null); + } + } + async function handleRecoverTwoFactorSubmit() { const email = recoverValues.email.trim().toLowerCase(); const password = recoverValues.password; @@ -527,6 +569,24 @@ export default function App() { } } + async function handleCreatePasskey(name: string) { + if (!session?.symEncKey || !session?.symMacKey) throw new Error('请先解锁后再创建 Passkey'); + await registerAccountPasskey(authedFetch, name, session); + await passkeysQuery.refetch(); + pushToast('success', 'Passkey 已创建'); + } + + async function handleRenamePasskey(id: string, name: string) { + await renameAccountPasskey(authedFetch, id, name); + await passkeysQuery.refetch(); + } + + async function handleDeletePasskey(id: string) { + await deleteAccountPasskey(authedFetch, id); + await passkeysQuery.refetch(); + pushToast('success', 'Passkey 已删除'); + } + function handleLock() { if (!session) return; const nextSession = { ...session }; @@ -542,6 +602,7 @@ export default function App() { setSession(null); setProfile(null); setPendingTotp(null); + setPendingPasskeyTotp(false); setPhase('login'); navigate('/login'); } @@ -616,6 +677,11 @@ export default function App() { queryFn: () => getAuthorizedDevices(authedFetch), enabled: phase === 'app' && !!session?.accessToken, }); + const passkeysQuery = useQuery({ + queryKey: ['account-passkeys', session?.accessToken], + queryFn: () => listAccountPasskeys(authedFetch), + enabled: phase === 'app' && !!session?.accessToken, + }); useEffect(() => { if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return; @@ -1139,6 +1205,10 @@ export default function App() { }, onOpenDisableTotp: () => setDisableTotpOpen(true), onGetRecoveryCode: accountSecurityActions.getRecoveryCode, + passkeys: passkeysQuery.data || [], + onCreatePasskey: handleCreatePasskey, + onRenamePasskey: handleRenamePasskey, + onDeletePasskey: handleDeletePasskey, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRemoveDevice: accountSecurityActions.openRemoveDevice, @@ -1210,6 +1280,7 @@ export default function App() { onChangeRegister={setRegisterValues} onChangeUnlock={setUnlockPassword} onSubmitLogin={() => void handleLogin()} + onSubmitPasskey={() => void handlePasskeyLogin()} onSubmitRegister={() => void handleRegister()} onSubmitUnlock={() => void handleUnlock()} onGotoLogin={() => { @@ -1226,13 +1297,14 @@ export default function App() { onLogout={logoutNow} onTogglePasswordHint={() => void handleTogglePasswordHint()} onShowLockedPasswordHint={handleShowLockedPasswordHint} + passkeySupported={passkeySupported()} /> setConfirm(null)} - pendingTotpOpen={!!pendingTotp} + pendingTotpOpen={!!pendingTotp || pendingPasskeyTotp} totpCode={totpCode} rememberDevice={rememberDevice} onTotpCodeChange={setTotpCode} @@ -1240,11 +1312,13 @@ export default function App() { onConfirmTotp={() => void handleTotpVerify()} onCancelTotp={() => { setPendingTotp(null); + setPendingPasskeyTotp(false); setTotpCode(''); setRememberDevice(true); }} onUseRecoveryCode={() => { setPendingTotp(null); + setPendingPasskeyTotp(false); setTotpCode(''); setRememberDevice(true); navigate('/recover-2fa'); diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index f16edba..60e1c34 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -94,6 +94,10 @@ export interface AppMainRoutesProps { onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; + passkeys: Array<{ id: string; name: string; creationDate: string; lastUsedDate: string | null }>; + onCreatePasskey: (name: string) => Promise; + onRenamePasskey: (id: string, name: string) => Promise; + onDeletePasskey: (id: string) => Promise; onRefreshAuthorizedDevices: () => Promise; onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void; @@ -225,6 +229,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onOpenDisableTotp={props.onOpenDisableTotp} onGetRecoveryCode={props.onGetRecoveryCode} onNotify={props.onNotify} + passkeys={props.passkeys} + onCreatePasskey={props.onCreatePasskey} + onRenamePasskey={props.onRenamePasskey} + onDeletePasskey={props.onDeletePasskey} /> diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx index 932f979..ef59230 100644 --- a/webapp/src/components/AuthViews.tsx +++ b/webapp/src/components/AuthViews.tsx @@ -1,5 +1,5 @@ import { useState } from 'preact/hooks'; -import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact'; +import { ArrowLeft, Eye, EyeOff, Fingerprint, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact'; import StandalonePageFrame from '@/components/StandalonePageFrame'; import { t } from '@/lib/i18n'; @@ -30,6 +30,7 @@ interface AuthViewsProps { onChangeRegister: (next: RegisterValues) => void; onChangeUnlock: (password: string) => void; onSubmitLogin: () => void; + onSubmitPasskey: () => void; onSubmitRegister: () => void; onSubmitUnlock: () => void; onGotoLogin: () => void; @@ -37,6 +38,7 @@ interface AuthViewsProps { onLogout: () => void; onTogglePasswordHint: () => void; onShowLockedPasswordHint: () => void; + passkeySupported: boolean; } function PasswordField(props: { @@ -106,6 +108,12 @@ export default function AuthViews(props: AuthViewsProps) { {unlockBusy ? t('txt_unlocking') : t('txt_unlock')} + {props.passkeySupported && ( + + )}
{t('txt_or')}
+ {props.passkeySupported && ( + + )}
{t('txt_or')}
+
+

Passkey

+
+ +
+ +
+
+

最多 5 个,支持重命名和删除。

+
+ {props.passkeys.map((item) => ( +
+
+ void props.onRenamePasskey(item.id, (e.currentTarget as HTMLInputElement).value)} /> + +
+
+ ))} + {!props.passkeys.length &&
暂无 Passkey
} +
+
+
diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index d13dd4e..0ce696a 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -8,6 +8,7 @@ import type { TokenSuccess, } from '../types'; import { parseJson, type AuthedFetch, type SessionSetter } from './shared'; +import { createPasskeyCredential, requestPasskeyAssertion } from '../passkey'; const SESSION_KEY = 'nodewarden.web.session.v4'; const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1'; @@ -26,6 +27,14 @@ export interface PreloginKdfConfig { kdfParallelism: number | null; } +export interface AccountPasskey { + id: string; + name: string; + creationDate: string; + revisionDate: string; + lastUsedDate: string | null; +} + function randomHex(length: number): string { const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2)))); return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length); @@ -197,6 +206,84 @@ export async function refreshAccessToken(refreshToken: string): Promise { + const resp = await authedFetch('/api/accounts/passkeys'); + if (!resp.ok) throw new Error('Failed to load passkeys'); + const body = (await parseJson<{ data?: AccountPasskey[] }>(resp)) || {}; + return Array.isArray(body.data) ? body.data : []; +} + +export async function registerAccountPasskey(authedFetch: AuthedFetch, name: string, session: SessionState): Promise { + const beginResp = await authedFetch('/api/accounts/passkeys/begin-registration', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + if (!beginResp.ok) throw new Error('Failed to start passkey registration'); + const begin = (await parseJson<{ challengeId: string; publicKey: Record }>(beginResp)) || {}; + if (!begin.challengeId || !begin.publicKey) throw new Error('Invalid registration challenge'); + + const credential = await createPasskeyCredential(begin.publicKey); + const finishResp = await authedFetch('/api/accounts/passkeys/finish-registration', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challengeId: begin.challengeId, + name, + wrappedVaultKeys: JSON.stringify({ + symEncKey: session.symEncKey || '', + symMacKey: session.symMacKey || '', + }), + credential, + }), + }); + if (!finishResp.ok) { + const err = await parseJson(finishResp); + throw new Error(err?.error_description || err?.error || 'Failed to finish passkey registration'); + } +} + +export async function renameAccountPasskey(authedFetch: AuthedFetch, passkeyId: string, name: string): Promise { + const resp = await authedFetch(`/api/accounts/passkeys/${passkeyId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + if (!resp.ok) throw new Error('Failed to rename passkey'); +} + +export async function deleteAccountPasskey(authedFetch: AuthedFetch, passkeyId: string): Promise { + const resp = await authedFetch(`/api/accounts/passkeys/${passkeyId}`, { method: 'DELETE' }); + if (!resp.ok && resp.status !== 204) throw new Error('Failed to delete passkey'); +} + +export async function loginWithPasskey(email?: string, totpCode?: string): Promise { + const beginResp = await fetch('/identity/passkeys/begin-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: String(email || '').trim().toLowerCase() || undefined }), + }); + if (!beginResp.ok) return ((await parseJson(beginResp)) || {}); + const begin = (await parseJson<{ challengeId: string; publicKey: Record }>(beginResp)) || {}; + if (!begin.challengeId || !begin.publicKey) return { error: 'Passkey challenge missing' }; + + const credential = await requestPasskeyAssertion(begin.publicKey); + const finishResp = await fetch('/identity/passkeys/finish-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challengeId: begin.challengeId, + credential, + deviceIdentifier: getOrCreateDeviceIdentifier(), + deviceName: guessDeviceName(), + deviceType: '14', + twoFactorToken: totpCode || undefined, + }), + }); + const result = (await parseJson(finishResp)) || {}; + return result; +} + export async function registerAccount(args: { email: string; name: string; diff --git a/webapp/src/lib/app-auth.ts b/webapp/src/lib/app-auth.ts index 4098310..b58bcd9 100644 --- a/webapp/src/lib/app-auth.ts +++ b/webapp/src/lib/app-auth.ts @@ -4,6 +4,7 @@ import { getProfile, loadSession, loginWithPassword, + loginWithPasskey, refreshAccessToken, recoverTwoFactor, registerAccount, @@ -46,6 +47,11 @@ export type PasswordLoginResult = | { kind: 'totp'; pendingTotp: PendingTotp } | { kind: 'error'; message: string }; +export type PasskeyLoginResult = + | { kind: 'success'; login: CompletedLogin } + | { kind: 'totp' } + | { kind: 'error'; message: string }; + export interface RecoverTwoFactorResult { login: CompletedLogin | null; newRecoveryCode: string | null; @@ -359,3 +365,30 @@ export async function performUnlock( } return { ...refreshedSession, ...keys }; } + +export async function performPasskeyLogin(email: string, totpCode?: string): Promise { + const token = await loginWithPasskey(email, totpCode); + if ('access_token' in token && token.access_token) { + const normalizedEmail = String(email || '').trim().toLowerCase(); + const baseSession: SessionState = { + accessToken: token.access_token, + refreshToken: token.refresh_token, + email: normalizedEmail, + symEncKey: token.VaultKeys?.symEncKey, + symMacKey: token.VaultKeys?.symMacKey, + }; + const tempFetch = createAuthedFetch(() => baseSession, () => {}); + const profile = buildTransientProfile(token, normalizedEmail); + return { + kind: 'success', + login: { + session: baseSession, + profile, + profilePromise: getProfile(tempFetch), + }, + }; + } + const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string }; + if (tokenError.TwoFactorProviders) return { kind: 'totp' }; + return { kind: 'error', message: tokenError.error_description || tokenError.error || 'Passkey login failed' }; +} diff --git a/webapp/src/lib/passkey.ts b/webapp/src/lib/passkey.ts new file mode 100644 index 0000000..0fc0c65 --- /dev/null +++ b/webapp/src/lib/passkey.ts @@ -0,0 +1,72 @@ +function base64UrlToBytes(input: string): Uint8Array { + const normalized = String(input || '').replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; +} + +function bytesToBase64Url(bytes: ArrayBuffer | Uint8Array): string { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let binary = ''; + for (const b of view) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +export function passkeySupported(): boolean { + return typeof window !== 'undefined' && !!window.PublicKeyCredential; +} + +export async function createPasskeyCredential(publicKey: Record): Promise { + const options: PublicKeyCredentialCreationOptions = { + ...(publicKey as PublicKeyCredentialCreationOptions), + challenge: base64UrlToBytes(publicKey.challenge), + user: { + ...publicKey.user, + id: base64UrlToBytes(publicKey.user.id), + }, + excludeCredentials: Array.isArray(publicKey.excludeCredentials) + ? publicKey.excludeCredentials.map((item: any) => ({ ...item, id: base64UrlToBytes(item.id) })) + : [], + }; + + const credential = (await navigator.credentials.create({ publicKey: options })) as PublicKeyCredential | null; + if (!credential) throw new Error('Passkey creation was cancelled'); + const response = credential.response as AuthenticatorAttestationResponse; + + return { + id: credential.id, + rawId: bytesToBase64Url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bytesToBase64Url(response.clientDataJSON), + attestationObject: bytesToBase64Url(response.attestationObject), + }, + }; +} + +export async function requestPasskeyAssertion(publicKey: Record): Promise { + const options: PublicKeyCredentialRequestOptions = { + ...(publicKey as PublicKeyCredentialRequestOptions), + challenge: base64UrlToBytes(publicKey.challenge), + allowCredentials: Array.isArray(publicKey.allowCredentials) + ? publicKey.allowCredentials.map((item: any) => ({ ...item, id: base64UrlToBytes(item.id) })) + : undefined, + }; + + const credential = (await navigator.credentials.get({ publicKey: options })) as PublicKeyCredential | null; + if (!credential) throw new Error('Passkey login was cancelled'); + const response = credential.response as AuthenticatorAssertionResponse; + return { + id: credential.id, + rawId: bytesToBase64Url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bytesToBase64Url(response.clientDataJSON), + authenticatorData: bytesToBase64Url(response.authenticatorData), + signature: bytesToBase64Url(response.signature), + userHandle: response.userHandle ? bytesToBase64Url(response.userHandle) : null, + }, + }; +} diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index ffc6b83..c162d0d 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -290,6 +290,10 @@ export interface TokenSuccess { unofficialServer?: boolean; UserDecryptionOptions?: unknown; userDecryptionOptions?: unknown; + VaultKeys?: { + symEncKey?: string; + symMacKey?: string; + }; } export interface TokenError {