diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 443122c..1223ee7 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -11,10 +11,14 @@ import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { buildAccountKeys } from '../utils/user-decryption'; import { buildProfileResponse } from '../utils/profile-response'; +import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp'; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; +const TWO_FACTOR_PROVIDER_YUBIKEY = 3; const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000; const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId'; +const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key'; // CONTRACT: // users.master_password_hash is server-side login verification only. It does @@ -193,6 +197,31 @@ function readNestedNumber(source: unknown, path: string[]): number | undefined { return typeof current === 'number' ? current : undefined; } +async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise { + const fromEnv = yubicoCredentialsFromEnv(env); + if (fromEnv) return fromEnv; + const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim(); + if (!clientId) return null; + const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim(); + return { clientId, secretKey }; +} + +async function ensureStoredYubicoCredentials( + storage: StorageService, + env: Env, + email: string, + otp: string +): Promise { + const existing = await getStoredYubicoCredentials(storage, env); + if (existing) return existing; + + const credentials = await requestYubicoApiCredentials(email, otp); + if (!credentials) return null; + await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId); + await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey); + return credentials; +} + async function readRequestBody(request: Request): Promise> { const contentType = request.headers.get('content-type') || ''; if (contentType.includes('application/x-www-form-urlencoded')) { @@ -322,6 +351,12 @@ export async function handleRegister(request: Request, env: Env): Promise { + return { + Enabled: isYubiKeyEnabled(user), + Key1: user.yubikeyKey1, + Key2: user.yubikeyKey2, + Key3: user.yubikeyKey3, + Key4: user.yubikeyKey4, + Key5: user.yubikeyKey5, + Nfc: !!user.yubikeyNfc, + Object: 'twoFactorYubiKey', + }; +} + +async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise> { + const credentials = await getStoredYubicoCredentials(storage, env); + return { + ...yubiKeyResponse(user), + YubicoConfigured: !!credentials?.clientId, + YubicoClientId: credentials?.clientId ?? '', + YubicoSecretKey: credentials?.secretKey ?? '', + }; +} + // GET /api/two-factor export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise { void request; @@ -769,9 +827,9 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us const user = await storage.getUserById(userId); if (!user) return errorResponse('User not found', 404); - const data = user.totpSecret - ? [twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)] - : []; + const data = []; + if (user.totpSecret) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)); + if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true)); return jsonResponse({ Data: data, @@ -803,6 +861,27 @@ export async function handleGetTwoFactorAuthenticator(request: Request, env: Env return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken)); } +// POST /api/two-factor/get-yubikey +export async function handleGetTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const auth = new AuthService(env); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + let body: Record; + try { + body = await readRequestBody(request); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']); + const verified = await verifyUserSecret(auth, user, secret); + if (!verified) return errorResponse('User verification failed.', 400); + + return jsonResponse(await yubiKeySettingsResponse(storage, env, user)); +} + // PUT/POST /api/two-factor/authenticator export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); @@ -849,6 +928,151 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env return jsonResponse(twoFactorAuthenticatorResponse(true, key)); } +// PUT/POST /api/two-factor/yubikey +export async function handlePutTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const auth = new AuthService(env); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + let body: Record; + try { + body = await readRequestBody(request); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']); + const verified = await verifyUserSecret(auth, user, secret); + if (!verified) return errorResponse('User verification failed.', 400); + + const keys = [ + readBodyString(body, ['key1', 'Key1']), + readBodyString(body, ['key2', 'Key2']), + readBodyString(body, ['key3', 'Key3']), + readBodyString(body, ['key4', 'Key4']), + readBodyString(body, ['key5', 'Key5']), + ]; + const publicIds: Array = []; + let credentials = await getStoredYubicoCredentials(storage, env); + let apiKeyBootstrapOtpIndex: number | null = null; + const existingPublicIds = [ + user.yubikeyKey1, + user.yubikeyKey2, + user.yubikeyKey3, + user.yubikeyKey4, + user.yubikeyKey5, + ].map((value) => String(value || '').trim().toLowerCase()); + for (const [index, key] of keys.entries()) { + const trimmed = key.trim(); + if (!trimmed) { + publicIds.push(null); + continue; + } + const publicId = yubiKeyPublicIdFromOtp(trimmed); + if (!publicId) return errorResponse('Invalid YubiKey OTP.', 400); + if (isYubiKeyPublicId(trimmed)) { + if (existingPublicIds[index] !== publicId) { + return errorResponse('A full YubiKey OTP is required to add or replace a key.', 400); + } + publicIds.push(publicId); + continue; + } + if (!credentials) { + credentials = await ensureStoredYubicoCredentials(storage, env, user.email, trimmed); + if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400); + apiKeyBootstrapOtpIndex = publicIds.length; + } + if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) { + return errorResponse('Invalid YubiKey OTP.', 400); + } + publicIds.push(publicId); + } + if (!publicIds.some(Boolean)) return errorResponse('At least one YubiKey OTP is required.', 400); + + user.yubikeyKey1 = publicIds[0] ?? null; + user.yubikeyKey2 = publicIds[1] ?? null; + user.yubikeyKey3 = publicIds[2] ?? null; + user.yubikeyKey4 = publicIds[3] ?? null; + user.yubikeyKey5 = publicIds[4] ?? null; + user.yubikeyNfc = !!(body.nfc ?? body.Nfc); + if (!user.totpRecoveryCode) { + user.totpRecoveryCode = createRecoveryCode(); + } + user.updatedAt = new Date().toISOString(); + await storage.saveUser(user); + await storage.deleteRefreshTokensByUserId(user.id); + AuthService.invalidateUserCache(user.id); + await writeAuditEvent(storage, { + actorUserId: user.id, + action: 'account.yubikey.enable', + category: 'security', + level: 'security', + targetType: 'user', + targetId: user.id, + metadata: auditRequestMetadata(request), + }); + + return jsonResponse(await yubiKeySettingsResponse(storage, env, user)); +} + +// PUT/POST /api/two-factor/yubikey/config +export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const auth = new AuthService(env); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + let body: Record; + try { + body = await readRequestBody(request); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']); + const verified = await verifyUserSecret(auth, user, secret); + if (!verified) return errorResponse('User verification failed.', 400); + + const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim(); + const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim(); + if (!clientId) return errorResponse('Yubico Client ID is required.', 400); + + await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, clientId); + await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, secretKey); + + return jsonResponse(await yubiKeySettingsResponse(storage, env, user)); +} + +// POST /api/two-factor/yubikey/bootstrap +export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const auth = new AuthService(env); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + let body: Record; + try { + body = await readRequestBody(request); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']); + const verified = await verifyUserSecret(auth, user, secret); + if (!verified) return errorResponse('User verification failed.', 400); + + const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim(); + if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400); + const credentials = await requestYubicoApiCredentials(user.email, otp); + if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400); + + await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId); + await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey); + + return jsonResponse(await yubiKeySettingsResponse(storage, env, user)); +} + // DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); @@ -865,7 +1089,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 (type !== TWO_FACTOR_PROVIDER_AUTHENTICATOR) { + if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY].includes(type)) { return errorResponse('Two-factor provider is not supported by this server.', 400); } @@ -881,14 +1105,23 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env, } if (!verified) return errorResponse('User verification failed.', 400); - user.totpSecret = null; + if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) { + user.totpSecret = null; + } else { + user.yubikeyKey1 = null; + user.yubikeyKey2 = null; + user.yubikeyKey3 = null; + user.yubikeyKey4 = null; + user.yubikeyKey5 = null; + user.yubikeyNfc = false; + } user.updatedAt = new Date().toISOString(); await storage.saveUser(user); await storage.deleteRefreshTokensByUserId(user.id); AuthService.invalidateUserCache(user.id); await writeAuditEvent(storage, { actorUserId: user.id, - action: 'account.totp.disable', + action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR ? 'account.totp.disable' : 'account.yubikey.disable', category: 'security', level: 'security', targetType: 'user', @@ -896,7 +1129,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env, metadata: auditRequestMetadata(request), }); - return jsonResponse(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, false)); + return jsonResponse(twoFactorProviderResponse(type, false)); } // PUT /api/accounts/totp @@ -1090,6 +1323,12 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis } user.totpSecret = null; + user.yubikeyKey1 = null; + user.yubikeyKey2 = null; + user.yubikeyKey3 = null; + user.yubikeyKey4 = null; + user.yubikeyKey5 = null; + user.yubikeyNfc = false; user.totpRecoveryCode = createRecoveryCode(); user.securityStamp = generateUUID(); user.updatedAt = new Date().toISOString(); diff --git a/src/handlers/admin.ts b/src/handlers/admin.ts index 8b3ae8d..e56af1c 100644 --- a/src/handlers/admin.ts +++ b/src/handlers/admin.ts @@ -76,7 +76,7 @@ export async function handleAdminListUsers( name: user.name, role: user.role, status: user.status, - twoFactorEnabled: !!user.totpSecret, + twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5), creationDate: user.createdAt, revisionDate: user.updatedAt, object: 'user', diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index d67b2aa..beea50f 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -1,4 +1,4 @@ -import { Env, TokenResponse } from '../types'; +import { Env, TokenResponse, User } from '../types'; import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; @@ -23,12 +23,16 @@ import { import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { createPasskeyUserVerificationToken } from '../utils/user-verification-token'; import { constantTimeEquals, verifyApiKey } from '../utils/api-key'; +import { isYubiKeyEnabled, userYubiKeyPublicIds, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp'; 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_RECOVERY_CODE = 8; const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh'; +const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId'; +const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key'; // Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows // the official Identity provider enum (RecoveryCode = 8), while request parsing remains // compatible with older/local provider values. @@ -115,6 +119,15 @@ function readBodyValue(body: Record, names: string[]): string | return undefined; } +async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise { + const fromEnv = yubicoCredentialsFromEnv(env); + if (fromEnv) return fromEnv; + const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim(); + if (!clientId) return null; + const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim(); + return { clientId, secretKey }; +} + function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string { const isHttps = new URL(request.url).protocol === 'https:'; const parts = [ @@ -183,13 +196,19 @@ function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] { }; } -function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response { +function twoFactorRequiredResponse(user?: User, message: string = 'Two factor required.'): Response { // 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(TWO_FACTOR_PROVIDER_AUTHENTICATOR)]; - const providers2: Record = {}; - for (const provider of providers) providers2[provider] = { Email: null }; + const providers: string[] = []; + if (!user || resolveTotpSecret(user.totpSecret)) providers.push(String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)); + if (user && isYubiKeyEnabled(user)) providers.push(String(TWO_FACTOR_PROVIDER_YUBIKEY)); + const providers2: Record> = {}; + for (const provider of providers) { + providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY) + ? { Nfc: user?.yubikeyNfc ?? false } + : { Email: null }; + } const customResponse = { TwoFactorProviders: providers, TwoFactorProviders2: providers2, @@ -370,10 +389,11 @@ export async function handleToken(request: Request, env: Env): Promise ); } - // Optional 2FA: enabled only by per-user secret. + // Optional 2FA: enabled by any supported per-user provider. let trustedTwoFactorTokenToReturn: string | undefined; const effectiveTotpSecret = resolveTotpSecret(user.totpSecret); - if (effectiveTotpSecret) { + const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user); + if (effectiveTotpSecret || effectiveYubiKeyPublicIds.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()); @@ -383,7 +403,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('Two factor required.'); + return twoFactorRequiredResponse(user, 'Two factor required.'); } let passedByRememberToken = false; @@ -398,9 +418,12 @@ 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('Two factor required.'); + return twoFactorRequiredResponse(user, 'Two factor required.'); } } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { + if (!effectiveTotpSecret) { + return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); + } const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken); if (matchedCounter == null) { return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); @@ -409,6 +432,15 @@ export async function handleToken(request: Request, env: Env): Promise if (!consumed) { return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); } + } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_YUBIKEY)) { + const publicId = yubiKeyPublicIdFromOtp(normalizedTwoFactorToken); + if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) { + return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); + } + const credentials = await getStoredYubicoCredentials(storage, env); + if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) { + return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); + } } else if ( normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE || normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) || @@ -418,6 +450,12 @@ export async function handleToken(request: Request, env: Env): Promise return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); } user.totpSecret = null; + user.yubikeyKey1 = null; + user.yubikeyKey2 = null; + user.yubikeyKey3 = null; + user.yubikeyKey4 = null; + user.yubikeyKey5 = null; + user.yubikeyNfc = false; user.totpRecoveryCode = createRecoveryCode(); user.securityStamp = generateUUID(); user.updatedAt = new Date().toISOString(); diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index b0efa0b..ec69c5e 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -15,6 +15,10 @@ import { handleGetTwoFactorProviders, handleGetTwoFactorAuthenticator, handlePutTwoFactorAuthenticator, + handleGetTwoFactorYubiKey, + handlePutTwoFactorYubiKey, + handlePutTwoFactorYubiKeyConfig, + handleBootstrapTwoFactorYubiKeyConfig, handleDisableTwoFactorProvider, handleGetApiKey, handleRotateApiKey, @@ -141,12 +145,30 @@ export async function handleAuthenticatedRoute( return handleGetTwoFactorAuthenticator(request, env, userId); } + if ((path === '/api/two-factor/get-yubikey' || path === '/api/two-factor/get-yubi-key') && method === 'POST') { + return handleGetTwoFactorYubiKey(request, env, userId); + } + if (path === '/api/two-factor/authenticator') { if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId); if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId); return errorResponse('Method not allowed', 405); } + if ((path === '/api/two-factor/yubikey' || path === '/api/two-factor/yubi-key')) { + if (method === 'PUT' || method === 'POST') return handlePutTwoFactorYubiKey(request, env, userId); + if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId); + 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); + } + + if ((path === '/api/two-factor/yubikey/bootstrap' || path === '/api/two-factor/yubi-key/bootstrap') && method === 'POST') { + return handleBootstrapTwoFactorYubiKeyConfig(request, env, userId); + } + if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) { return handleDisableTwoFactorProvider(request, env, userId); } diff --git a/src/services/backup-archive.ts b/src/services/backup-archive.ts index 0ae3cf2..ebfb35b 100644 --- a/src/services/backup-archive.ts +++ b/src/services/backup-archive.ts @@ -427,7 +427,7 @@ export async function buildBackupArchive( const encoder = new TextEncoder(); const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([ queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), - queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), + queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'), diff --git a/src/services/backup-import.ts b/src/services/backup-import.ts index e95c249..351b652 100644 --- a/src/services/backup-import.ts +++ b/src/services/backup-import.ts @@ -297,6 +297,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload[' users: cloneRows(payload.users || []).map((row) => ({ ...row, verify_devices: row.verify_devices ?? 1, + yubikey_nfc: row.yubikey_nfc ?? 0, })), domain_settings: cloneRows(payload.domain_settings || []), user_revisions: cloneRows(payload.user_revisions || []), @@ -619,7 +620,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us buildInsertStatements( db, tableName('users'), - ['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'], + ['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'yubikey_key1', 'yubikey_key2', 'yubikey_key3', 'yubikey_key4', 'yubikey_key5', 'yubikey_nfc', 'created_at', 'updated_at'], payload.users || [] ) ); diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index c7821ed..b51b1d6 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -14,13 +14,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + - 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', + 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1', 'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', + 'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT', + 'ALTER TABLE users ADD COLUMN yubikey_key2 TEXT', + 'ALTER TABLE users ADD COLUMN yubikey_key3 TEXT', + 'ALTER TABLE users ADD COLUMN yubikey_key4 TEXT', + 'ALTER TABLE users ADD COLUMN yubikey_key5 TEXT', + 'ALTER TABLE users ADD COLUMN yubikey_nfc INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE users ADD COLUMN api_key TEXT', 'CREATE TABLE IF NOT EXISTS domain_settings (' + diff --git a/src/services/storage-user-repo.ts b/src/services/storage-user-repo.ts index fdb3ab0..ed7e9ee 100644 --- a/src/services/storage-user-repo.ts +++ b/src/services/storage-user-repo.ts @@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState const USER_SELECT_COLUMNS = 'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' + 'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' + - 'totp_secret, totp_recovery_code, api_key, created_at, updated_at'; + 'totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at'; function mapUserRow(row: any): User { return { @@ -26,6 +26,12 @@ function mapUserRow(row: any): User { verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, totpSecret: row.totp_secret ?? null, totpRecoveryCode: row.totp_recovery_code ?? null, + yubikeyKey1: row.yubikey_key1 ?? null, + yubikeyKey2: row.yubikey_key2 ?? null, + yubikeyKey3: row.yubikey_key3 ?? null, + yubikeyKey4: row.yubikey_key4 ?? null, + yubikeyKey5: row.yubikey_key5 ?? null, + yubikeyNfc: !!row.yubikey_nfc, apiKey: row.api_key ?? null, createdAt: row.created_at, updatedAt: row.updated_at, @@ -65,11 +71,11 @@ export async function getAllUsers(db: D1Database): Promise { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise { const email = user.email.toLowerCase(); const stmt = db.prepare( - 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' + 'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + - 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at' + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, yubikey_key1=excluded.yubikey_key1, yubikey_key2=excluded.yubikey_key2, yubikey_key3=excluded.yubikey_key3, yubikey_key4=excluded.yubikey_key4, yubikey_key5=excluded.yubikey_key5, yubikey_nfc=excluded.yubikey_nfc, api_key=excluded.api_key, updated_at=excluded.updated_at' ); await safeBind( stmt, @@ -91,6 +97,12 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): user.verifyDevices ? 1 : 0, user.totpSecret, user.totpRecoveryCode, + user.yubikeyKey1, + user.yubikeyKey2, + user.yubikeyKey3, + user.yubikeyKey4, + user.yubikeyKey5, + user.yubikeyNfc ? 1 : 0, user.apiKey, user.createdAt, user.updatedAt @@ -104,8 +116,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User) export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise { const email = user.email.toLowerCase(); const stmt = db.prepare( - 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' + - 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' + + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' ); const result = await safeBind( @@ -128,6 +140,12 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: user.verifyDevices ? 1 : 0, user.totpSecret, user.totpRecoveryCode, + user.yubikeyKey1, + user.yubikeyKey2, + user.yubikeyKey3, + user.yubikeyKey4, + user.yubikeyKey5, + user.yubikeyNfc ? 1 : 0, user.apiKey, user.createdAt, user.updatedAt diff --git a/src/services/storage.ts b/src/services/storage.ts index cbbd232..1cd7a0c 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-06-23-totp-login-replay'; +const STORAGE_SCHEMA_VERSION = '2026-07-03-yubikey-otp'; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const; // D1-backed storage. diff --git a/src/types/index.ts b/src/types/index.ts index d41b71a..798b079 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,6 +14,12 @@ export interface Env { WEBAUTHN_RP_ID?: string; WEBAUTHN_RP_NAME?: string; WEBAUTHN_ALLOWED_ORIGINS?: string; + YUBICO_CLIENT_ID?: string; + YUBICO_SECRET_KEY?: string; + YUBICO_VALIDATION_URLS?: string; + 'globalSettings__yubico__clientId'?: string; + 'globalSettings__yubico__key'?: string; + 'globalSettings__yubico__validationUrls'?: string; } export type UserRole = 'admin' | 'user'; @@ -49,6 +55,12 @@ export interface User { verifyDevices?: boolean; totpSecret: string | null; totpRecoveryCode: string | null; + yubikeyKey1: string | null; + yubikeyKey2: string | null; + yubikeyKey3: string | null; + yubikeyKey4: string | null; + yubikeyKey5: string | null; + yubikeyNfc: boolean; apiKey: string | null; createdAt: string; updatedAt: string; @@ -498,6 +510,7 @@ export interface ProfileResponse { masterPasswordHint: string | null; culture: string; twoFactorEnabled: boolean; + yubikeyEnabled?: boolean; key: string; privateKey: string | null; accountKeys: any | null; diff --git a/src/utils/profile-response.ts b/src/utils/profile-response.ts index 3343be8..5329f99 100644 --- a/src/utils/profile-response.ts +++ b/src/utils/profile-response.ts @@ -1,5 +1,6 @@ import type { Env, ProfileResponse, User } from '../types'; import { buildAccountKeys } from './user-decryption'; +import { isYubiKeyEnabled } from './yubico-otp'; export function buildProfileResponse(user: User, env?: Env): ProfileResponse { void env; @@ -16,7 +17,8 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse { usesKeyConnector: false, masterPasswordHint: user.masterPasswordHint, culture: 'en-US', - twoFactorEnabled: !!user.totpSecret, + twoFactorEnabled: !!user.totpSecret || isYubiKeyEnabled(user), + yubikeyEnabled: isYubiKeyEnabled(user), key: user.key, privateKey: user.privateKey, accountKeys, diff --git a/src/utils/yubico-otp.ts b/src/utils/yubico-otp.ts new file mode 100644 index 0000000..3c810db --- /dev/null +++ b/src/utils/yubico-otp.ts @@ -0,0 +1,175 @@ +import type { Env, User } from '../types'; + +const YUBIKEY_PUBLIC_ID_LENGTH = 12; +const YUBIKEY_MIN_OTP_LENGTH = 32; +const YUBIKEY_MAX_OTP_LENGTH = 48; +const YUBICO_DEFAULT_VALIDATION_URL = 'https://api.yubico.com/wsapi/2.0/verify'; +const YUBICO_GET_API_KEY_URL = 'https://upgrade.yubico.com/getapikey/'; +const MODHEX_RE = /^[cbdefghijklnrtuv]+$/; + +export interface YubicoApiCredentials { + clientId: string; + secretKey: string; +} + +export function normalizeYubiKeyOtp(input: string): string { + return String(input || '').replace(/\s+/g, '').toLowerCase(); +} + +export function yubiKeyPublicIdFromOtp(input: string): string | null { + const otp = normalizeYubiKeyOtp(input); + if (otp.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(otp)) return otp; + if (otp.length < YUBIKEY_MIN_OTP_LENGTH || otp.length > YUBIKEY_MAX_OTP_LENGTH) return null; + if (!MODHEX_RE.test(otp)) return null; + return otp.slice(0, YUBIKEY_PUBLIC_ID_LENGTH); +} + +export function isYubiKeyPublicId(input: string): boolean { + const value = normalizeYubiKeyOtp(input); + return value.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(value); +} + +function isYubiKeyOtp(input: string): boolean { + const otp = normalizeYubiKeyOtp(input); + return otp.length >= YUBIKEY_MIN_OTP_LENGTH && otp.length <= YUBIKEY_MAX_OTP_LENGTH && MODHEX_RE.test(otp); +} + +export function userYubiKeyPublicIds(user: User): string[] { + return [ + user.yubikeyKey1, + user.yubikeyKey2, + user.yubikeyKey3, + user.yubikeyKey4, + user.yubikeyKey5, + ].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean); +} + +export function isYubiKeyEnabled(user: User): boolean { + return userYubiKeyPublicIds(user).length > 0; +} + +export function yubicoCredentialsFromEnv(env: Env): YubicoApiCredentials | null { + const clientId = String(env['globalSettings__yubico__clientId'] || env.YUBICO_CLIENT_ID || '').trim(); + const secretKey = String(env['globalSettings__yubico__key'] || env.YUBICO_SECRET_KEY || '').trim(); + return clientId ? { clientId, secretKey } : null; +} + +function randomNonce(): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function parseYubicoResponse(text: string): Record { + const out: Record = {}; + for (const line of text.split(/\r?\n/)) { + const idx = line.indexOf('='); + if (idx <= 0) continue; + out[line.slice(0, idx)] = line.slice(idx + 1); + } + return out; +} + +function base64ToBytes(input: string): Uint8Array { + const binary = atob(input); + const out = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) out[index] = binary.charCodeAt(index); + return out; +} + +function bytesToBase64(input: Uint8Array): string { + let binary = ''; + for (const byte of input) binary += String.fromCharCode(byte); + return btoa(binary); +} + +async function hmacSha1Base64(base64Key: string, message: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + base64ToBytes(base64Key), + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'] + ); + return bytesToBase64(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)))); +} + +function canonicalQuery(params: URLSearchParams): string { + return Array.from(params.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join('&'); +} + +function validationUrls(env: Env): string[] { + const configured = String(env['globalSettings__yubico__validationUrls'] || env.YUBICO_VALIDATION_URLS || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + return configured.length > 0 ? configured : [YUBICO_DEFAULT_VALIDATION_URL]; +} + +export async function requestYubicoApiCredentials(email: string, otpInput: string): Promise { + const otp = normalizeYubiKeyOtp(otpInput); + if (!isYubiKeyOtp(otp)) return null; + + const body = new URLSearchParams(); + body.set('email', String(email || '').trim().toLowerCase()); + body.set('otp', otp); + body.set('terms_conditions', 'consented'); + + const response = await fetch(YUBICO_GET_API_KEY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!response.ok) return null; + + const html = await response.text(); + const clientId = /Client ID:<\/th>\s*(\d+)<\/b>/i.exec(html)?.[1] || ''; + const secretKey = /Secret key:<\/th>\s*([^<]+)<\/code>/i.exec(html)?.[1] || ''; + return clientId ? { clientId, secretKey } : null; +} + +export async function verifyYubicoOtp( + env: Env, + otpInput: string, + credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env) +): Promise { + const otp = normalizeYubiKeyOtp(otpInput); + if (!isYubiKeyOtp(otp)) return false; + + const clientId = String(credentials?.clientId || '').trim(); + if (!clientId) return false; + + const nonce = randomNonce(); + const secretKey = String(credentials?.secretKey || '').trim(); + const params = new URLSearchParams({ + id: clientId, + nonce, + otp, + }); + if (secretKey) { + params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params))); + } + + for (const baseUrl of validationUrls(env)) { + try { + const response = await fetch(`${baseUrl}?${params.toString()}`, { method: 'GET' }); + if (!response.ok) continue; + const parsed = parseYubicoResponse(await response.text()); + if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue; + if (secretKey && parsed.h) { + const signedParams = new URLSearchParams(); + for (const [key, value] of Object.entries(parsed)) { + if (key !== 'h') signedParams.set(key, value); + } + if ((await hmacSha1Base64(secretKey, canonicalQuery(signedParams))) !== parsed.h) continue; + } + return true; + } catch { + continue; + } + } + + return false; +} diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 9a78a91..797a796 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -20,7 +20,7 @@ import { loadProfileSnapshot, saveProfileSnapshot, revokeCurrentSession, - getTotpStatus, + getTwoFactorProviderStatus, getVaultRevisionDate, saveSession, stripProfileSecrets, @@ -658,7 +658,7 @@ export default function App() { if (totpSubmitting) return; if (!pendingTotp) return; if (!totpCode.trim()) { - pushToast('error', t('txt_please_input_totp_code')); + pushToast('error', pendingTotp.providerType === 3 ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code')); return; } setTotpSubmitting(true); @@ -666,7 +666,7 @@ export default function App() { const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); await finalizeLogin(login); } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); + pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : t('txt_totp_verify_failed')); } finally { setTotpSubmitting(false); } @@ -951,6 +951,7 @@ export default function App() { confirm={null} onCancelConfirm={() => {}} pendingTotpOpen={false} + pendingTotpProviderType={0} totpCode="" rememberDevice={false} onTotpCodeChange={() => {}} @@ -1081,9 +1082,9 @@ export default function App() { enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone, staleTime: 30_000, }); - const totpStatusQuery = useQuery({ - queryKey: ['totp-status', vaultCacheKey || session?.email], - queryFn: () => getTotpStatus(authedFetch), + const twoFactorStatusQuery = useQuery({ + queryKey: ['two-factor-status', vaultCacheKey || session?.email], + queryFn: () => getTwoFactorProviderStatus(authedFetch), enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, staleTime: 30_000, }); @@ -1816,7 +1817,7 @@ export default function App() { onNotify: pushToast, onProfileUpdated: setProfile, onSetConfirm: setConfirm, - refetchTotpStatus: totpStatusQuery.refetch, + refetchTwoFactorStatus: twoFactorStatusQuery.refetch, refetchAuthorizedDevices: authorizedDevicesQuery.refetch, }); const adminActions = useAdminActions({ @@ -1954,7 +1955,8 @@ export default function App() { invites: invitesQuery.data || [], adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data), adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '', - totpEnabled: !!totpStatusQuery.data?.enabled, + totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled, + yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled, lockTimeoutMinutes, sessionTimeoutAction, authorizedDevices: authorizedDevicesQuery.data || [], @@ -2004,9 +2006,14 @@ export default function App() { onSavePasswordHint: accountSecurityActions.savePasswordHint, onEnableTotp: async (secret: string, token: string, masterPassword: string) => { await accountSecurityActions.enableTotp(secret, token, masterPassword); - await totpStatusQuery.refetch(); + await twoFactorStatusQuery.refetch(); }, onOpenDisableTotp: () => setDisableTotpOpen(true), + onGetYubiKeySettings: accountSecurityActions.getYubiKeySettings, + onSaveYubiKeySettings: accountSecurityActions.saveYubiKeySettings, + onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials, + onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials, + onDisableYubiKey: accountSecurityActions.disableYubiKey, onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onGetApiKey: accountSecurityActions.getApiKey, onRotateApiKey: accountSecurityActions.rotateApiKey, @@ -2014,6 +2021,9 @@ export default function App() { onCreateAccountPasskey: accountSecurityActions.createAccountPasskey, onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock, onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey, + onRefreshTwoFactorStatus: async () => { + await twoFactorStatusQuery.refetch(); + }, pendingAuthRequests, pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading, pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading, @@ -2208,6 +2218,7 @@ export default function App() { confirm={confirm} onCancelConfirm={() => setConfirm(null)} pendingTotpOpen={!!pendingTotp} + pendingTotpProviderType={pendingTotp?.providerType ?? 0} totpCode={totpCode} rememberDevice={rememberDevice} onTotpCodeChange={setTotpCode} @@ -2267,6 +2278,7 @@ export default function App() { confirm={confirm} onCancelConfirm={() => setConfirm(null)} pendingTotpOpen={false} + pendingTotpProviderType={0} totpCode="" rememberDevice={false} onTotpCodeChange={() => {}} diff --git a/webapp/src/components/AppGlobalOverlays.tsx b/webapp/src/components/AppGlobalOverlays.tsx index 45f242f..59c4f24 100644 --- a/webapp/src/components/AppGlobalOverlays.tsx +++ b/webapp/src/components/AppGlobalOverlays.tsx @@ -21,6 +21,7 @@ interface AppGlobalOverlaysProps { confirm: AppConfirmState | null; onCancelConfirm: () => void; pendingTotpOpen: boolean; + pendingTotpProviderType?: number; totpCode: string; rememberDevice: boolean; onTotpCodeChange: (value: string) => void; @@ -38,6 +39,7 @@ interface AppGlobalOverlaysProps { } export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { + const isYubiKeyOtp = props.pendingTotpProviderType === 3; return ( <>