feat: Add YubiKey OTP support and management features

- Implemented YubiKey OTP settings management in useAccountSecurityActions hook.
- Added API functions for retrieving, saving, and bootstrapping YubiKey OTP credentials.
- Enhanced authentication flow to support multiple two-factor providers, including YubiKey.
- Updated localization files to include new YubiKey-related strings in English, Spanish, Russian, and Chinese.
- Introduced new styles for YubiKey management UI components.
- Created utility functions for YubiKey OTP validation and credential handling.
This commit is contained in:
shuaiplus
2026-07-04 02:49:46 +08:00
parent c7eb6c663d
commit f63b745d05
27 changed files with 1325 additions and 61 deletions
+246 -7
View File
@@ -11,10 +11,14 @@ import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption'; import { buildAccountKeys } from '../utils/user-decryption';
import { buildProfileResponse } from '../utils/profile-response'; 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_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000; const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
// CONTRACT: // CONTRACT:
// users.master_password_hash is server-side login verification only. It does // 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; return typeof current === 'number' ? current : undefined;
} }
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
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<YubicoApiCredentials | null> {
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<Record<string, unknown>> { async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) { if (contentType.includes('application/x-www-form-urlencoded')) {
@@ -322,6 +351,12 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
verifyDevices: true, verifyDevices: true,
totpSecret: null, totpSecret: null,
totpRecoveryCode: null, totpRecoveryCode: null,
yubikeyKey1: null,
yubikeyKey2: null,
yubikeyKey3: null,
yubikeyKey4: null,
yubikeyKey5: null,
yubikeyNfc: false,
apiKey: null, apiKey: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -762,6 +797,29 @@ function twoFactorAuthenticatorResponse(
}; };
} }
function yubiKeyResponse(user: User): Record<string, unknown> {
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<Record<string, unknown>> {
const credentials = await getStoredYubicoCredentials(storage, env);
return {
...yubiKeyResponse(user),
YubicoConfigured: !!credentials?.clientId,
YubicoClientId: credentials?.clientId ?? '',
YubicoSecretKey: credentials?.secretKey ?? '',
};
}
// GET /api/two-factor // GET /api/two-factor
export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> {
void request; void request;
@@ -769,9 +827,9 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
const data = user.totpSecret const data = [];
? [twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)] if (user.totpSecret) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true));
: []; if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true));
return jsonResponse({ return jsonResponse({
Data: data, Data: data,
@@ -803,6 +861,27 @@ export async function handleGetTwoFactorAuthenticator(request: Request, env: Env
return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken)); return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken));
} }
// POST /api/two-factor/get-yubikey
export async function handleGetTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
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<string, unknown>;
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 // PUT/POST /api/two-factor/authenticator
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> { export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -849,6 +928,151 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
return jsonResponse(twoFactorAuthenticatorResponse(true, key)); return jsonResponse(twoFactorAuthenticatorResponse(true, key));
} }
// PUT/POST /api/two-factor/yubikey
export async function handlePutTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
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<string, unknown>;
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<string | null> = [];
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<Response> {
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<string, unknown>;
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<Response> {
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<string, unknown>;
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 // DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> { export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); 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 typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10); 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); 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); 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(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id); AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'account.totp.disable', action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR ? 'account.totp.disable' : 'account.yubikey.disable',
category: 'security', category: 'security',
level: 'security', level: 'security',
targetType: 'user', targetType: 'user',
@@ -896,7 +1129,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
metadata: auditRequestMetadata(request), metadata: auditRequestMetadata(request),
}); });
return jsonResponse(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, false)); return jsonResponse(twoFactorProviderResponse(type, false));
} }
// PUT /api/accounts/totp // PUT /api/accounts/totp
@@ -1090,6 +1323,12 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
} }
user.totpSecret = null; 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.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
+1 -1
View File
@@ -76,7 +76,7 @@ export async function handleAdminListUsers(
name: user.name, name: user.name,
role: user.role, role: user.role,
status: user.status, status: user.status,
twoFactorEnabled: !!user.totpSecret, twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5),
creationDate: user.createdAt, creationDate: user.createdAt,
revisionDate: user.updatedAt, revisionDate: user.updatedAt,
object: 'user', object: 'user',
+47 -9
View File
@@ -1,4 +1,4 @@
import { Env, TokenResponse } from '../types'; import { Env, TokenResponse, User } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
@@ -23,12 +23,16 @@ import {
import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token'; import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { constantTimeEquals, verifyApiKey } from '../utils/api-key'; 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_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_REMEMBER = 5; const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8; const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh'; 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 // 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 // the official Identity provider enum (RecoveryCode = 8), while request parsing remains
// compatible with older/local provider values. // compatible with older/local provider values.
@@ -115,6 +119,15 @@ function readBodyValue(body: Record<string, string>, names: string[]): string |
return undefined; return undefined;
} }
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
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 { function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
const isHttps = new URL(request.url).protocol === 'https:'; const isHttps = new URL(request.url).protocol === 'https:';
const parts = [ 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. // Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to // 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. // parse the challenge if an unknown recovery provider key such as "8" is included.
const providers = [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)]; const providers: string[] = [];
const providers2: Record<string, { Email: null }> = {}; if (!user || resolveTotpSecret(user.totpSecret)) providers.push(String(TWO_FACTOR_PROVIDER_AUTHENTICATOR));
for (const provider of providers) providers2[provider] = { Email: null }; if (user && isYubiKeyEnabled(user)) providers.push(String(TWO_FACTOR_PROVIDER_YUBIKEY));
const providers2: Record<string, Record<string, unknown>> = {};
for (const provider of providers) {
providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY)
? { Nfc: user?.yubikeyNfc ?? false }
: { Email: null };
}
const customResponse = { const customResponse = {
TwoFactorProviders: providers, TwoFactorProviders: providers,
TwoFactorProviders2: providers2, TwoFactorProviders2: providers2,
@@ -370,10 +389,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
} }
// Optional 2FA: enabled only by per-user secret. // Optional 2FA: enabled by any supported per-user provider.
let trustedTwoFactorTokenToReturn: string | undefined; let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret); const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) { const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user);
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0) {
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim(); const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim(); const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').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<Response>
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing, // Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
// respond with a 2FA challenge payload. // respond with a 2FA challenge payload.
if (!hasProvider || !hasToken) { if (!hasProvider || !hasToken) {
return twoFactorRequiredResponse('Two factor required.'); return twoFactorRequiredResponse(user, 'Two factor required.');
} }
let passedByRememberToken = false; let passedByRememberToken = false;
@@ -398,9 +418,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow. // Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
if (!passedByRememberToken) { if (!passedByRememberToken) {
return twoFactorRequiredResponse('Two factor required.'); return twoFactorRequiredResponse(user, 'Two factor required.');
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
if (!effectiveTotpSecret) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken); const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
if (matchedCounter == null) { if (matchedCounter == null) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
@@ -409,6 +432,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
if (!consumed) { if (!consumed) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); 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 ( } else if (
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE || normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) || normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
@@ -418,6 +450,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
user.totpSecret = null; 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.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
+22
View File
@@ -15,6 +15,10 @@ import {
handleGetTwoFactorProviders, handleGetTwoFactorProviders,
handleGetTwoFactorAuthenticator, handleGetTwoFactorAuthenticator,
handlePutTwoFactorAuthenticator, handlePutTwoFactorAuthenticator,
handleGetTwoFactorYubiKey,
handlePutTwoFactorYubiKey,
handlePutTwoFactorYubiKeyConfig,
handleBootstrapTwoFactorYubiKeyConfig,
handleDisableTwoFactorProvider, handleDisableTwoFactorProvider,
handleGetApiKey, handleGetApiKey,
handleRotateApiKey, handleRotateApiKey,
@@ -141,12 +145,30 @@ export async function handleAuthenticatedRoute(
return handleGetTwoFactorAuthenticator(request, env, userId); 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 (path === '/api/two-factor/authenticator') {
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId); if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId); if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
return errorResponse('Method not allowed', 405); 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')) { if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
return handleDisableTwoFactorProvider(request, env, userId); return handleDisableTwoFactorProvider(request, env, userId);
} }
+1 -1
View File
@@ -427,7 +427,7 @@ export async function buildBackupArchive(
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([ 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 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, 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 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'), queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
+2 -1
View File
@@ -297,6 +297,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
users: cloneRows(payload.users || []).map((row) => ({ users: cloneRows(payload.users || []).map((row) => ({
...row, ...row,
verify_devices: row.verify_devices ?? 1, verify_devices: row.verify_devices ?? 1,
yubikey_nfc: row.yubikey_nfc ?? 0,
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []), user_revisions: cloneRows(payload.user_revisions || []),
@@ -619,7 +620,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
buildInsertStatements( buildInsertStatements(
db, db,
tableName('users'), 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 || [] payload.users || []
) )
); );
+7 -1
View File
@@ -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, ' + '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, ' + '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, ' + '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 master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', '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 status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1', '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_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code 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', 'ALTER TABLE users ADD COLUMN api_key TEXT',
'CREATE TABLE IF NOT EXISTS domain_settings (' + 'CREATE TABLE IF NOT EXISTS domain_settings (' +
+24 -6
View File
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
const USER_SELECT_COLUMNS = const USER_SELECT_COLUMNS =
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' + '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, ' + '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 { function mapUserRow(row: any): User {
return { return {
@@ -26,6 +26,12 @@ function mapUserRow(row: any): User {
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
totpSecret: row.totp_secret ?? null, totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? 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, apiKey: row.api_key ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
@@ -65,11 +71,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( 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) ' + '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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + '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, ' + '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( await safeBind(
stmt, stmt,
@@ -91,6 +97,12 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
user.verifyDevices ? 1 : 0, user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.yubikeyKey1,
user.yubikeyKey2,
user.yubikeyKey3,
user.yubikeyKey4,
user.yubikeyKey5,
user.yubikeyNfc ? 1 : 0,
user.apiKey, user.apiKey,
user.createdAt, user.createdAt,
user.updatedAt 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<boolean> { export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( 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) ' + '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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
); );
const result = await safeBind( const result = await safeBind(
@@ -128,6 +140,12 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
user.verifyDevices ? 1 : 0, user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.yubikeyKey1,
user.yubikeyKey2,
user.yubikeyKey3,
user.yubikeyKey4,
user.yubikeyKey5,
user.yubikeyNfc ? 1 : 0,
user.apiKey, user.apiKey,
user.createdAt, user.createdAt,
user.updatedAt user.updatedAt
+1 -1
View File
@@ -161,7 +161,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // 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; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
+13
View File
@@ -14,6 +14,12 @@ export interface Env {
WEBAUTHN_RP_ID?: string; WEBAUTHN_RP_ID?: string;
WEBAUTHN_RP_NAME?: string; WEBAUTHN_RP_NAME?: string;
WEBAUTHN_ALLOWED_ORIGINS?: 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'; export type UserRole = 'admin' | 'user';
@@ -49,6 +55,12 @@ export interface User {
verifyDevices?: boolean; verifyDevices?: boolean;
totpSecret: string | null; totpSecret: string | null;
totpRecoveryCode: 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; apiKey: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -498,6 +510,7 @@ export interface ProfileResponse {
masterPasswordHint: string | null; masterPasswordHint: string | null;
culture: string; culture: string;
twoFactorEnabled: boolean; twoFactorEnabled: boolean;
yubikeyEnabled?: boolean;
key: string; key: string;
privateKey: string | null; privateKey: string | null;
accountKeys: any | null; accountKeys: any | null;
+3 -1
View File
@@ -1,5 +1,6 @@
import type { Env, ProfileResponse, User } from '../types'; import type { Env, ProfileResponse, User } from '../types';
import { buildAccountKeys } from './user-decryption'; import { buildAccountKeys } from './user-decryption';
import { isYubiKeyEnabled } from './yubico-otp';
export function buildProfileResponse(user: User, env?: Env): ProfileResponse { export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
void env; void env;
@@ -16,7 +17,8 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
usesKeyConnector: false, usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint, masterPasswordHint: user.masterPasswordHint,
culture: 'en-US', culture: 'en-US',
twoFactorEnabled: !!user.totpSecret, twoFactorEnabled: !!user.totpSecret || isYubiKeyEnabled(user),
yubikeyEnabled: isYubiKeyEnabled(user),
key: user.key, key: user.key,
privateKey: user.privateKey, privateKey: user.privateKey,
accountKeys, accountKeys,
+175
View File
@@ -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<string, string> {
const out: Record<string, string> = {};
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<string> {
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<YubicoApiCredentials | null> {
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*<td><b>(\d+)<\/b>/i.exec(html)?.[1] || '';
const secretKey = /Secret key:<\/th>\s*<td><code>([^<]+)<\/code>/i.exec(html)?.[1] || '';
return clientId ? { clientId, secretKey } : null;
}
export async function verifyYubicoOtp(
env: Env,
otpInput: string,
credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env)
): Promise<boolean> {
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;
}
+21 -9
View File
@@ -20,7 +20,7 @@ import {
loadProfileSnapshot, loadProfileSnapshot,
saveProfileSnapshot, saveProfileSnapshot,
revokeCurrentSession, revokeCurrentSession,
getTotpStatus, getTwoFactorProviderStatus,
getVaultRevisionDate, getVaultRevisionDate,
saveSession, saveSession,
stripProfileSecrets, stripProfileSecrets,
@@ -658,7 +658,7 @@ export default function App() {
if (totpSubmitting) return; if (totpSubmitting) return;
if (!pendingTotp) return; if (!pendingTotp) return;
if (!totpCode.trim()) { 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; return;
} }
setTotpSubmitting(true); setTotpSubmitting(true);
@@ -666,7 +666,7 @@ export default function App() {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
await finalizeLogin(login); await finalizeLogin(login);
} catch (error) { } 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 { } finally {
setTotpSubmitting(false); setTotpSubmitting(false);
} }
@@ -951,6 +951,7 @@ export default function App() {
confirm={null} confirm={null}
onCancelConfirm={() => {}} onCancelConfirm={() => {}}
pendingTotpOpen={false} pendingTotpOpen={false}
pendingTotpProviderType={0}
totpCode="" totpCode=""
rememberDevice={false} rememberDevice={false}
onTotpCodeChange={() => {}} onTotpCodeChange={() => {}}
@@ -1081,9 +1082,9 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
const totpStatusQuery = useQuery({ const twoFactorStatusQuery = useQuery({
queryKey: ['totp-status', vaultCacheKey || session?.email], queryKey: ['two-factor-status', vaultCacheKey || session?.email],
queryFn: () => getTotpStatus(authedFetch), queryFn: () => getTwoFactorProviderStatus(authedFetch),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
@@ -1816,7 +1817,7 @@ export default function App() {
onNotify: pushToast, onNotify: pushToast,
onProfileUpdated: setProfile, onProfileUpdated: setProfile,
onSetConfirm: setConfirm, onSetConfirm: setConfirm,
refetchTotpStatus: totpStatusQuery.refetch, refetchTwoFactorStatus: twoFactorStatusQuery.refetch,
refetchAuthorizedDevices: authorizedDevicesQuery.refetch, refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
}); });
const adminActions = useAdminActions({ const adminActions = useAdminActions({
@@ -1954,7 +1955,8 @@ export default function App() {
invites: invitesQuery.data || [], invites: invitesQuery.data || [],
adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data), adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data),
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '', adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
totpEnabled: !!totpStatusQuery.data?.enabled, totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
lockTimeoutMinutes, lockTimeoutMinutes,
sessionTimeoutAction, sessionTimeoutAction,
authorizedDevices: authorizedDevicesQuery.data || [], authorizedDevices: authorizedDevicesQuery.data || [],
@@ -2004,9 +2006,14 @@ export default function App() {
onSavePasswordHint: accountSecurityActions.savePasswordHint, onSavePasswordHint: accountSecurityActions.savePasswordHint,
onEnableTotp: async (secret: string, token: string, masterPassword: string) => { onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
await accountSecurityActions.enableTotp(secret, token, masterPassword); await accountSecurityActions.enableTotp(secret, token, masterPassword);
await totpStatusQuery.refetch(); await twoFactorStatusQuery.refetch();
}, },
onOpenDisableTotp: () => setDisableTotpOpen(true), onOpenDisableTotp: () => setDisableTotpOpen(true),
onGetYubiKeySettings: accountSecurityActions.getYubiKeySettings,
onSaveYubiKeySettings: accountSecurityActions.saveYubiKeySettings,
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
onDisableYubiKey: accountSecurityActions.disableYubiKey,
onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey, onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey, onRotateApiKey: accountSecurityActions.rotateApiKey,
@@ -2014,6 +2021,9 @@ export default function App() {
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey, onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock, onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey, onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
onRefreshTwoFactorStatus: async () => {
await twoFactorStatusQuery.refetch();
},
pendingAuthRequests, pendingAuthRequests,
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading, pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading,
pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading, pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading,
@@ -2208,6 +2218,7 @@ export default function App() {
confirm={confirm} confirm={confirm}
onCancelConfirm={() => setConfirm(null)} onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={!!pendingTotp} pendingTotpOpen={!!pendingTotp}
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
totpCode={totpCode} totpCode={totpCode}
rememberDevice={rememberDevice} rememberDevice={rememberDevice}
onTotpCodeChange={setTotpCode} onTotpCodeChange={setTotpCode}
@@ -2267,6 +2278,7 @@ export default function App() {
confirm={confirm} confirm={confirm}
onCancelConfirm={() => setConfirm(null)} onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={false} pendingTotpOpen={false}
pendingTotpProviderType={0}
totpCode="" totpCode=""
rememberDevice={false} rememberDevice={false}
onTotpCodeChange={() => {}} onTotpCodeChange={() => {}}
+6 -4
View File
@@ -21,6 +21,7 @@ interface AppGlobalOverlaysProps {
confirm: AppConfirmState | null; confirm: AppConfirmState | null;
onCancelConfirm: () => void; onCancelConfirm: () => void;
pendingTotpOpen: boolean; pendingTotpOpen: boolean;
pendingTotpProviderType?: number;
totpCode: string; totpCode: string;
rememberDevice: boolean; rememberDevice: boolean;
onTotpCodeChange: (value: string) => void; onTotpCodeChange: (value: string) => void;
@@ -38,6 +39,7 @@ interface AppGlobalOverlaysProps {
} }
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const isYubiKeyOtp = props.pendingTotpProviderType === 3;
return ( return (
<> <>
<ConfirmDialog <ConfirmDialog
@@ -55,8 +57,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
<ConfirmDialog <ConfirmDialog
open={props.pendingTotpOpen} open={props.pendingTotpOpen}
title={t('txt_two_step_verification')} title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : t('txt_two_step_verification')}
message={t('txt_password_is_already_verified')} message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : t('txt_password_is_already_verified')}
confirmText={t('txt_verify')} confirmText={t('txt_verify')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
showIcon={false} showIcon={false}
@@ -74,8 +76,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
)} )}
> >
<label className="field"> <label className="field">
<span>{t('txt_totp_code')}</span> <span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} /> <input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label> </label>
<label className="check-line check-line-compact"> <label className="check-line check-line-compact">
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} /> <input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
+15 -1
View File
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
import type { AuditLogFilters } from '@/lib/api/admin'; import type { AuditLogFilters } from '@/lib/api/admin';
import type { CiphersImportPayload } from '@/lib/api/vault'; import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats'; import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage')); const VaultPage = lazy(() => import('@/components/VaultPage'));
@@ -55,6 +55,7 @@ export interface AppMainRoutesProps {
adminLoading: boolean; adminLoading: boolean;
adminError: string; adminError: string;
totpEnabled: boolean; totpEnabled: boolean;
yubikeyEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[]; authorizedDevices: AuthorizedDevice[];
@@ -112,6 +113,11 @@ export interface AppMainRoutesProps {
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetYubiKeySettings: (masterPassword: string) => Promise<YubiKeyOtpSettings>;
onSaveYubiKeySettings: (keys: string[], nfc: boolean, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onDisableYubiKey: (masterPassword: string) => Promise<void>;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>; onRotateApiKey: (masterPassword: string) => Promise<string>;
@@ -119,6 +125,7 @@ export interface AppMainRoutesProps {
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>; onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
onRefreshTwoFactorStatus: () => Promise<void>;
pendingAuthRequests: AuthRequest[]; pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean; pendingAuthRequestsLoading: boolean;
pendingAuthRequestsRefreshing: boolean; pendingAuthRequestsRefreshing: boolean;
@@ -268,6 +275,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsPage <SettingsPage
profile={props.profile} profile={props.profile}
totpEnabled={props.totpEnabled} totpEnabled={props.totpEnabled}
yubikeyEnabled={props.yubikeyEnabled}
themePreference={props.themePreference} themePreference={props.themePreference}
lockTimeoutMinutes={props.lockTimeoutMinutes} lockTimeoutMinutes={props.lockTimeoutMinutes}
sessionTimeoutAction={props.sessionTimeoutAction} sessionTimeoutAction={props.sessionTimeoutAction}
@@ -277,6 +285,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onSavePasswordHint={props.onSavePasswordHint} onSavePasswordHint={props.onSavePasswordHint}
onEnableTotp={props.onEnableTotp} onEnableTotp={props.onEnableTotp}
onOpenDisableTotp={props.onOpenDisableTotp} onOpenDisableTotp={props.onOpenDisableTotp}
onGetYubiKeySettings={props.onGetYubiKeySettings}
onSaveYubiKeySettings={props.onSaveYubiKeySettings}
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
onDisableYubiKey={props.onDisableYubiKey}
onGetRecoveryCode={props.onGetRecoveryCode} onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey} onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey} onRotateApiKey={props.onRotateApiKey}
@@ -284,6 +297,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onCreateAccountPasskey={props.onCreateAccountPasskey} onCreateAccountPasskey={props.onCreateAccountPasskey}
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock} onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
onDeleteAccountPasskey={props.onDeleteAccountPasskey} onDeleteAccountPasskey={props.onDeleteAccountPasskey}
onRefreshTwoFactorStatus={props.onRefreshTwoFactorStatus}
onLockTimeoutChange={props.onLockTimeoutChange} onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange} onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify} onNotify={props.onNotify}
+4 -3
View File
@@ -7,7 +7,7 @@ import { t } from '@/lib/i18n';
interface ConfirmDialogProps { interface ConfirmDialogProps {
open: boolean; open: boolean;
title: string; title: string;
message: string; message?: string;
variant?: 'default' | 'warning'; variant?: 'default' | 'warning';
showIcon?: boolean; showIcon?: boolean;
confirmText?: string; confirmText?: string;
@@ -90,6 +90,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []); const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`; const titleId = `${dialogId}-title`;
const messageId = `${dialogId}-message`; const messageId = `${dialogId}-message`;
const hasMessage = !!props.message;
const canDismiss = !props.cancelDisabled && !closing; const canDismiss = !props.cancelDisabled && !closing;
useEffect(() => { useEffect(() => {
@@ -193,7 +194,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={messageId} aria-describedby={hasMessage ? messageId : undefined}
tabIndex={-1} tabIndex={-1}
onKeyDown={handleDialogKeyDown} onKeyDown={handleDialogKeyDown}
onSubmit={(e) => { onSubmit={(e) => {
@@ -228,7 +229,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
</button> </button>
)} )}
<h3 id={titleId} className="dialog-title">{props.title}</h3> <h3 id={titleId} className="dialog-title">{props.title}</h3>
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div> {hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
{props.children} {props.children}
{!props.hideConfirm && ( {!props.hideConfirm && (
<button <button
+322 -5
View File
@@ -2,13 +2,14 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact'; import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator'; import qrcode from 'qrcode-generator';
import type { AccountPasskeyCredential, Profile } from '@/lib/types'; import type { AccountPasskeyCredential, Profile, YubiKeyOtpSettings } from '@/lib/types';
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n'; import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
interface SettingsPageProps { interface SettingsPageProps {
profile: Profile; profile: Profile;
totpEnabled: boolean; totpEnabled: boolean;
yubikeyEnabled: boolean;
themePreference: ThemePreference; themePreference: ThemePreference;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
@@ -18,6 +19,11 @@ interface SettingsPageProps {
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetYubiKeySettings: (masterPassword: string) => Promise<YubiKeyOtpSettings>;
onSaveYubiKeySettings: (keys: string[], nfc: boolean, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onDisableYubiKey: (masterPassword: string) => Promise<void>;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>; onRotateApiKey: (masterPassword: string) => Promise<string>;
@@ -25,6 +31,7 @@ interface SettingsPageProps {
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>; onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
onRefreshTwoFactorStatus: () => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void; onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -39,6 +46,7 @@ type MasterPasswordPromptAction =
| 'apiKey' | 'apiKey'
| 'rotateApiKey' | 'rotateApiKey'
| 'manageTotp' | 'manageTotp'
| 'manageYubiKey'
| 'createPasskey' | 'createPasskey'
| 'enablePasskeyDirectUnlock' | 'enablePasskeyDirectUnlock'
| 'deletePasskey'; | 'deletePasskey';
@@ -51,6 +59,18 @@ const LOCK_TIMEOUT_OPTIONS = [
{ value: 0, labelKey: 'txt_timeout_never' }, { value: 0, labelKey: 'txt_timeout_never' },
] as const; ] as const;
const EMPTY_YUBIKEY_KEYS: [string, string, string, string, string] = ['', '', '', '', ''];
function formatStoredYubiKey(value: string): string {
if (!value) return '';
if (value.length >= 44) return value;
return `${value}${'•'.repeat(44 - value.length)}`;
}
function normalizeYubiKeyFieldValue(value: string): string {
return value.replace(/\s+/g, '').toLowerCase();
}
function randomBase32Secret(length: number): string { function randomBase32Secret(length: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let out = ''; let out = '';
@@ -111,6 +131,19 @@ export default function SettingsPage(props: SettingsPageProps) {
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false); const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
const [totpManageDialogOpen, setTotpManageDialogOpen] = useState(false); const [totpManageDialogOpen, setTotpManageDialogOpen] = useState(false);
const [yubiKeyDialogOpen, setYubiKeyDialogOpen] = useState(false);
const [yubiKeyMasterPassword, setYubiKeyMasterPassword] = useState('');
const [yubiKeyEnabled, setYubiKeyEnabled] = useState(props.yubikeyEnabled || !!props.profile.yubikeyEnabled);
const [yubiKeyKeys, setYubiKeyKeys] = useState<[string, string, string, string, string]>(EMPTY_YUBIKEY_KEYS);
const [yubiKeyStoredKeys, setYubiKeyStoredKeys] = useState<[string, string, string, string, string]>(EMPTY_YUBIKEY_KEYS);
const [yubiKeyNfc, setYubiKeyNfc] = useState(false);
const [yubiKeyYubicoConfigured, setYubiKeyYubicoConfigured] = useState(false);
const [yubiKeyYubicoClientId, setYubiKeyYubicoClientId] = useState('');
const [yubiKeyYubicoSecretKey, setYubiKeyYubicoSecretKey] = useState('');
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
const [yubiKeyConfigOpen, setYubiKeyConfigOpen] = useState(false);
const [yubiKeySubmitting, setYubiKeySubmitting] = useState(false);
const [twoFactorStatusRefreshing, setTwoFactorStatusRefreshing] = useState(false);
const [recoveryCodeDialogOpen, setRecoveryCodeDialogOpen] = useState(false); const [recoveryCodeDialogOpen, setRecoveryCodeDialogOpen] = useState(false);
const [totpManagePassword, setTotpManagePassword] = useState(''); const [totpManagePassword, setTotpManagePassword] = useState('');
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<MasterPasswordPromptAction | null>(null); const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<MasterPasswordPromptAction | null>(null);
@@ -135,6 +168,10 @@ export default function SettingsPage(props: SettingsPageProps) {
setPasswordHint(props.profile.masterPasswordHint || ''); setPasswordHint(props.profile.masterPasswordHint || '');
}, [props.profile.masterPasswordHint]); }, [props.profile.masterPasswordHint]);
useEffect(() => {
setYubiKeyEnabled(props.yubikeyEnabled || !!props.profile.yubikeyEnabled);
}, [props.yubikeyEnabled, props.profile.yubikeyEnabled]);
useEffect(() => { useEffect(() => {
void refreshAccountPasskeys(); void refreshAccountPasskeys();
}, [props.profile.id]); }, [props.profile.id]);
@@ -207,6 +244,12 @@ export default function SettingsPage(props: SettingsPageProps) {
await props.onVerifyMasterPassword(props.profile.email, masterPassword); await props.onVerifyMasterPassword(props.profile.email, masterPassword);
setTotpManagePassword(masterPassword); setTotpManagePassword(masterPassword);
setTotpManageDialogOpen(true); setTotpManageDialogOpen(true);
} else if (masterPasswordPrompt === 'manageYubiKey') {
const settings = await props.onGetYubiKeySettings(masterPassword);
setYubiKeyMasterPassword(masterPassword);
applyYubiKeySettings(settings);
setYubiKeyConfigOpen(false);
setYubiKeyDialogOpen(true);
} else if (masterPasswordPrompt === 'createPasskey') { } else if (masterPasswordPrompt === 'createPasskey') {
await props.onVerifyMasterPassword(props.profile.email, masterPassword); await props.onVerifyMasterPassword(props.profile.email, masterPassword);
setCreatePasskeyMasterPassword(masterPassword); setCreatePasskeyMasterPassword(masterPassword);
@@ -239,7 +282,9 @@ export default function SettingsPage(props: SettingsPageProps) {
? t('txt_rotate_api_key') ? t('txt_rotate_api_key')
: masterPasswordPrompt === 'manageTotp' : masterPasswordPrompt === 'manageTotp'
? t('txt_totp') ? t('txt_totp')
: masterPasswordPrompt === 'createPasskey' : masterPasswordPrompt === 'manageYubiKey'
? 'YubiKey'
: masterPasswordPrompt === 'createPasskey'
? t('txt_add_account_passkey') ? t('txt_add_account_passkey')
: masterPasswordPrompt === 'enablePasskeyDirectUnlock' : masterPasswordPrompt === 'enablePasskeyDirectUnlock'
? t('txt_enable_passkey_direct_unlock') ? t('txt_enable_passkey_direct_unlock')
@@ -265,6 +310,110 @@ export default function SettingsPage(props: SettingsPageProps) {
setTotpManagePassword(''); setTotpManagePassword('');
} }
function applyYubiKeySettings(settings: YubiKeyOtpSettings): void {
setYubiKeyEnabled(settings.enabled);
setYubiKeyKeys(settings.keys);
setYubiKeyStoredKeys(settings.keys);
setYubiKeyNfc(settings.nfc);
setYubiKeyYubicoConfigured(settings.yubicoConfigured);
setYubiKeyYubicoClientId(settings.yubicoClientId);
setYubiKeyYubicoSecretKey(settings.yubicoSecretKey);
}
function closeYubiKeyDialog(): void {
if (yubiKeySubmitting) return;
setYubiKeyDialogOpen(false);
setYubiKeyMasterPassword('');
setYubiKeyKeys(EMPTY_YUBIKEY_KEYS);
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
setYubiKeyNfc(false);
setYubiKeyEnabled(false);
setYubiKeyYubicoConfigured(false);
setYubiKeyYubicoClientId('');
setYubiKeyYubicoSecretKey('');
setYubiKeyBootstrapOtp('');
setYubiKeyConfigOpen(false);
}
function updateYubiKey(index: number, value: string): void {
setYubiKeyKeys((current) => {
const next = [...current] as [string, string, string, string, string];
next[index] = value;
return next;
});
}
async function saveYubiKeyDialog(): Promise<void> {
if (yubiKeySubmitting) return;
setYubiKeySubmitting(true);
try {
const settings = await props.onSaveYubiKeySettings(yubiKeyKeys.map((value) => value.trim()), yubiKeyNfc, yubiKeyMasterPassword);
applyYubiKeySettings(settings);
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_yubikey_update_failed'));
} finally {
setYubiKeySubmitting(false);
}
}
async function bootstrapYubiKeyConfigDialog(): Promise<void> {
if (yubiKeySubmitting || !yubiKeyBootstrapOtp.trim()) return;
const bootstrapOtp = yubiKeyBootstrapOtp.trim().toLowerCase();
setYubiKeySubmitting(true);
try {
const settings = await props.onBootstrapYubiKeyApiCredentials(bootstrapOtp, yubiKeyMasterPassword);
applyYubiKeySettings(settings);
setYubiKeyKeys(settings.keys);
setYubiKeyBootstrapOtp('');
setYubiKeyConfigOpen(false);
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_yubikey_auto_config_failed'));
} finally {
setYubiKeySubmitting(false);
}
}
async function saveYubiKeyConfigDialog(): Promise<void> {
if (yubiKeySubmitting || !yubiKeyYubicoClientId.trim()) return;
setYubiKeySubmitting(true);
try {
const settings = await props.onSaveYubiKeyApiCredentials(yubiKeyYubicoClientId.trim(), yubiKeyYubicoSecretKey.trim(), yubiKeyMasterPassword);
applyYubiKeySettings(settings);
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_yubikey_config_update_failed'));
} finally {
setYubiKeySubmitting(false);
}
}
async function disableYubiKeyDialog(): Promise<void> {
if (yubiKeySubmitting || !yubiKeyMasterPassword) return;
setYubiKeySubmitting(true);
try {
await props.onDisableYubiKey(yubiKeyMasterPassword);
setYubiKeyEnabled(false);
setYubiKeyKeys(EMPTY_YUBIKEY_KEYS);
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
setYubiKeyNfc(false);
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_disable_yubikey_failed'));
} finally {
setYubiKeySubmitting(false);
}
}
async function refreshTwoFactorStatus(): Promise<void> {
if (twoFactorStatusRefreshing) return;
setTwoFactorStatusRefreshing(true);
try {
await props.onRefreshTwoFactorStatus();
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_load_failed'));
} finally {
setTwoFactorStatusRefreshing(false);
}
}
async function enableTotpFromManageDialog(): Promise<void> { async function enableTotpFromManageDialog(): Promise<void> {
if (totpLocked) return; if (totpLocked) return;
if (!secret.trim() || !token.trim()) { if (!secret.trim() || !token.trim()) {
@@ -543,7 +692,18 @@ export default function SettingsPage(props: SettingsPageProps) {
</section> </section>
<section className="settings-submodule two-step-providers-module"> <section className="settings-submodule two-step-providers-module">
<h3>{t('txt_providers')}</h3> <div className="settings-module-head">
<h3>{t('txt_providers')}</h3>
<button
type="button"
className="btn btn-secondary small"
disabled={twoFactorStatusRefreshing}
onClick={() => void refreshTwoFactorStatus()}
>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh_status')}
</button>
</div>
<div className="two-step-provider-list"> <div className="two-step-provider-list">
<div className="two-step-provider-row"> <div className="two-step-provider-row">
<div className="two-step-provider-icon"> <div className="two-step-provider-icon">
@@ -577,10 +737,13 @@ export default function SettingsPage(props: SettingsPageProps) {
<div className="two-step-provider-row"> <div className="two-step-provider-row">
<div className="two-step-provider-icon two-step-provider-yubico">yubico</div> <div className="two-step-provider-icon two-step-provider-yubico">yubico</div>
<div className="two-step-provider-copy"> <div className="two-step-provider-copy">
<strong>{t('txt_yubico_otp_security_key')}</strong> <div className="two-step-provider-title">
<strong>{t('txt_yubico_otp_security_key')}</strong>
{yubiKeyEnabled && <span className="two-step-enabled-badge">{t('txt_enabled')}</span>}
</div>
<span>{t('txt_yubico_otp_security_key_help')}</span> <span>{t('txt_yubico_otp_security_key_help')}</span>
</div> </div>
<button type="button" className="btn btn-secondary" disabled> <button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('manageYubiKey')}>
{t('txt_manage')} {t('txt_manage')}
</button> </button>
</div> </div>
@@ -711,6 +874,160 @@ export default function SettingsPage(props: SettingsPageProps) {
</div> </div>
</div> </div>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog
open={yubiKeyDialogOpen}
title={`${t('txt_two_step_login')} YubiKey`}
message={!yubiKeyYubicoConfigured ? '' : yubiKeyEnabled ? t('txt_yubikey_enabled') : t('txt_disabled')}
hideConfirm
hideCancel
closeButton
onConfirm={() => {
if (yubiKeySubmitting) return;
if (yubiKeyYubicoConfigured) {
void saveYubiKeyDialog();
} else {
void bootstrapYubiKeyConfigDialog();
}
}}
onCancel={closeYubiKeyDialog}
afterActions={(
<>
{yubiKeyYubicoConfigured && (
<button type="button" className="btn btn-primary dialog-btn" disabled={yubiKeySubmitting} onClick={() => void saveYubiKeyDialog()}>
{t('txt_save')}
</button>
)}
{yubiKeyEnabled && (
<button type="button" className="btn btn-secondary dialog-btn" disabled={yubiKeySubmitting} onClick={() => void disableYubiKeyDialog()}>
{t('txt_disable_all_keys')}
</button>
)}
</>
)}
>
<div className="yubikey-manage-dialog-body">
{!yubiKeyYubicoConfigured && (
<section className="settings-submodule yubikey-config-panel">
<h3>{t('txt_yubikey_config_required')}</h3>
<p className="muted-inline settings-field-note">{t('txt_yubikey_config_required_help')}</p>
<label className="field">
<span>{t('txt_otp_from_yubikey')}</span>
<input
className="input"
type="password"
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
inputMode="verbatim"
spellcheck={false}
value={yubiKeyBootstrapOtp}
onInput={(e) => setYubiKeyBootstrapOtp(normalizeYubiKeyFieldValue((e.currentTarget as HTMLInputElement).value))}
/>
</label>
<button type="button" className="btn btn-primary" disabled={yubiKeySubmitting || !yubiKeyBootstrapOtp.trim()} onClick={() => void bootstrapYubiKeyConfigDialog()}>
{t('txt_yubikey_auto_configure')}
</button>
</section>
)}
{yubiKeyYubicoConfigured && (
<>
<section className="settings-submodule yubikey-config-panel">
<div className="settings-module-head">
<h3>{t('txt_yubikey_validation_credentials')}</h3>
<button type="button" className="btn btn-secondary small" onClick={() => setYubiKeyConfigOpen((open) => !open)}>
{yubiKeyConfigOpen ? t('txt_hide') : t('txt_view')}
</button>
</div>
{yubiKeyConfigOpen && (
<div className="settings-vertical-fields">
<label className="field">
<span>Client ID</span>
<input className="input" value={yubiKeyYubicoClientId} onInput={(e) => setYubiKeyYubicoClientId((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="field">
<span>Secret key</span>
<input className="input" value={yubiKeyYubicoSecretKey} onInput={(e) => setYubiKeyYubicoSecretKey((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="field">
<span>{t('txt_otp_from_yubikey')}</span>
<input
className="input"
type="password"
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
inputMode="verbatim"
spellcheck={false}
value={yubiKeyBootstrapOtp}
onInput={(e) => setYubiKeyBootstrapOtp(normalizeYubiKeyFieldValue((e.currentTarget as HTMLInputElement).value))}
/>
<div className="field-help">{t('txt_yubikey_reconfigure_help')}</div>
</label>
<div className="actions">
<button type="button" className="btn btn-secondary" disabled={yubiKeySubmitting || !yubiKeyYubicoClientId.trim()} onClick={() => void saveYubiKeyConfigDialog()}>
{t('txt_save')}
</button>
<button type="button" className="btn btn-secondary" disabled={yubiKeySubmitting || !yubiKeyBootstrapOtp.trim()} onClick={() => void bootstrapYubiKeyConfigDialog()}>
{t('txt_yubikey_auto_configure_again')}
</button>
</div>
</div>
)}
</section>
<ol className="settings-plain-steps">
<li>{t('txt_yubikey_plug_in')}</li>
<li>{t('txt_yubikey_select_empty_field')}</li>
<li>{t('txt_yubikey_touch_button')}</li>
</ol>
<div className="settings-vertical-fields">
{yubiKeyKeys.map((keyValue, index) => (
<label className="field" key={index}>
<span>{t('txt_yubikey_x').replace('{index}', String(index + 1))}</span>
<div className="yubikey-input-row">
{yubiKeyStoredKeys[index] && keyValue === yubiKeyStoredKeys[index] ? (
<span className="yubikey-stored-key">{formatStoredYubiKey(keyValue)}</span>
) : (
<input
className="input"
type="password"
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
inputMode="verbatim"
spellcheck={false}
value={keyValue}
onInput={(e) => updateYubiKey(index, normalizeYubiKeyFieldValue((e.currentTarget as HTMLInputElement).value))}
/>
)}
{keyValue && (
<button
type="button"
className="btn btn-danger small yubikey-remove-btn"
title={t('txt_remove')}
aria-label={t('txt_remove')}
onClick={() => updateYubiKey(index, '')}
>
<Trash2 size={14} className="btn-icon" />
</button>
)}
</div>
</label>
))}
</div>
<div className="settings-checkbox-block">
<strong>{t('txt_nfc_support')}</strong>
<label className="checkbox-inline">
<input type="checkbox" checked={yubiKeyNfc} onInput={(e) => setYubiKeyNfc((e.currentTarget as HTMLInputElement).checked)} />
<span>{t('txt_yubikey_supports_nfc')}</span>
</label>
{t('txt_yubikey_supports_nfc_desc') && <div className="field-help">{t('txt_yubikey_supports_nfc_desc')}</div>}
</div>
</>
)}
</div>
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={recoveryCodeDialogOpen} open={recoveryCodeDialogOpen}
title={`${t('txt_two_step_login')} ${t('txt_recovery_code')}`} title={`${t('txt_two_step_login')} ${t('txt_recovery_code')}`}
+68 -5
View File
@@ -1,22 +1,27 @@
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { import {
changeMasterPassword, changeMasterPassword,
bootstrapYubiKeyOtpApiCredentials,
deleteAllAuthorizedDevices, deleteAllAuthorizedDevices,
deleteAuthorizedDevice, deleteAuthorizedDevice,
deleteAuthorizedDevices, deleteAuthorizedDevices,
deriveLoginHash, deriveLoginHash,
deleteAccountPasskey as deleteAccountPasskeyApi, deleteAccountPasskey as deleteAccountPasskeyApi,
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi, enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
disableYubiKeyOtp,
getCurrentDeviceIdentifier, getCurrentDeviceIdentifier,
getApiKey, getApiKey,
getAccountPasskeyAttestationOptions, getAccountPasskeyAttestationOptions,
getAccountPasskeyUpdateAssertionOptions, getAccountPasskeyUpdateAssertionOptions,
getTotpRecoveryCode, getTotpRecoveryCode,
getYubiKeyOtpSettings,
listAccountPasskeys, listAccountPasskeys,
rotateApiKey, rotateApiKey,
revokeAuthorizedDeviceTrust, revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust,
saveAccountPasskey, saveAccountPasskey,
saveYubiKeyOtpApiCredentials,
saveYubiKeyOtpSettings,
setTotp, setTotp,
trustAuthorizedDevicePermanently, trustAuthorizedDevicePermanently,
updateAuthorizedDeviceName, updateAuthorizedDeviceName,
@@ -32,7 +37,7 @@ import {
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays'; import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared'; import type { AuthedFetch } from '@/lib/api/shared';
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState } from '@/lib/types'; import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState, YubiKeyOtpSettings } from '@/lib/types';
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void; type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -47,7 +52,7 @@ interface UseAccountSecurityActionsOptions {
onNotify: Notify; onNotify: Notify;
onProfileUpdated: (profile: Profile) => void; onProfileUpdated: (profile: Profile) => void;
onSetConfirm: (next: AppConfirmState | null) => void; onSetConfirm: (next: AppConfirmState | null) => void;
refetchTotpStatus: () => Promise<unknown>; refetchTwoFactorStatus: () => Promise<unknown>;
refetchAuthorizedDevices: () => Promise<unknown>; refetchAuthorizedDevices: () => Promise<unknown>;
} }
@@ -63,7 +68,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onNotify, onNotify,
onProfileUpdated, onProfileUpdated,
onSetConfirm, onSetConfirm,
refetchTotpStatus, refetchTwoFactorStatus,
refetchAuthorizedDevices, refetchAuthorizedDevices,
} = options; } = options;
@@ -187,13 +192,71 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
clearDisableTotpDialog(); clearDisableTotpDialog();
await refetchTotpStatus(); await refetchTwoFactorStatus();
onNotify('success', t('txt_totp_disabled')); onNotify('success', t('txt_totp_disabled'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_disable_totp_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_disable_totp_failed'));
} }
}, },
async getYubiKeySettings(masterPassword: string): Promise<YubiKeyOtpSettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
return getYubiKeyOtpSettings(authedFetch, derived.hash);
},
async saveYubiKeySettings(keys: string[], nfc: boolean, masterPassword: string): Promise<YubiKeyOtpSettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const settings = await saveYubiKeyOtpSettings(authedFetch, { keys, nfc, masterPasswordHash: derived.hash });
await refetchTwoFactorStatus();
onNotify('success', t('txt_yubikeys_updated'));
return settings;
},
async saveYubiKeyApiCredentials(clientId: string, secretKey: string, masterPassword: string): Promise<YubiKeyOtpSettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const settings = await saveYubiKeyOtpApiCredentials(authedFetch, {
masterPasswordHash: derived.hash,
yubicoClientId: clientId,
yubicoSecretKey: secretKey,
});
await refetchTwoFactorStatus();
onNotify('success', t('txt_yubikey_config_updated'));
return settings;
},
async bootstrapYubiKeyApiCredentials(otp: string, masterPassword: string): Promise<YubiKeyOtpSettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const settings = await bootstrapYubiKeyOtpApiCredentials(authedFetch, {
masterPasswordHash: derived.hash,
otp,
});
await refetchTwoFactorStatus();
onNotify('success', t('txt_yubikey_config_updated'));
return settings;
},
async disableYubiKey(masterPassword: string): Promise<void> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
await disableYubiKeyOtp(authedFetch, derived.hash);
await refetchTwoFactorStatus();
onNotify('success', t('txt_yubikey_disabled'));
},
async getRecoveryCode(masterPassword: string): Promise<string> { async getRecoveryCode(masterPassword: string): Promise<string> {
if (!profile) throw new Error(t('txt_profile_unavailable')); if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || ''); const normalized = String(masterPassword || '');
@@ -476,7 +539,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
session?.symEncKey, session?.symEncKey,
session?.symMacKey, session?.symMacKey,
refetchAuthorizedDevices, refetchAuthorizedDevices,
refetchTotpStatus, refetchTwoFactorStatus,
] ]
); );
} }
+121 -6
View File
@@ -7,6 +7,7 @@ import type {
SessionState, SessionState,
TokenError, TokenError,
TokenSuccess, TokenSuccess,
YubiKeyOtpSettings,
} from '../types'; } from '../types';
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys'; import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status'; import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
@@ -240,6 +241,7 @@ export async function loginWithPassword(
passwordHash: string, passwordHash: string,
options?: { options?: {
totpCode?: string; totpCode?: string;
twoFactorProvider?: number;
rememberDevice?: boolean; rememberDevice?: boolean;
useRememberToken?: boolean; useRememberToken?: boolean;
signal?: AbortSignal; signal?: AbortSignal;
@@ -259,7 +261,7 @@ export async function loginWithPassword(
body.set('twoFactorProvider', '5'); body.set('twoFactorProvider', '5');
body.set('twoFactorToken', rememberedToken); body.set('twoFactorToken', rememberedToken);
} else if (options?.totpCode) { } else if (options?.totpCode) {
body.set('twoFactorProvider', '0'); body.set('twoFactorProvider', String(options.twoFactorProvider ?? 0));
body.set('twoFactorToken', options.totpCode); body.set('twoFactorToken', options.totpCode);
if (options.rememberDevice) { if (options.rememberDevice) {
body.set('twoFactorRemember', '1'); body.set('twoFactorRemember', '1');
@@ -650,6 +652,110 @@ export async function setTotp(
} }
} }
function normalizeYubiKeySettings(raw: any): YubiKeyOtpSettings {
return {
enabled: !!(raw?.enabled ?? raw?.Enabled),
keys: [
String(raw?.key1 ?? raw?.Key1 ?? ''),
String(raw?.key2 ?? raw?.Key2 ?? ''),
String(raw?.key3 ?? raw?.Key3 ?? ''),
String(raw?.key4 ?? raw?.Key4 ?? ''),
String(raw?.key5 ?? raw?.Key5 ?? ''),
],
nfc: !!(raw?.nfc ?? raw?.Nfc),
yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured),
yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''),
yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''),
};
}
export async function getYubiKeyOtpSettings(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<YubiKeyOtpSettings> {
const resp = await authedFetch('/api/two-factor/get-yubikey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
}
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
}
export async function saveYubiKeyOtpSettings(
authedFetch: AuthedFetch,
payload: { keys: string[]; nfc: boolean; masterPasswordHash: string }
): Promise<YubiKeyOtpSettings> {
const resp = await authedFetch('/api/two-factor/yubikey', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key1: payload.keys[0] || '',
key2: payload.keys[1] || '',
key3: payload.keys[2] || '',
key4: payload.keys[3] || '',
key5: payload.keys[4] || '',
nfc: payload.nfc,
masterPasswordHash: payload.masterPasswordHash,
}),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_update_failed')));
}
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
}
export async function saveYubiKeyOtpApiCredentials(
authedFetch: AuthedFetch,
payload: { masterPasswordHash: string; yubicoClientId: string; yubicoSecretKey: string }
): Promise<YubiKeyOtpSettings> {
const resp = await authedFetch('/api/two-factor/yubikey/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_config_update_failed')));
}
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
}
export async function bootstrapYubiKeyOtpApiCredentials(
authedFetch: AuthedFetch,
payload: { masterPasswordHash: string; otp: string }
): Promise<YubiKeyOtpSettings> {
const resp = await authedFetch('/api/two-factor/yubikey/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_auto_config_failed')));
}
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
}
export async function disableYubiKeyOtp(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<void> {
const resp = await authedFetch('/api/two-factor/disable', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 3, masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_yubikey_failed')));
}
}
export async function verifyMasterPassword( export async function verifyMasterPassword(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string masterPasswordHash: string
@@ -807,11 +913,20 @@ export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<nu
return stamp; return stamp;
} }
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> { export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: boolean }> {
const resp = await authedFetch('/api/accounts/totp'); const resp = await authedFetch('/api/two-factor');
if (!resp.ok) throw new Error('Failed to load TOTP status'); if (!resp.ok) throw new Error('Failed to load two-factor status');
const body = (await parseJson<{ enabled?: boolean }>(resp)) || {}; const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
return { enabled: !!body.enabled }; const providers = Array.isArray(body.data) ? body.data : Array.isArray(body.Data) ? body.Data : [];
const enabledTypes = new Set(
providers
.map((provider: any) => Number(provider?.type ?? provider?.Type))
.filter((type) => Number.isFinite(type))
);
return {
totpEnabled: enabledTypes.has(0),
yubikeyEnabled: enabledTypes.has(3),
};
} }
export async function getTotpRecoveryCode( export async function getTotpRecoveryCode(
+25
View File
@@ -34,6 +34,7 @@ export interface PendingTotp {
passwordHash: string; passwordHash: string;
masterKey: Uint8Array; masterKey: Uint8Array;
kdfIterations: number; kdfIterations: number;
providerType: number;
} }
export interface PendingPasskeyPassword { export interface PendingPasskeyPassword {
@@ -70,10 +71,31 @@ export interface CompletedLogin {
freshUserVerificationToken?: string | null; freshUserVerificationToken?: string | null;
} }
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
function readTokenUserVerificationToken(token: TokenSuccess): string | null { function readTokenUserVerificationToken(token: TokenSuccess): string | null {
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null; return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
} }
function resolvePendingTwoFactorProvider(providers: unknown): number {
if (Array.isArray(providers)) {
if (providers.some((provider: any) => Number(
provider && typeof provider === 'object' ? provider.Type ?? provider.type : provider
) === TWO_FACTOR_PROVIDER_YUBIKEY)) {
return TWO_FACTOR_PROVIDER_YUBIKEY;
}
return TWO_FACTOR_PROVIDER_AUTHENTICATOR;
}
if (providers && typeof providers === 'object') {
const record = providers as Record<string, unknown>;
if (record[String(TWO_FACTOR_PROVIDER_YUBIKEY)] || record.YubiKey || record.Yubikey) {
return TWO_FACTOR_PROVIDER_YUBIKEY;
}
}
return TWO_FACTOR_PROVIDER_AUTHENTICATOR;
}
export type PasswordLoginResult = export type PasswordLoginResult =
| { kind: 'success'; login: CompletedLogin } | { kind: 'success'; login: CompletedLogin }
| { kind: 'totp'; pendingTotp: PendingTotp } | { kind: 'totp'; pendingTotp: PendingTotp }
@@ -425,6 +447,7 @@ export async function performPasswordLogin(
passwordHash: derived.hash, passwordHash: derived.hash,
masterKey: derived.masterKey, masterKey: derived.masterKey,
kdfIterations: derived.kdfIterations, kdfIterations: derived.kdfIterations,
providerType: resolvePendingTwoFactorProvider(tokenError.TwoFactorProviders),
}, },
}; };
} }
@@ -498,6 +521,7 @@ export async function performTotpLogin(
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, { const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, {
totpCode: totpCode.trim(), totpCode: totpCode.trim(),
twoFactorProvider: pendingTotp.providerType,
rememberDevice, rememberDevice,
}); });
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
@@ -615,6 +639,7 @@ export async function performUnlock(
passwordHash: derived.hash, passwordHash: derived.hash,
masterKey: derived.masterKey, masterKey: derived.masterKey,
kdfIterations: derived.kdfIterations, kdfIterations: derived.kdfIterations,
providerType: resolvePendingTwoFactorProvider(tokenError.TwoFactorProviders),
}, },
}; };
} }
+31
View File
@@ -27,6 +27,35 @@ const en: Record<string, string> = {
"txt_passkey_provider_help": "Use a FIDO2-compatible security key or biometric authenticator.", "txt_passkey_provider_help": "Use a FIDO2-compatible security key or biometric authenticator.",
"txt_yubico_otp_security_key": "Yubico OTP security key", "txt_yubico_otp_security_key": "Yubico OTP security key",
"txt_yubico_otp_security_key_help": "Use a YubiKey 4, 5, or NEO device.", "txt_yubico_otp_security_key_help": "Use a YubiKey 4, 5, or NEO device.",
"txt_yubikey_setup_intro": "Insert your YubiKey into a USB port. Select the first empty YubiKey field below, touch the YubiKey button, then save the form.",
"txt_yubikey_plug_in": "Insert your YubiKey into a USB port.",
"txt_yubikey_select_empty_field": "Select the first empty YubiKey input field below.",
"txt_yubikey_touch_button": "Touch the YubiKey button.",
"txt_yubikey_save_form": "Save the form.",
"txt_yubikey_x": "YubiKey {index}",
"txt_nfc_support": "NFC support",
"txt_yubikey_supports_nfc": "One of my keys supports NFC.",
"txt_yubikey_supports_nfc_desc": "If one of your YubiKeys supports NFC, mobile apps can prompt you when NFC is available.",
"txt_disable_all_keys": "Disable all keys",
"txt_yubikeys_updated": "YubiKeys updated",
"txt_yubikey_update_failed": "Failed to update YubiKeys",
"txt_disable_yubikey_failed": "Failed to disable YubiKeys",
"txt_yubikey_disabled": "YubiKeys disabled",
"txt_yubikey_enabled": "YubiKey is enabled.",
"txt_yubikey_config_required": "Yubico validation is not configured",
"txt_yubikey_config_required_help": "Enter one YubiKey OTP first. NodeWarden will automatically request and save the instance Client ID and Secret key, then open the YubiKey setup form.",
"txt_otp_from_yubikey": "OTP from YubiKey",
"txt_please_input_yubikey_otp": "Please input YubiKey OTP",
"txt_yubikey_verify_failed": "YubiKey verification failed",
"txt_press_yubikey_to_authenticate": "Press your YubiKey to authenticate.",
"txt_yubikey_auto_configure": "Get and save automatically",
"txt_yubikey_validation_credentials": "Yubico validation credentials",
"txt_view": "View",
"txt_yubikey_config_updated": "Yubico validation credentials updated",
"txt_yubikey_config_update_failed": "Failed to update Yubico validation credentials",
"txt_yubikey_auto_config_failed": "Failed to get Yubico validation credentials",
"txt_yubikey_reconfigure_help": "Enter a fresh OTP to request and replace these credentials automatically.",
"txt_yubikey_auto_configure_again": "Get again automatically",
"txt_setting_coming_soon": "Coming soon.", "txt_setting_coming_soon": "Coming soon.",
"txt_totp_manage_intro": "Scan the QR code or enter the key in your authenticator app, then enter the verification code.", "txt_totp_manage_intro": "Scan the QR code or enter the key in your authenticator app, then enter the verification code.",
"txt_two_step_recovery_code_warning": "If you cannot access your two-step login provider, your one-time recovery code can be used to disable two-step login. Store the recovery code somewhere safe.", "txt_two_step_recovery_code_warning": "If you cannot access your two-step login provider, your one-time recovery code can be used to disable two-step login. Store the recovery code somewhere safe.",
@@ -860,6 +889,8 @@ const en: Record<string, string> = {
"txt_scope": "scope", "txt_scope": "scope",
"txt_grant_type": "grant_type", "txt_grant_type": "grant_type",
"txt_refresh": "Refresh", "txt_refresh": "Refresh",
"txt_refresh_status": "Refresh status",
"txt_load_failed": "Failed to load",
"txt_refresh_in_seconds_s": "Refresh in {seconds}s", "txt_refresh_in_seconds_s": "Refresh in {seconds}s",
"txt_regenerate": "Regenerate", "txt_regenerate": "Regenerate",
"txt_registration_succeeded_please_sign_in": "Registration succeeded. Please sign in.", "txt_registration_succeeded_please_sign_in": "Registration succeeded. Please sign in.",
+31
View File
@@ -27,6 +27,35 @@ const es: Record<string, string> = {
"txt_passkey_provider_help": "Usa una llave de seguridad compatible con FIDO2 o autenticación biométrica.", "txt_passkey_provider_help": "Usa una llave de seguridad compatible con FIDO2 o autenticación biométrica.",
"txt_yubico_otp_security_key": "Llave de seguridad Yubico OTP", "txt_yubico_otp_security_key": "Llave de seguridad Yubico OTP",
"txt_yubico_otp_security_key_help": "Usa un dispositivo YubiKey 4, 5 o NEO.", "txt_yubico_otp_security_key_help": "Usa un dispositivo YubiKey 4, 5 o NEO.",
"txt_yubikey_setup_intro": "Inserta tu YubiKey en un puerto USB. Selecciona el primer campo YubiKey vacío, toca el botón de la YubiKey y guarda el formulario.",
"txt_yubikey_plug_in": "Inserta tu YubiKey en un puerto USB.",
"txt_yubikey_select_empty_field": "Selecciona el primer campo YubiKey vacío.",
"txt_yubikey_touch_button": "Toca el botón de la YubiKey.",
"txt_yubikey_save_form": "Guarda el formulario.",
"txt_yubikey_x": "YubiKey {index}",
"txt_nfc_support": "Compatibilidad NFC",
"txt_yubikey_supports_nfc": "Una de mis llaves admite NFC.",
"txt_yubikey_supports_nfc_desc": "Si una de tus YubiKeys admite NFC, las apps móviles pueden avisarte cuando NFC esté disponible.",
"txt_disable_all_keys": "Desactivar todas las llaves",
"txt_yubikeys_updated": "YubiKeys actualizadas",
"txt_yubikey_update_failed": "No se pudieron actualizar las YubiKeys",
"txt_disable_yubikey_failed": "No se pudieron desactivar las YubiKeys",
"txt_yubikey_disabled": "YubiKeys desactivadas",
"txt_yubikey_enabled": "YubiKey activada.",
"txt_yubikey_config_required": "La validación de Yubico no está configurada",
"txt_yubikey_config_required_help": "Introduce primero un OTP de YubiKey. NodeWarden solicitará y guardará automáticamente el Client ID y la Secret key de la instancia, y luego abrirá el formulario de YubiKey.",
"txt_otp_from_yubikey": "OTP de YubiKey",
"txt_please_input_yubikey_otp": "Introduce el OTP de YubiKey",
"txt_yubikey_verify_failed": "No se pudo verificar la YubiKey",
"txt_press_yubikey_to_authenticate": "Pulsa tu YubiKey para autenticarte.",
"txt_yubikey_auto_configure": "Obtener y guardar automáticamente",
"txt_yubikey_validation_credentials": "Credenciales de validación de Yubico",
"txt_view": "Ver",
"txt_yubikey_config_updated": "Credenciales de validación de Yubico actualizadas",
"txt_yubikey_config_update_failed": "No se pudieron actualizar las credenciales de validación de Yubico",
"txt_yubikey_auto_config_failed": "No se pudieron obtener las credenciales de validación de Yubico",
"txt_yubikey_reconfigure_help": "Introduce un OTP nuevo para solicitar y reemplazar estas credenciales automáticamente.",
"txt_yubikey_auto_configure_again": "Obtener de nuevo automáticamente",
"txt_setting_coming_soon": "Próximamente.", "txt_setting_coming_soon": "Próximamente.",
"txt_totp_manage_intro": "Escanea el código QR o introduce la clave en tu aplicación autenticadora, luego escribe el código de verificación.", "txt_totp_manage_intro": "Escanea el código QR o introduce la clave en tu aplicación autenticadora, luego escribe el código de verificación.",
"txt_two_step_recovery_code_warning": "Si no puedes acceder a tu proveedor de inicio de sesión en dos pasos, tu código de recuperación de un solo uso puede desactivar el inicio de sesión en dos pasos. Guarda el código en un lugar seguro.", "txt_two_step_recovery_code_warning": "Si no puedes acceder a tu proveedor de inicio de sesión en dos pasos, tu código de recuperación de un solo uso puede desactivar el inicio de sesión en dos pasos. Guarda el código en un lugar seguro.",
@@ -860,6 +889,8 @@ const es: Record<string, string> = {
"txt_scope": "Ámbito", "txt_scope": "Ámbito",
"txt_grant_type": "Tipo de concesión", "txt_grant_type": "Tipo de concesión",
"txt_refresh": "Actualizar", "txt_refresh": "Actualizar",
"txt_refresh_status": "Actualizar estado",
"txt_load_failed": "No se pudo cargar",
"txt_refresh_in_seconds_s": "Actualizar en {seconds}s", "txt_refresh_in_seconds_s": "Actualizar en {seconds}s",
"txt_regenerate": "Regenerar", "txt_regenerate": "Regenerar",
"txt_registration_succeeded_please_sign_in": "Registro completado. Inicie sesión.", "txt_registration_succeeded_please_sign_in": "Registro completado. Inicie sesión.",
+31
View File
@@ -28,6 +28,35 @@ const ru: Record<string, string> = {
"txt_passkey_provider_help": "Используйте FIDO2-совместимый ключ безопасности или биометрический аутентификатор.", "txt_passkey_provider_help": "Используйте FIDO2-совместимый ключ безопасности или биометрический аутентификатор.",
"txt_yubico_otp_security_key": "Ключ безопасности Yubico OTP", "txt_yubico_otp_security_key": "Ключ безопасности Yubico OTP",
"txt_yubico_otp_security_key_help": "Используйте устройство YubiKey 4, 5 или NEO.", "txt_yubico_otp_security_key_help": "Используйте устройство YubiKey 4, 5 или NEO.",
"txt_yubikey_setup_intro": "Вставьте YubiKey в USB-порт. Выберите первое пустое поле YubiKey ниже, коснитесь кнопки YubiKey и сохраните форму.",
"txt_yubikey_plug_in": "Вставьте YubiKey в USB-порт.",
"txt_yubikey_select_empty_field": "Выберите первое пустое поле YubiKey ниже.",
"txt_yubikey_touch_button": "Коснитесь кнопки YubiKey.",
"txt_yubikey_save_form": "Сохраните форму.",
"txt_yubikey_x": "YubiKey {index}",
"txt_nfc_support": "Поддержка NFC",
"txt_yubikey_supports_nfc": "Один из моих ключей поддерживает NFC.",
"txt_yubikey_supports_nfc_desc": "Если один из ваших YubiKey поддерживает NFC, мобильные приложения смогут подсказать вам, когда NFC доступен.",
"txt_disable_all_keys": "Отключить все ключи",
"txt_yubikeys_updated": "YubiKey обновлены",
"txt_yubikey_update_failed": "Не удалось обновить YubiKey",
"txt_disable_yubikey_failed": "Не удалось отключить YubiKey",
"txt_yubikey_disabled": "YubiKey отключены",
"txt_yubikey_enabled": "YubiKey включен.",
"txt_yubikey_config_required": "Проверка Yubico не настроена",
"txt_yubikey_config_required_help": "Сначала введите один OTP с YubiKey. NodeWarden автоматически запросит и сохранит Client ID и Secret key экземпляра, затем откроет форму настройки YubiKey.",
"txt_otp_from_yubikey": "OTP с YubiKey",
"txt_please_input_yubikey_otp": "Введите OTP с YubiKey",
"txt_yubikey_verify_failed": "Не удалось проверить YubiKey",
"txt_press_yubikey_to_authenticate": "Нажмите YubiKey для проверки.",
"txt_yubikey_auto_configure": "Получить и сохранить автоматически",
"txt_yubikey_validation_credentials": "Учетные данные проверки Yubico",
"txt_view": "Показать",
"txt_yubikey_config_updated": "Учетные данные проверки Yubico обновлены",
"txt_yubikey_config_update_failed": "Не удалось обновить учетные данные проверки Yubico",
"txt_yubikey_auto_config_failed": "Не удалось получить учетные данные проверки Yubico",
"txt_yubikey_reconfigure_help": "Введите новый OTP, чтобы автоматически запросить и заменить эти учетные данные.",
"txt_yubikey_auto_configure_again": "Получить снова автоматически",
"txt_setting_coming_soon": "Скоро появится.", "txt_setting_coming_soon": "Скоро появится.",
"txt_totp_manage_intro": "Отсканируйте QR-код или введите ключ в приложении-аутентификаторе, затем введите код проверки.", "txt_totp_manage_intro": "Отсканируйте QR-код или введите ключ в приложении-аутентификаторе, затем введите код проверки.",
"txt_two_step_recovery_code_warning": "Если вы не можете получить доступ к поставщику двухэтапного входа, одноразовый код восстановления можно использовать для отключения двухэтапного входа. Сохраните код в надежном месте.", "txt_two_step_recovery_code_warning": "Если вы не можете получить доступ к поставщику двухэтапного входа, одноразовый код восстановления можно использовать для отключения двухэтапного входа. Сохраните код в надежном месте.",
@@ -860,6 +889,8 @@ const ru: Record<string, string> = {
"txt_scope": "Область доступа", "txt_scope": "Область доступа",
"txt_grant_type": "Тип авторизации", "txt_grant_type": "Тип авторизации",
"txt_refresh": "Обновить", "txt_refresh": "Обновить",
"txt_refresh_status": "Обновить статус",
"txt_load_failed": "Не удалось загрузить",
"txt_refresh_in_seconds_s": "Обновить через {seconds} с.", "txt_refresh_in_seconds_s": "Обновить через {seconds} с.",
"txt_regenerate": "Регенерировать", "txt_regenerate": "Регенерировать",
"txt_registration_succeeded_please_sign_in": "Регистрация прошла успешно. Пожалуйста, войдите в систему.", "txt_registration_succeeded_please_sign_in": "Регистрация прошла успешно. Пожалуйста, войдите в систему.",
+31
View File
@@ -27,6 +27,35 @@ const zhCN: Record<string, string> = {
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密钥或生物识别验证器。", "txt_passkey_provider_help": "使用兼容 FIDO2 的安全密钥或生物识别验证器。",
"txt_yubico_otp_security_key": "Yubico OTP 安全密钥", "txt_yubico_otp_security_key": "Yubico OTP 安全密钥",
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 设备。", "txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 设备。",
"txt_yubikey_setup_intro": "将 YubiKey 插入计算机的 USB 端口。在下面选择第一个空的 YubiKey 输入字段。触摸 YubiKey 的按钮、保存。",
"txt_yubikey_plug_in": "将 YubiKey 插入计算机的 USB 端口",
"txt_yubikey_select_empty_field": "在下面选择第一个空的 YubiKey 输入字段",
"txt_yubikey_touch_button": "触摸 YubiKey 的按钮、保存",
"txt_yubikey_save_form": "保存",
"txt_yubikey_x": "YubiKey {index}",
"txt_nfc_support": "NFC 支持",
"txt_yubikey_supports_nfc": "我的某个密钥支持 NFC",
"txt_yubikey_supports_nfc_desc": "",
"txt_disable_all_keys": "停用全部密钥",
"txt_yubikeys_updated": "YubiKey 已更新",
"txt_yubikey_update_failed": "更新 YubiKey 失败",
"txt_disable_yubikey_failed": "停用 YubiKey 失败",
"txt_yubikey_disabled": "YubiKey 已停用",
"txt_yubikey_enabled": "YubiKey 已启用。",
"txt_yubikey_config_required": "尚未配置 Yubico 验证",
"txt_yubikey_config_required_help": "请先输入一次 YubiKey OTP。NodeWarden 会自动获取并保存实例级 Client ID 和 Secret key,成功后再进入 YubiKey 设置表单。",
"txt_otp_from_yubikey": "来自 YubiKey 的 OTP",
"txt_please_input_yubikey_otp": "请输入 YubiKey OTP",
"txt_yubikey_verify_failed": "YubiKey 验证失败",
"txt_press_yubikey_to_authenticate": "按下 YubiKey 进行验证。",
"txt_yubikey_auto_configure": "自动获取并保存",
"txt_yubikey_validation_credentials": "Yubico 验证凭据",
"txt_view": "查看",
"txt_yubikey_config_updated": "Yubico 验证凭据已更新",
"txt_yubikey_config_update_failed": "更新 Yubico 验证凭据失败",
"txt_yubikey_auto_config_failed": "获取 Yubico 验证凭据失败",
"txt_yubikey_reconfigure_help": "输入一个新的 OTP,可以重新自动获取并替换当前凭据。",
"txt_yubikey_auto_configure_again": "重新自动获取",
"txt_setting_coming_soon": "即将推出。", "txt_setting_coming_soon": "即将推出。",
"txt_totp_manage_intro": "扫描二维码或在验证器 App 中输入密钥,然后输入验证码。", "txt_totp_manage_intro": "扫描二维码或在验证器 App 中输入密钥,然后输入验证码。",
"txt_two_step_recovery_code_warning": "当您无法访问两步登录提供程序时,您的一次性恢复代码可用于停用两步登录。请将其妥善保管。", "txt_two_step_recovery_code_warning": "当您无法访问两步登录提供程序时,您的一次性恢复代码可用于停用两步登录。请将其妥善保管。",
@@ -860,6 +889,8 @@ const zhCN: Record<string, string> = {
"txt_scope": "权限范围", "txt_scope": "权限范围",
"txt_grant_type": "授权类型", "txt_grant_type": "授权类型",
"txt_refresh": "刷新", "txt_refresh": "刷新",
"txt_refresh_status": "刷新状态",
"txt_load_failed": "加载失败",
"txt_refresh_in_seconds_s": "{seconds} 秒后刷新", "txt_refresh_in_seconds_s": "{seconds} 秒后刷新",
"txt_regenerate": "重新生成", "txt_regenerate": "重新生成",
"txt_registration_succeeded_please_sign_in": "注册成功,请登录", "txt_registration_succeeded_please_sign_in": "注册成功,请登录",
+31
View File
@@ -27,6 +27,35 @@ const zhTW: Record<string, string> = {
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密鑰或生物識別驗證器。", "txt_passkey_provider_help": "使用兼容 FIDO2 的安全密鑰或生物識別驗證器。",
"txt_yubico_otp_security_key": "Yubico OTP 安全密鑰", "txt_yubico_otp_security_key": "Yubico OTP 安全密鑰",
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 裝置。", "txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 裝置。",
"txt_yubikey_setup_intro": "將 YubiKey 插入電腦的 USB 連接埠。在下方選擇第一個空的 YubiKey 輸入欄位,觸摸 YubiKey 按鈕,然後保存表單。",
"txt_yubikey_plug_in": "將 YubiKey 插入電腦的 USB 連接埠。",
"txt_yubikey_select_empty_field": "在下方選擇第一個空的 YubiKey 輸入欄位。",
"txt_yubikey_touch_button": "觸摸 YubiKey 按鈕。",
"txt_yubikey_save_form": "保存表單。",
"txt_yubikey_x": "YubiKey {index}",
"txt_nfc_support": "NFC 支援",
"txt_yubikey_supports_nfc": "我的某個密鑰支援 NFC。",
"txt_yubikey_supports_nfc_desc": "如果您的某個 YubiKey 支援 NFC,行動裝置偵測到 NFC 可用時會提示您。",
"txt_disable_all_keys": "停用全部密鑰",
"txt_yubikeys_updated": "YubiKey 已更新",
"txt_yubikey_update_failed": "更新 YubiKey 失敗",
"txt_disable_yubikey_failed": "停用 YubiKey 失敗",
"txt_yubikey_disabled": "YubiKey 已停用",
"txt_yubikey_enabled": "YubiKey 已啟用。",
"txt_yubikey_config_required": "尚未配置 Yubico 驗證",
"txt_yubikey_config_required_help": "請先輸入一次 YubiKey OTP。NodeWarden 會自動取得並保存實例級 Client ID 和 Secret key,成功後再進入 YubiKey 設定表單。",
"txt_otp_from_yubikey": "來自 YubiKey 的 OTP",
"txt_please_input_yubikey_otp": "請輸入 YubiKey OTP",
"txt_yubikey_verify_failed": "YubiKey 驗證失敗",
"txt_press_yubikey_to_authenticate": "按下 YubiKey 進行驗證。",
"txt_yubikey_auto_configure": "自動取得並保存",
"txt_yubikey_validation_credentials": "Yubico 驗證憑據",
"txt_view": "查看",
"txt_yubikey_config_updated": "Yubico 驗證憑據已更新",
"txt_yubikey_config_update_failed": "更新 Yubico 驗證憑據失敗",
"txt_yubikey_auto_config_failed": "取得 Yubico 驗證憑據失敗",
"txt_yubikey_reconfigure_help": "輸入一個新的 OTP,可以重新自動取得並替換目前憑據。",
"txt_yubikey_auto_configure_again": "重新自動取得",
"txt_setting_coming_soon": "即將推出。", "txt_setting_coming_soon": "即將推出。",
"txt_totp_manage_intro": "掃描二維碼或在驗證器 App 中輸入密鑰,然後輸入驗證碼。", "txt_totp_manage_intro": "掃描二維碼或在驗證器 App 中輸入密鑰,然後輸入驗證碼。",
"txt_two_step_recovery_code_warning": "當您無法訪問兩步登入提供程序時,您的一次性恢復代碼可用於停用兩步登入。請將其妥善保管。", "txt_two_step_recovery_code_warning": "當您無法訪問兩步登入提供程序時,您的一次性恢復代碼可用於停用兩步登入。請將其妥善保管。",
@@ -860,6 +889,8 @@ const zhTW: Record<string, string> = {
"txt_scope": "權限範圍", "txt_scope": "權限範圍",
"txt_grant_type": "授權類型", "txt_grant_type": "授權類型",
"txt_refresh": "刷新", "txt_refresh": "刷新",
"txt_refresh_status": "刷新狀態",
"txt_load_failed": "載入失敗",
"txt_refresh_in_seconds_s": "{seconds} 秒後刷新", "txt_refresh_in_seconds_s": "{seconds} 秒後刷新",
"txt_regenerate": "重新生成", "txt_regenerate": "重新生成",
"txt_registration_succeeded_please_sign_in": "註冊成功,請登錄", "txt_registration_succeeded_please_sign_in": "註冊成功,請登錄",
+10
View File
@@ -15,6 +15,7 @@ export interface Profile {
name: string; name: string;
key: string; key: string;
masterPasswordHint?: string | null; masterPasswordHint?: string | null;
yubikeyEnabled?: boolean;
privateKey?: string | null; privateKey?: string | null;
publicKey?: string | null; publicKey?: string | null;
role: 'admin' | 'user'; role: 'admin' | 'user';
@@ -295,6 +296,15 @@ export interface WebBootstrapResponse {
registrationInviteRequired?: boolean; registrationInviteRequired?: boolean;
} }
export interface YubiKeyOtpSettings {
enabled: boolean;
keys: [string, string, string, string, string];
nfc: boolean;
yubicoConfigured: boolean;
yubicoClientId: string;
yubicoSecretKey: string;
}
export interface TokenSuccess { export interface TokenSuccess {
access_token: string; access_token: string;
refresh_token?: string; refresh_token?: string;
+36
View File
@@ -745,6 +745,42 @@
font-size: 13px; font-size: 13px;
} }
.yubikey-manage-dialog-body {
@apply mt-3 space-y-4;
}
.settings-plain-steps {
@apply m-0 space-y-1 border-b pb-3 pl-5;
border-color: var(--line);
color: var(--text);
}
.yubikey-input-row {
@apply grid items-center gap-2;
grid-template-columns: minmax(0, 1fr) auto;
}
.yubikey-stored-key {
@apply block min-h-9 rounded-md border border-transparent px-0 py-2 text-sm text-slate-800 dark:text-slate-100;
overflow-wrap: anywhere;
}
.yubikey-remove-btn {
@apply h-9 w-9 p-0;
}
.settings-checkbox-block {
@apply space-y-2;
}
.checkbox-inline {
@apply flex items-center gap-2 text-sm;
}
.checkbox-inline input {
@apply h-4 w-4;
}
.dialog-close-btn { .dialog-close-btn {
@apply absolute right-3 top-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-0 p-0; @apply absolute right-3 top-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-0 p-0;
background: transparent; background: transparent;