feat: add passkey-first login and management flow

This commit is contained in:
Shuai
2026-03-31 00:59:50 +08:00
parent 1184cb8d9a
commit 0f6da7d147
16 changed files with 799 additions and 6 deletions
+266
View File
@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<string, string>, 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);
}
+25
View File
@@ -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);
}
+9
View File
@@ -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);
+10
View File
@@ -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<void> {
+86 -2
View File
@@ -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<void> {
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<PasskeyCredential[]> {
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<Record<string, unknown>>();
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<PasskeyCredential | null> {
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<Record<string, unknown>>();
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<string> {
+18
View File
@@ -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 {
+30
View File
@@ -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;
}
}