feat(yubico): refactor Yubico credential management and enhance settings UI

This commit is contained in:
shuaiplus
2026-07-13 13:05:04 +08:00
parent b731a014f1
commit 573451c52f
10 changed files with 205 additions and 86 deletions
+68 -42
View File
@@ -11,15 +11,18 @@ import { findMatchingTotpCounter, isTotpEnabled } 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'; import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubiKeyPublicIdFromOtp } from '../utils/yubico-otp';
import {
getYubicoCredentials,
initializeYubicoCredentialsOnce,
replaceYubicoCredentials,
} from '../services/yubico-config';
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3; const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7; const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
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
@@ -201,31 +204,6 @@ 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')) {
@@ -815,12 +793,19 @@ function deviceVerificationSettingsResponse(_user: User): Record<string, unknown
} }
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> { async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
const credentials = await getStoredYubicoCredentials(storage, env); void storage;
const credentials = await getYubicoCredentials(env.DB);
const canManageCredentials = user.role === 'admin' && user.status === 'active';
return { return {
...yubiKeyResponse(user), ...yubiKeyResponse(user),
YubicoConfigured: !!credentials?.clientId, YubicoConfigured: !!credentials?.clientId,
YubicoClientId: credentials?.clientId ?? '', YubicoCanManage: canManageCredentials,
YubicoSecretKey: credentials?.secretKey ?? '', ...(canManageCredentials
? {
YubicoClientId: credentials?.clientId ?? '',
YubicoSecretKey: credentials?.secretKey ?? '',
}
: {}),
}; };
} }
@@ -1013,7 +998,7 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user
readBodyString(body, ['key5', 'Key5']), readBodyString(body, ['key5', 'Key5']),
]; ];
const publicIds: Array<string | null> = []; const publicIds: Array<string | null> = [];
let credentials = await getStoredYubicoCredentials(storage, env); let credentials = await getYubicoCredentials(env.DB);
let apiKeyBootstrapOtpIndex: number | null = null; let apiKeyBootstrapOtpIndex: number | null = null;
for (const key of keys) { for (const key of keys) {
const trimmed = key.trim(); const trimmed = key.trim();
@@ -1028,9 +1013,10 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user
continue; continue;
} }
if (!credentials) { if (!credentials) {
credentials = await ensureStoredYubicoCredentials(storage, env, user.email, trimmed); const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, trimmed);
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400); if (!initialized) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
apiKeyBootstrapOtpIndex = publicIds.length; credentials = initialized.credentials;
if (initialized.created) apiKeyBootstrapOtpIndex = publicIds.length;
} }
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) { if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
return errorResponse('Invalid YubiKey OTP.', 400); return errorResponse('Invalid YubiKey OTP.', 400);
@@ -1071,6 +1057,7 @@ export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env
const auth = new AuthService(env); const auth = new AuthService(env);
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);
if (user.role !== 'admin' || user.status !== 'active') return errorResponse('Forbidden', 403);
let body: Record<string, unknown>; let body: Record<string, unknown>;
try { try {
@@ -1085,10 +1072,18 @@ export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env
const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim(); const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim();
const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim(); const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim();
if (!clientId) return errorResponse('Yubico Client ID is required.', 400); if (!clientId || !secretKey) return errorResponse('Yubico Client ID and Secret Key are required.', 400);
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, clientId); await replaceYubicoCredentials(env.DB, { clientId, secretKey });
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, secretKey); await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'system.yubico.credentials.update',
category: 'security',
level: 'security',
targetType: 'system',
targetId: 'yubico',
metadata: auditRequestMetadata(request),
});
return jsonResponse(await yubiKeySettingsResponse(storage, env, user)); return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
} }
@@ -1113,11 +1108,42 @@ export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, en
const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim(); const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim();
if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400); if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400);
const credentials = await requestYubicoApiCredentials(user.email, otp); const existing = await getYubicoCredentials(env.DB);
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400); if (user.role !== 'admin' && existing) {
return errorResponse('Yubico validation credentials are already configured.', 403);
}
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId); let credentials;
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey); if (user.role === 'admin') {
credentials = await requestYubicoApiCredentials(user.email, otp);
if (!credentials?.clientId || !credentials.secretKey) {
return errorResponse('Unable to initialize Yubico validation credentials.', 400);
}
await replaceYubicoCredentials(env.DB, credentials);
} else {
const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, otp);
if (!initialized?.created) {
return errorResponse(
initialized?.credentials
? 'Yubico validation credentials are already configured.'
: 'Unable to initialize Yubico validation credentials.',
initialized?.credentials ? 403 : 400
);
}
credentials = initialized.credentials;
}
await writeAuditEvent(storage, {
actorUserId: user.id,
action: user.role === 'admin'
? 'system.yubico.credentials.reconfigure'
: 'system.yubico.credentials.initialize',
category: 'security',
level: 'security',
targetType: 'system',
targetId: 'yubico',
metadata: auditRequestMetadata(request),
});
return jsonResponse(await yubiKeySettingsResponse(storage, env, user)); return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
} }
+13 -14
View File
@@ -25,7 +25,8 @@ 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'; import { isYubiKeyEnabled, userYubiKeyPublicIds, verifyYubicoOtp, yubiKeyPublicIdFromOtp } from '../utils/yubico-otp';
import { getYubicoCredentials, initializeYubicoCredentialsOnce } from '../services/yubico-config';
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;
@@ -34,8 +35,6 @@ const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7; const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
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.
@@ -163,15 +162,6 @@ async function loginRateLimitKey(clientIdentifier: string, grantType: string, su
return `${clientIdentifier}:login:${grantType}:${subjectHash}`; return `${clientIdentifier}:login:${grantType}:${subjectHash}`;
} }
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 = [
@@ -507,8 +497,17 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) { if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
const credentials = await getStoredYubicoCredentials(storage, env); let credentials = await getYubicoCredentials(env.DB);
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) { let initializedWithCurrentOtp = false;
if (!credentials) {
const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, normalizedTwoFactorToken);
if (!initialized) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
credentials = initialized.credentials;
initializedWithCurrentOtp = initialized.created;
}
if (!initializedWithCurrentOtp && !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) { } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) {
+2 -1
View File
@@ -2,6 +2,7 @@ import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
import type { Env } from '../types'; import type { Env } from '../types';
import { APP_VERSION } from '../../shared/app-version'; import { APP_VERSION } from '../../shared/app-version';
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config'; import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto'; import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
import { import {
getAttachmentObjectKey, getAttachmentObjectKey,
@@ -111,7 +112,7 @@ function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
const sanitized: SqlRow[] = []; const sanitized: SqlRow[] = [];
for (const row of rows) { for (const row of rows) {
const key = String(row.key || '').trim(); const key = String(row.key || '').trim();
if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY) continue; if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY || key === YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY) continue;
if (key === BACKUP_SETTINGS_CONFIG_KEY) { if (key === BACKUP_SETTINGS_CONFIG_KEY) {
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null); const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Env, User } from '../types'; import type { Env, User } from '../types';
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store'; import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config'; import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
import { import {
type BackupManifestAttachmentBlob, type BackupManifestAttachmentBlob,
type BackupPayload, type BackupPayload,
@@ -276,7 +277,9 @@ async function prepareImportedConfigRows(
configRows: SqlRow[], configRows: SqlRow[],
userRows: SqlRow[] userRows: SqlRow[]
): Promise<SqlRow[]> { ): Promise<SqlRow[]> {
let nextConfigRows = cloneRows(configRows || []); let nextConfigRows = cloneRows(configRows || []).filter(
(row) => String(row.key || '').trim() !== YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY
);
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY); const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue( const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null, typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
+97
View File
@@ -0,0 +1,97 @@
import {
requestYubicoApiCredentials,
type YubicoApiCredentials,
} from '../utils/yubico-otp';
export const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
export const YUBICO_SECRET_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
export const YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY = 'yubico.bootstrap.claim.v1';
const YUBICO_BOOTSTRAP_CLAIM_TTL_MS = 2 * 60 * 1000;
export interface YubicoCredentialInitializationResult {
credentials: YubicoApiCredentials;
created: boolean;
}
export async function getYubicoCredentials(db: D1Database): Promise<YubicoApiCredentials | null> {
const result = await db
.prepare('SELECT key, value FROM config WHERE key IN (?, ?)')
.bind(YUBICO_CLIENT_ID_CONFIG_KEY, YUBICO_SECRET_KEY_CONFIG_KEY)
.all<{ key: string; value: string }>();
const values = new Map((result.results || []).map((row) => [row.key, String(row.value || '').trim()]));
const clientId = values.get(YUBICO_CLIENT_ID_CONFIG_KEY) || '';
const secretKey = values.get(YUBICO_SECRET_KEY_CONFIG_KEY) || '';
return clientId && secretKey ? { clientId, secretKey } : null;
}
export async function replaceYubicoCredentials(
db: D1Database,
credentials: YubicoApiCredentials
): Promise<void> {
const clientId = String(credentials.clientId || '').trim();
const secretKey = String(credentials.secretKey || '').trim();
if (!clientId || !secretKey) throw new Error('Yubico credentials are incomplete');
await db.batch([
db.prepare(
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).bind(YUBICO_CLIENT_ID_CONFIG_KEY, clientId),
db.prepare(
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).bind(YUBICO_SECRET_KEY_CONFIG_KEY, secretKey),
]);
}
async function acquireBootstrapClaim(db: D1Database): Promise<string | null> {
const now = Date.now();
await db
.prepare('DELETE FROM config WHERE key = ? AND CAST(value AS INTEGER) < ?')
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, now)
.run();
const claim = `${now + YUBICO_BOOTSTRAP_CLAIM_TTL_MS}:${crypto.randomUUID()}`;
const result = await db
.prepare('INSERT OR IGNORE INTO config(key, value) VALUES(?, ?)')
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, claim)
.run();
return (result.meta.changes ?? 0) > 0 ? claim : null;
}
async function releaseBootstrapClaim(db: D1Database, claim: string): Promise<void> {
await db
.prepare('DELETE FROM config WHERE key = ? AND value = ?')
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, claim)
.run();
}
export async function initializeYubicoCredentialsOnce(
db: D1Database,
email: string,
otp: string
): Promise<YubicoCredentialInitializationResult | null> {
const existing = await getYubicoCredentials(db);
if (existing) return { credentials: existing, created: false };
const claim = await acquireBootstrapClaim(db);
if (!claim) {
const concurrentlyCreated = await getYubicoCredentials(db);
return concurrentlyCreated ? { credentials: concurrentlyCreated, created: false } : null;
}
try {
const rechecked = await getYubicoCredentials(db);
if (rechecked) return { credentials: rechecked, created: false };
const issued = await requestYubicoApiCredentials(email, otp);
if (!issued?.clientId || !issued.secretKey) return null;
const configuredDuringRequest = await getYubicoCredentials(db);
if (configuredDuringRequest) {
return { credentials: configuredDuringRequest, created: false };
}
await replaceYubicoCredentials(db, issued);
return { credentials: issued, created: true };
} finally {
await releaseBootstrapClaim(db, claim).catch(() => undefined);
}
}
-4
View File
@@ -14,11 +14,7 @@ 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; YUBICO_VALIDATION_URLS?: string;
'globalSettings__yubico__clientId'?: string;
'globalSettings__yubico__key'?: string;
'globalSettings__yubico__validationUrls'?: string; 'globalSettings__yubico__validationUrls'?: string;
} }
+12 -22
View File
@@ -48,12 +48,6 @@ export function isYubiKeyEnabled(user: User): boolean {
return userYubiKeyPublicIds(user).length > 0; 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 { function randomNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16)); const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''); return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
@@ -143,27 +137,25 @@ export async function requestYubicoApiCredentials(email: string, otpInput: strin
export async function verifyYubicoOtp( export async function verifyYubicoOtp(
env: Env, env: Env,
otpInput: string, otpInput: string,
credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env) credentials: YubicoApiCredentials | null
): Promise<boolean> { ): Promise<boolean> {
const otp = normalizeYubiKeyOtp(otpInput); const otp = normalizeYubiKeyOtp(otpInput);
if (!isYubiKeyOtp(otp)) return false; if (!isYubiKeyOtp(otp)) return false;
const clientId = String(credentials?.clientId || '').trim(); const clientId = String(credentials?.clientId || '').trim();
if (!clientId) return false; const secretKey = String(credentials?.secretKey || '').trim();
if (!clientId || !secretKey) return false;
const nonce = randomNonce(); const nonce = randomNonce();
const secretKey = String(credentials?.secretKey || '').trim();
const params = new URLSearchParams({ const params = new URLSearchParams({
id: clientId, id: clientId,
nonce, nonce,
otp, otp,
}); });
if (secretKey) { try {
try { params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params))); } catch {
} catch { return false;
return false;
}
} }
for (const baseUrl of validationUrls(env)) { for (const baseUrl of validationUrls(env)) {
@@ -172,14 +164,12 @@ export async function verifyYubicoOtp(
if (!response.ok) continue; if (!response.ok) continue;
const parsed = parseYubicoResponse(await response.text()); const parsed = parseYubicoResponse(await response.text());
if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue; if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue;
if (secretKey) { if (!parsed.h) continue;
if (!parsed.h) continue; const signedParams = new URLSearchParams();
const signedParams = new URLSearchParams(); for (const [key, value] of Object.entries(parsed)) {
for (const [key, value] of Object.entries(parsed)) { if (key !== 'h') signedParams.set(key, value);
if (key !== 'h') signedParams.set(key, value);
}
if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue;
} }
if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue;
return true; return true;
} catch { } catch {
continue; continue;
+7 -2
View File
@@ -144,6 +144,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [yubiKeyStoredKeys, setYubiKeyStoredKeys] = 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 [yubiKeyNfc, setYubiKeyNfc] = useState(false);
const [yubiKeyYubicoConfigured, setYubiKeyYubicoConfigured] = useState(false); const [yubiKeyYubicoConfigured, setYubiKeyYubicoConfigured] = useState(false);
const [yubiKeyYubicoCanManage, setYubiKeyYubicoCanManage] = useState(false);
const [yubiKeyYubicoClientId, setYubiKeyYubicoClientId] = useState(''); const [yubiKeyYubicoClientId, setYubiKeyYubicoClientId] = useState('');
const [yubiKeyYubicoSecretKey, setYubiKeyYubicoSecretKey] = useState(''); const [yubiKeyYubicoSecretKey, setYubiKeyYubicoSecretKey] = useState('');
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState(''); const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
@@ -340,6 +341,7 @@ export default function SettingsPage(props: SettingsPageProps) {
setYubiKeyStoredKeys(settings.keys); setYubiKeyStoredKeys(settings.keys);
setYubiKeyNfc(settings.nfc); setYubiKeyNfc(settings.nfc);
setYubiKeyYubicoConfigured(settings.yubicoConfigured); setYubiKeyYubicoConfigured(settings.yubicoConfigured);
setYubiKeyYubicoCanManage(settings.yubicoCanManage);
setYubiKeyYubicoClientId(settings.yubicoClientId); setYubiKeyYubicoClientId(settings.yubicoClientId);
setYubiKeyYubicoSecretKey(settings.yubicoSecretKey); setYubiKeyYubicoSecretKey(settings.yubicoSecretKey);
} }
@@ -352,6 +354,7 @@ export default function SettingsPage(props: SettingsPageProps) {
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS); setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
setYubiKeyNfc(false); setYubiKeyNfc(false);
setYubiKeyYubicoConfigured(false); setYubiKeyYubicoConfigured(false);
setYubiKeyYubicoCanManage(false);
setYubiKeyYubicoClientId(''); setYubiKeyYubicoClientId('');
setYubiKeyYubicoSecretKey(''); setYubiKeyYubicoSecretKey('');
setYubiKeyBootstrapOtp(''); setYubiKeyBootstrapOtp('');
@@ -1010,8 +1013,7 @@ export default function SettingsPage(props: SettingsPageProps) {
</section> </section>
)} )}
{yubiKeyYubicoConfigured && ( {yubiKeyYubicoConfigured && yubiKeyYubicoCanManage && (
<>
<section className="settings-submodule yubikey-config-panel"> <section className="settings-submodule yubikey-config-panel">
<div className="settings-module-head"> <div className="settings-module-head">
<h3>{t('txt_yubikey_validation_credentials')}</h3> <h3>{t('txt_yubikey_validation_credentials')}</h3>
@@ -1055,7 +1057,10 @@ export default function SettingsPage(props: SettingsPageProps) {
</div> </div>
)} )}
</section> </section>
)}
{yubiKeyYubicoConfigured && (
<>
<ol className="settings-plain-steps"> <ol className="settings-plain-steps">
<li>{t('txt_yubikey_plug_in')}</li> <li>{t('txt_yubikey_plug_in')}</li>
<li>{t('txt_yubikey_select_empty_field')}</li> <li>{t('txt_yubikey_select_empty_field')}</li>
+1
View File
@@ -687,6 +687,7 @@ function normalizeYubiKeySettings(raw: any): YubiKeyOtpSettings {
], ],
nfc: !!(raw?.nfc ?? raw?.Nfc), nfc: !!(raw?.nfc ?? raw?.Nfc),
yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured), yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured),
yubicoCanManage: !!(raw?.yubicoCanManage ?? raw?.YubicoCanManage),
yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''), yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''),
yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''), yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''),
}; };
+1
View File
@@ -423,6 +423,7 @@ export interface YubiKeyOtpSettings {
keys: [string, string, string, string, string]; keys: [string, string, string, string, string];
nfc: boolean; nfc: boolean;
yubicoConfigured: boolean; yubicoConfigured: boolean;
yubicoCanManage: boolean;
yubicoClientId: string; yubicoClientId: string;
yubicoSecretKey: string; yubicoSecretKey: string;
} }