diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 6320188..4c9c8ef 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -241,6 +241,7 @@ CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at CREATE TABLE IF NOT EXISTS webauthn_credentials ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, diff --git a/src/handlers/account-passkeys.ts b/src/handlers/account-passkeys.ts index 321c7b5..b2ab005 100644 --- a/src/handlers/account-passkeys.ts +++ b/src/handlers/account-passkeys.ts @@ -9,7 +9,7 @@ import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; -import { bytesToBase64Url } from '../utils/passkey'; +import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey'; import { accountPasskeyCredentialToResponse, accountPasskeyPrfStatus, @@ -29,8 +29,10 @@ import { verifyAccountPasskeyToken, } from '../utils/account-passkeys'; import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events'; +import { createRecoveryCode } from '../utils/recovery-code'; const MAX_ACCOUNT_PASSKEYS = 5; +const MAX_TWO_FACTOR_PASSKEYS = 5; function parseBodyObject(body: unknown): Record { return body && typeof body === 'object' ? body as Record : {}; @@ -81,6 +83,43 @@ function hasCompletePrfKeySet(body: Record): boolean { return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey); } +function twoFactorWebAuthnResponse(credentials: AccountPasskeyCredential[]): Record { + return { + Enabled: credentials.length > 0, + enabled: credentials.length > 0, + Keys: credentials.map((credential, index) => ({ + Id: index + 1, + id: index + 1, + Name: credential.name, + name: credential.name, + Migrated: false, + migrated: false, + })), + keys: credentials.map((credential, index) => ({ + Id: index + 1, + id: index + 1, + Name: credential.name, + name: credential.name, + Migrated: false, + migrated: false, + })), + Object: 'twoFactorWebAuthn', + object: 'twoFactorWebAuthn', + }; +} + +function readRegistrationChallenge(response: ReturnType): string | null { + if (!response) return null; + const clientData = parseClientDataJSON(response.response.clientDataJSON); + return String(clientData?.challenge || '').trim() || null; +} + +function readAuthenticationChallenge(response: ReturnType): string | null { + if (!response) return null; + const clientData = parseClientDataJSON(response.response.clientDataJSON); + return String(clientData?.challenge || '').trim() || null; +} + function readPrfKeySet(body: Record): { encryptedUserKey: string | null; encryptedPublicKey: string | null; @@ -176,6 +215,9 @@ export async function assertAccountPasskeyCredential( if (payload.userId && credential.userId !== payload.userId) { throw new Error('Passkey does not belong to this user'); } + if (credential.purpose !== 'login') { + throw new Error('Passkey is not registered for login'); + } const userHandleUserId = userHandleToUserId(response.response.userHandle); const resolvedUserId = payload.userId || userHandleUserId || credential.userId; @@ -225,6 +267,268 @@ export async function handleGetAccountPasskeyCredentials(request: Request, env: }); } +export async function buildTwoFactorPasskeyAssertionOptions( + request: Request, + env: Env, + storage: StorageService, + user: User +): Promise | null> { + const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor'); + if (!credentials.length) return null; + + const { rpId } = getAccountPasskeyRpConfig(request, env); + const options = await generateAuthenticationOptions({ + rpID: rpId, + allowCredentials: credentials.map((credential) => ({ + id: credential.credentialId, + transports: (credential.transports || undefined) as any, + })), + userVerification: 'discouraged', + timeout: 60000, + }); + await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id); + return options as unknown as Record; +} + +export async function assertTwoFactorPasskeyCredential( + request: Request, + env: Env, + storage: StorageService, + user: User, + deviceResponse: unknown +): Promise { + const response = normalizeAuthenticationResponse(deviceResponse); + if (!response) { + throw new Error('Invalid passkey assertion response'); + } + + const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId); + if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') { + throw new Error('Passkey is not registered for two-step login'); + } + + const challenge = readAuthenticationChallenge(response); + if (!challenge) { + throw new Error('Passkey assertion challenge is missing'); + } + const consumed = await storage.consumeAccountPasskeyChallenge( + await sha256Base64Url(challenge), + 'TwoFactorAuthentication', + user.id, + Date.now() + ); + if (!consumed) { + throw new Error('Passkey challenge has expired or was already used'); + } + + const { origins, rpId } = getAccountPasskeyRpConfig(request, env); + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: challenge, + expectedOrigin: origins, + expectedRPID: rpId, + credential: toSimpleWebAuthnCredential(credential), + requireUserVerification: false, + }); + if (!verification.verified) { + throw new Error('Passkey assertion could not be verified'); + } + + await storage.updateAccountPasskeyCounter( + credential.userId, + credential.credentialId, + verification.authenticationInfo.newCounter, + new Date().toISOString() + ); + credential.counter = verification.authenticationInfo.newCounter; + return credential; +} + +export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise { + const body = await readJsonBody(request); + if (!body) return errorResponse('Invalid request payload', 400); + if (!(await verifyUserSecret(env, user, body))) { + return errorResponse('User verification failed.', 400); + } + + const storage = new StorageService(env.DB); + const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor'); + return jsonResponse(twoFactorWebAuthnResponse(credentials)); +} + +export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise { + const body = await readJsonBody(request); + if (!body) return errorResponse('Invalid request payload', 400); + if (!(await verifyUserSecret(env, user, body))) { + return errorResponse('User verification failed.', 400); + } + + const storage = new StorageService(env.DB); + const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor'); + if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) { + return errorResponse('Maximum WebAuthn credential count reached.', 400); + } + + const { rpId, rpName } = getAccountPasskeyRpConfig(request, env); + const options = await generateRegistrationOptions({ + rpID: rpId, + rpName, + userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)), + userName: user.email, + userDisplayName: user.name || user.email, + attestationType: 'none', + timeout: 60000, + excludeCredentials: credentials.map((credential) => ({ + id: credential.credentialId, + transports: (credential.transports || undefined) as any, + })), + authenticatorSelection: { + residentKey: 'discouraged', + requireResidentKey: false, + userVerification: 'discouraged', + }, + }); + await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId); + return jsonResponse(options); +} + +export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise { + const body = await readJsonBody(request); + if (!body) return errorResponse('Invalid request payload', 400); + if (!(await verifyUserSecret(env, user, body))) { + return errorResponse('User verification failed.', 400); + } + + const storage = new StorageService(env.DB); + const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor'); + if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) { + return errorResponse('Maximum WebAuthn credential count reached.', 400); + } + + const registrationResponse = normalizeRegistrationResponse(body.deviceResponse); + if (!registrationResponse) { + return errorResponse('Invalid passkey registration response', 400); + } + const challenge = readRegistrationChallenge(registrationResponse); + if (!challenge) { + return errorResponse('Passkey challenge is missing', 400); + } + const consumed = await storage.consumeAccountPasskeyChallenge( + await sha256Base64Url(challenge), + 'TwoFactorCreate', + userId, + Date.now() + ); + if (!consumed) { + return errorResponse('Passkey challenge has expired or was already used', 400); + } + + const { origins, rpId } = getAccountPasskeyRpConfig(request, env); + let verification: Awaited>; + try { + verification = await verifyRegistrationResponse({ + response: registrationResponse, + expectedChallenge: challenge, + expectedOrigin: origins, + expectedRPID: rpId, + requireUserPresence: true, + requireUserVerification: false, + }); + } catch { + return errorResponse('Passkey registration could not be verified', 400); + } + if (!verification.verified) { + return errorResponse('Passkey registration could not be verified', 400); + } + + const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id); + if (existing) { + return errorResponse('Passkey is already registered', 409); + } + + const now = new Date().toISOString(); + const transports = normalizeTransports(registrationResponse.response.transports); + await storage.saveAccountPasskeyCredential({ + id: generateUUID(), + userId, + purpose: 'twoFactor', + name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`), + publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey), + credentialId: verification.registrationInfo.credential.id, + counter: verification.registrationInfo.credential.counter, + type: verification.registrationInfo.credentialType || 'public-key', + aaGuid: verification.registrationInfo.aaguid || null, + transports, + encryptedUserKey: null, + encryptedPublicKey: null, + encryptedPrivateKey: null, + supportsPrf: false, + createdAt: now, + updatedAt: now, + }); + + if (!user.totpRecoveryCode) { + user.totpRecoveryCode = createRecoveryCode(); + user.updatedAt = now; + await storage.saveUser(user); + } + await storage.deleteRefreshTokensByUserId(userId); + AuthService.invalidateUserCache(userId); + + await safeWriteAuditEvent(env, { + actorUserId: userId, + action: 'account.webauthn_2fa.enable', + category: 'security', + level: 'security', + targetType: 'accountPasskey', + targetId: null, + metadata: auditRequestMetadata(request), + }); + + const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor'); + return jsonResponse(twoFactorWebAuthnResponse(credentials)); +} + +export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise { + const body = await readJsonBody(request); + if (!body) return errorResponse('Invalid request payload', 400); + if (!(await verifyUserSecret(env, user, body))) { + return errorResponse('User verification failed.', 400); + } + + const requestedId = Number(body.id ?? body.Id); + if (!Number.isInteger(requestedId) || requestedId <= 0) { + return errorResponse('Invalid key id', 400); + } + + const storage = new StorageService(env.DB); + const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor'); + if (credentials.length < 2) { + return errorResponse('Unable to delete WebAuthn credential.', 400); + } + const credential = credentials[requestedId - 1]; + if (!credential) { + return errorResponse('Unable to delete WebAuthn credential.', 400); + } + + const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor'); + if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400); + await storage.deleteRefreshTokensByUserId(userId); + AuthService.invalidateUserCache(userId); + + await safeWriteAuditEvent(env, { + actorUserId: userId, + action: 'account.webauthn_2fa.delete', + category: 'security', + level: 'security', + targetType: 'accountPasskey', + targetId: credential.id, + metadata: auditRequestMetadata(request), + }); + + return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor'))); +} + export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise { const body = await readJsonBody(request); if (!body) return errorResponse('Invalid request payload', 400); @@ -380,6 +684,7 @@ export async function handleCreateAccountPasskeyCredential(request: Request, env const credential: AccountPasskeyCredential = { id: generateUUID(), userId, + purpose: 'login', name: normalizeAccountPasskeyName(body.name), publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey), credentialId: verification.registrationInfo.credential.id, diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 1223ee7..f09354d 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -15,6 +15,7 @@ import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verif const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_YUBIKEY = 3; +const TWO_FACTOR_PROVIDER_WEBAUTHN = 7; const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000; const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId'; @@ -830,6 +831,8 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us const data = []; if (user.totpSecret) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)); if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true)); + const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor'); + if (webAuthnCredentials.length > 0) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_WEBAUTHN, true)); return jsonResponse({ Data: data, @@ -1089,7 +1092,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env, const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR; const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10); - if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY].includes(type)) { + if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY, TWO_FACTOR_PROVIDER_WEBAUTHN].includes(type)) { return errorResponse('Two-factor provider is not supported by this server.', 400); } @@ -1107,13 +1110,18 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env, if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) { user.totpSecret = null; - } else { + } else if (type === TWO_FACTOR_PROVIDER_YUBIKEY) { user.yubikeyKey1 = null; user.yubikeyKey2 = null; user.yubikeyKey3 = null; user.yubikeyKey4 = null; user.yubikeyKey5 = null; user.yubikeyNfc = false; + } else { + const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor'); + for (const credential of credentials) { + await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor'); + } } user.updatedAt = new Date().toISOString(); await storage.saveUser(user); @@ -1121,7 +1129,11 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env, AuthService.invalidateUserCache(user.id); await writeAuditEvent(storage, { actorUserId: user.id, - action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR ? 'account.totp.disable' : 'account.yubikey.disable', + action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR + ? 'account.totp.disable' + : type === TWO_FACTOR_PROVIDER_YUBIKEY + ? 'account.yubikey.disable' + : 'account.webauthn_2fa.disable', category: 'security', level: 'security', targetType: 'user', @@ -1329,6 +1341,10 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis user.yubikeyKey4 = null; user.yubikeyKey5 = null; user.yubikeyNfc = false; + const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor'); + for (const credential of webAuthnCredentials) { + await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor'); + } user.totpRecoveryCode = createRecoveryCode(); user.securityStamp = generateUUID(); user.updatedAt = new Date().toISOString(); diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index beea50f..4459407 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -18,7 +18,9 @@ import { import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events'; import { assertAccountPasskeyCredential, + assertTwoFactorPasskeyCredential, buildAccountPasskeyTokenUserDecryptionOption, + buildTwoFactorPasskeyAssertionOptions, } from './account-passkeys'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { createPasskeyUserVerificationToken } from '../utils/user-verification-token'; @@ -29,6 +31,7 @@ const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_YUBIKEY = 3; const TWO_FACTOR_PROVIDER_REMEMBER = 5; +const TWO_FACTOR_PROVIDER_WEBAUTHN = 7; const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8; const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh'; const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId'; @@ -196,18 +199,31 @@ function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] { }; } -function twoFactorRequiredResponse(user?: User, message: string = 'Two factor required.'): Response { +async function twoFactorRequiredResponse( + request: Request, + env: Env, + storage: StorageService, + user?: User, + message: string = 'Two factor required.' +): Promise { // Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only. // Clients expose recovery-code entry points themselves; Android 2026.4 fails to // parse the challenge if an unknown recovery provider key such as "8" is included. const providers: string[] = []; + let webAuthnOptions: Record | null = null; if (!user || resolveTotpSecret(user.totpSecret)) providers.push(String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)); if (user && isYubiKeyEnabled(user)) providers.push(String(TWO_FACTOR_PROVIDER_YUBIKEY)); + if (user) { + webAuthnOptions = await buildTwoFactorPasskeyAssertionOptions(request, env, storage, user) as Record | null; + if (webAuthnOptions) providers.push(String(TWO_FACTOR_PROVIDER_WEBAUTHN)); + } const providers2: Record> = {}; for (const provider of providers) { providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY) ? { Nfc: user?.yubikeyNfc ?? false } - : { Email: null }; + : provider === String(TWO_FACTOR_PROVIDER_WEBAUTHN) && webAuthnOptions + ? webAuthnOptions + : { Email: null }; } const customResponse = { TwoFactorProviders: providers, @@ -393,7 +409,8 @@ export async function handleToken(request: Request, env: Env): Promise let trustedTwoFactorTokenToReturn: string | undefined; const effectiveTotpSecret = resolveTotpSecret(user.totpSecret); const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user); - if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0) { + const effectiveWebAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor'); + if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0 || effectiveWebAuthnCredentials.length > 0) { const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim(); const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim(); let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim()); @@ -403,7 +420,7 @@ export async function handleToken(request: Request, env: Env): Promise // Upstream-compatible behavior: if 2FA is required and either provider or token is missing, // respond with a 2FA challenge payload. if (!hasProvider || !hasToken) { - return twoFactorRequiredResponse(user, 'Two factor required.'); + return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.'); } let passedByRememberToken = false; @@ -418,7 +435,7 @@ export async function handleToken(request: Request, env: Env): Promise // Remember token missing/invalid/expired should re-enter the 2FA challenge flow. if (!passedByRememberToken) { - return twoFactorRequiredResponse(user, 'Two factor required.'); + return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.'); } } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { if (!effectiveTotpSecret) { @@ -441,6 +458,21 @@ export async function handleToken(request: Request, env: Env): Promise if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) { return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); } + } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) { + if (!effectiveWebAuthnCredentials.length) { + return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); + } + let deviceResponse: unknown; + try { + deviceResponse = JSON.parse(normalizedTwoFactorToken); + } catch { + return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); + } + try { + await assertTwoFactorPasskeyCredential(request, env, storage, user, deviceResponse); + } catch { + return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); + } } else if ( normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE || normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) || @@ -456,6 +488,9 @@ export async function handleToken(request: Request, env: Env): Promise user.yubikeyKey4 = null; user.yubikeyKey5 = null; user.yubikeyNfc = false; + for (const credential of effectiveWebAuthnCredentials) { + await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor'); + } user.totpRecoveryCode = createRecoveryCode(); user.securityStamp = generateUUID(); user.updatedAt = new Date().toISOString(); diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index ec69c5e..5f062ed 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -78,9 +78,13 @@ import { handleGetDomains, handleUpdateDomains } from './handlers/domains'; import { handleCreateAccountPasskeyCredential, handleDeleteAccountPasskeyCredential, + handleDeleteTwoFactorWebAuthn, handleGetAccountPasskeyAttestationOptions, handleGetAccountPasskeyCredentials, handleGetAccountPasskeyUpdateAssertionOptions, + handleGetTwoFactorWebAuthn, + handleGetTwoFactorWebAuthnChallenge, + handlePutTwoFactorWebAuthn, handleUpdateAccountPasskeyEncryption, } from './handlers/account-passkeys'; import { @@ -149,6 +153,14 @@ export async function handleAuthenticatedRoute( return handleGetTwoFactorYubiKey(request, env, userId); } + if (path === '/api/two-factor/get-webauthn' && method === 'POST') { + return handleGetTwoFactorWebAuthn(request, env, userId, currentUser); + } + + if (path === '/api/two-factor/get-webauthn-challenge' && method === 'POST') { + return handleGetTwoFactorWebAuthnChallenge(request, env, userId, currentUser); + } + if (path === '/api/two-factor/authenticator') { if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId); if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId); @@ -161,6 +173,12 @@ export async function handleAuthenticatedRoute( return errorResponse('Method not allowed', 405); } + if (path === '/api/two-factor/webauthn') { + if (method === 'PUT' || method === 'POST') return handlePutTwoFactorWebAuthn(request, env, userId, currentUser); + if (method === 'DELETE') return handleDeleteTwoFactorWebAuthn(request, env, userId, currentUser); + return errorResponse('Method not allowed', 405); + } + if ((path === '/api/two-factor/yubikey/config' || path === '/api/two-factor/yubi-key/config') && (method === 'PUT' || method === 'POST')) { return handlePutTwoFactorYubiKeyConfig(request, env, userId); } diff --git a/src/services/storage-account-passkey-repo.ts b/src/services/storage-account-passkey-repo.ts index 57ea45d..f0cedc5 100644 --- a/src/services/storage-account-passkey-repo.ts +++ b/src/services/storage-account-passkey-repo.ts @@ -7,6 +7,7 @@ let accountPasskeySchemaReady = false; const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [ { name: 'id', sql: 'id TEXT' }, { name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" }, + { name: 'purpose', sql: "purpose TEXT NOT NULL DEFAULT 'login'" }, { name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" }, { name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" }, { name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" }, @@ -42,7 +43,7 @@ async function ensureAccountPasskeySchema(db: D1Database): Promise { await db .prepare( 'CREATE TABLE IF NOT EXISTS webauthn_credentials (' + - 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + + "id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, " + 'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)' @@ -100,6 +101,7 @@ function parseTransports(value: string | null): string[] | null { function mapCredentialRow(row: { id: string; user_id: string; + purpose?: string | null; name: string; public_key: string; credential_id: string; @@ -117,6 +119,7 @@ function mapCredentialRow(row: { return { id: row.id, userId: row.user_id, + purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login', name: row.name, publicKey: row.public_key, credentialId: row.credential_id, @@ -160,16 +163,17 @@ export async function saveAccountPasskeyCredential( await safeBind( db.prepare( 'INSERT INTO webauthn_credentials(' + - 'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' + + 'id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, ' + 'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' + - ') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + ') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' + - 'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' + + 'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' + 'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' + 'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at' ), credential.id, credential.userId, + credential.purpose, credential.name, credential.publicKey, credential.credentialId, @@ -188,12 +192,13 @@ export async function saveAccountPasskeyCredential( export async function listAccountPasskeyCredentialsByUserId( db: D1Database, - userId: string + userId: string, + purpose: AccountPasskeyCredential['purpose'] = 'login' ): Promise { await ensureAccountPasskeySchema(db); const rows = await db - .prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC') - .bind(userId) + .prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC') + .bind(userId, purpose) .all(); return (rows.results || []).map(mapCredentialRow); } @@ -225,12 +230,13 @@ export async function getAccountPasskeyCredentialByCredentialId( export async function countAccountPasskeyCredentialsByUserId( db: D1Database, - userId: string + userId: string, + purpose: AccountPasskeyCredential['purpose'] = 'login' ): Promise { await ensureAccountPasskeySchema(db); const row = await db - .prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?') - .bind(userId) + .prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?') + .bind(userId, purpose) .first<{ count: number }>(); return Number(row?.count || 0); } @@ -272,12 +278,13 @@ export async function updateAccountPasskeyEncryption( export async function deleteAccountPasskeyCredential( db: D1Database, userId: string, - id: string + id: string, + purpose: AccountPasskeyCredential['purpose'] = 'login' ): Promise { await ensureAccountPasskeySchema(db); const result = await db - .prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?') - .bind(userId, id) + .prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?') + .bind(userId, id, purpose) .run(); return Number(result.meta.changes || 0) > 0; } diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index b51b1d6..140e316 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -140,10 +140,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)', 'CREATE TABLE IF NOT EXISTS webauthn_credentials (' + - 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + 'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'ALTER TABLE webauthn_credentials ADD COLUMN purpose TEXT NOT NULL DEFAULT \'login\'', 'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)', 'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)', 'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)', diff --git a/src/services/storage.ts b/src/services/storage.ts index 1cd7a0c..473f392 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -161,7 +161,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // changes. Existing D1 installs only rerun ensureStorageSchema() when this value // differs from config.schema.version. -const STORAGE_SCHEMA_VERSION = '2026-07-03-yubikey-otp'; +const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa'; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const; // D1-backed storage. @@ -398,8 +398,11 @@ export class StorageService { await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential); } - async getAccountPasskeyCredentialsByUserId(userId: string): Promise { - return listStoredAccountPasskeyCredentialsByUserId(this.db, userId); + async getAccountPasskeyCredentialsByUserId( + userId: string, + purpose: AccountPasskeyCredential['purpose'] = 'login' + ): Promise { + return listStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose); } async getAccountPasskeyCredentialById(userId: string, id: string): Promise { @@ -410,8 +413,11 @@ export class StorageService { return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId); } - async countAccountPasskeyCredentialsByUserId(userId: string): Promise { - return countStoredAccountPasskeyCredentialsByUserId(this.db, userId); + async countAccountPasskeyCredentialsByUserId( + userId: string, + purpose: AccountPasskeyCredential['purpose'] = 'login' + ): Promise { + return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose); } async updateAccountPasskeyCounter( @@ -442,8 +448,12 @@ export class StorageService { ); } - async deleteAccountPasskeyCredential(userId: string, id: string): Promise { - return deleteStoredAccountPasskeyCredential(this.db, userId, id); + async deleteAccountPasskeyCredential( + userId: string, + id: string, + purpose: AccountPasskeyCredential['purpose'] = 'login' + ): Promise { + return deleteStoredAccountPasskeyCredential(this.db, userId, id, purpose); } async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index 798b079..e6d82bf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -252,6 +252,7 @@ export type AccountPasskeyPrfStatus = 0 | 1 | 2; export interface AccountPasskeyCredential { id: string; userId: string; + purpose: 'login' | 'twoFactor'; name: string; publicKey: string; credentialId: string; @@ -267,7 +268,12 @@ export interface AccountPasskeyCredential { updatedAt: string; } -export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet'; +export type AccountPasskeyChallengeScope = + | 'Authentication' + | 'CreateCredential' + | 'UpdateKeySet' + | 'TwoFactorAuthentication' + | 'TwoFactorCreate'; export interface AccountPasskeyChallenge { challengeHash: string; diff --git a/src/utils/account-passkeys.ts b/src/utils/account-passkeys.ts index 07a47df..e5211a0 100644 --- a/src/utils/account-passkeys.ts +++ b/src/utils/account-passkeys.ts @@ -59,7 +59,9 @@ export async function sha256Base64Url(value: string): Promise { } export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number { - return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS; + return scope === 'CreateCredential' || scope === 'TwoFactorCreate' + ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS + : ACCOUNT_PASSKEY_TOKEN_TTL_MS; } export async function createAccountPasskeyToken( diff --git a/webapp/public/webauthn-fallback-connector.html b/webapp/public/webauthn-fallback-connector.html new file mode 100644 index 0000000..d50af30 --- /dev/null +++ b/webapp/public/webauthn-fallback-connector.html @@ -0,0 +1,346 @@ + + + + + + NodeWarden WebAuthn Connector + + + +
+
+
+ NodeWarden + NodeWarden +
+

Verify your identity

+

Use your security key to finish two-step verification.

+
+
+ + +
+
+
+ + + + diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 797a796..a477709 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -58,6 +58,7 @@ import { type PendingPasskeyPassword, type PendingTotp, } from '@/lib/app-auth'; +import { assertTwoFactorPasskey } from '@/lib/account-passkeys'; import useAccountSecurityActions from '@/hooks/useAccountSecurityActions'; import useAdminActions from '@/hooks/useAdminActions'; import useBackupActions from '@/hooks/useBackupActions'; @@ -152,6 +153,8 @@ const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15; const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101; const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102; +const TWO_FACTOR_PROVIDER_YUBIKEY = 3; +const TWO_FACTOR_PROVIDER_WEBAUTHN = 7; type ThemePreference = 'system' | 'light' | 'dark'; type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30; @@ -654,19 +657,38 @@ export default function App() { } } + function handleSelectTotpProvider(providerType: number) { + if (totpSubmitting) return; + setPendingTotp((current) => { + if (!current || current.providerType === providerType) return current; + const canUseProvider = current.availableProviders.includes(providerType); + if (!canUseProvider) return current; + return { + ...current, + providerType, + providerData: current.providerDataByType[providerType], + }; + }); + setTotpCode(''); + } + async function handleTotpVerify() { if (totpSubmitting) return; if (!pendingTotp) return; - if (!totpCode.trim()) { - pushToast('error', pendingTotp.providerType === 3 ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code')); + const isPasskeyTwoFactor = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN; + if (!isPasskeyTwoFactor && !totpCode.trim()) { + pushToast('error', pendingTotp.providerType === TWO_FACTOR_PROVIDER_YUBIKEY ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code')); return; } setTotpSubmitting(true); try { - const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); + const token = isPasskeyTwoFactor + ? await assertTwoFactorPasskey(pendingTotp.providerData) + : totpCode; + const login = await performTotpLogin(pendingTotp, token, rememberDevice); await finalizeLogin(login); } catch (error) { - pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : t('txt_totp_verify_failed')); + pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : isPasskeyTwoFactor ? t('txt_passkey_verification_failed') : t('txt_totp_verify_failed')); } finally { setTotpSubmitting(false); } @@ -952,11 +974,13 @@ export default function App() { onCancelConfirm={() => {}} pendingTotpOpen={false} pendingTotpProviderType={0} + pendingTotpAvailableProviders={[]} totpCode="" rememberDevice={false} onTotpCodeChange={() => {}} onRememberDeviceChange={() => {}} onConfirmTotp={() => {}} + onSelectTotpProvider={() => {}} onCancelTotp={() => {}} onUseRecoveryCode={() => {}} totpSubmitting={false} @@ -1957,6 +1981,7 @@ export default function App() { adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '', totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled, yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled, + passkey2faEnabled: !!twoFactorStatusQuery.data?.passkeyEnabled, lockTimeoutMinutes, sessionTimeoutAction, authorizedDevices: authorizedDevicesQuery.data || [], @@ -2014,6 +2039,10 @@ export default function App() { onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials, onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials, onDisableYubiKey: accountSecurityActions.disableYubiKey, + onGetTwoFactorPasskeySettings: accountSecurityActions.getTwoFactorPasskeySettings, + onCreateTwoFactorPasskey: accountSecurityActions.createTwoFactorPasskey, + onDeleteTwoFactorPasskey: accountSecurityActions.deleteTwoFactorPasskey, + onDisableTwoFactorPasskeys: accountSecurityActions.disableTwoFactorPasskeys, onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onGetApiKey: accountSecurityActions.getApiKey, onRotateApiKey: accountSecurityActions.rotateApiKey, @@ -2219,11 +2248,13 @@ export default function App() { onCancelConfirm={() => setConfirm(null)} pendingTotpOpen={!!pendingTotp} pendingTotpProviderType={pendingTotp?.providerType ?? 0} + pendingTotpAvailableProviders={pendingTotp?.availableProviders ?? []} totpCode={totpCode} rememberDevice={rememberDevice} onTotpCodeChange={setTotpCode} onRememberDeviceChange={setRememberDevice} onConfirmTotp={() => void handleTotpVerify()} + onSelectTotpProvider={handleSelectTotpProvider} onCancelTotp={() => { if (totpSubmitting) return; setPendingTotp(null); @@ -2279,11 +2310,13 @@ export default function App() { onCancelConfirm={() => setConfirm(null)} pendingTotpOpen={false} pendingTotpProviderType={0} + pendingTotpAvailableProviders={[]} totpCode="" rememberDevice={false} onTotpCodeChange={() => {}} onRememberDeviceChange={() => {}} onConfirmTotp={() => {}} + onSelectTotpProvider={() => {}} onCancelTotp={() => {}} onUseRecoveryCode={() => {}} totpSubmitting={false} diff --git a/webapp/src/components/AppGlobalOverlays.tsx b/webapp/src/components/AppGlobalOverlays.tsx index 59c4f24..360516f 100644 --- a/webapp/src/components/AppGlobalOverlays.tsx +++ b/webapp/src/components/AppGlobalOverlays.tsx @@ -1,3 +1,4 @@ +import { useEffect, useMemo, useState } from 'preact/hooks'; import ConfirmDialog from '@/components/ConfirmDialog'; import ToastHost from '@/components/ToastHost'; import { t } from '@/lib/i18n'; @@ -22,11 +23,13 @@ interface AppGlobalOverlaysProps { onCancelConfirm: () => void; pendingTotpOpen: boolean; pendingTotpProviderType?: number; + pendingTotpAvailableProviders?: number[]; totpCode: string; rememberDevice: boolean; onTotpCodeChange: (value: string) => void; onRememberDeviceChange: (checked: boolean) => void; onConfirmTotp: () => void; + onSelectTotpProvider: (providerType: number) => void; onCancelTotp: () => void; onUseRecoveryCode: () => void; totpSubmitting: boolean; @@ -38,8 +41,40 @@ interface AppGlobalOverlaysProps { disableTotpSubmitting: boolean; } +const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; +const TWO_FACTOR_PROVIDER_YUBIKEY = 3; +const TWO_FACTOR_PROVIDER_WEBAUTHN = 7; +const TWO_FACTOR_PROVIDER_ORDER = [ + TWO_FACTOR_PROVIDER_WEBAUTHN, + TWO_FACTOR_PROVIDER_YUBIKEY, + TWO_FACTOR_PROVIDER_AUTHENTICATOR, +] as const; + +function uniqueSupportedProviders(providerTypes: number[] | undefined): number[] { + const available = new Set(providerTypes || []); + return TWO_FACTOR_PROVIDER_ORDER.filter((provider) => available.has(provider)); +} + +function twoFactorProviderLabel(providerType: number): string { + if (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN) return t('txt_passkey'); + if (providerType === TWO_FACTOR_PROVIDER_YUBIKEY) return t('txt_otp_from_yubikey'); + return t('txt_authenticator_app'); +} + export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { - const isYubiKeyOtp = props.pendingTotpProviderType === 3; + const [methodChooserOpen, setMethodChooserOpen] = useState(false); + const availableProviders = useMemo( + () => uniqueSupportedProviders(props.pendingTotpAvailableProviders), + [props.pendingTotpAvailableProviders] + ); + const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType); + const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY; + const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN; + + useEffect(() => { + setMethodChooserOpen(false); + }, [props.pendingTotpOpen, props.pendingTotpProviderType]); + return ( <>
+ {alternateProviders.length > 0 && ( +
+ + {methodChooserOpen && ( +
+
{t('txt_select_two_step_login_method')}
+ {alternateProviders.map((providerType) => ( + + ))} +
+ )} +
+ )}
)} > - + {isWebAuthn ? ( +

{t('txt_touch_your_passkey_when_prompted')}

+ ) : ( + + )}