mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
feat(yubico): refactor Yubico credential management and enhance settings UI
This commit is contained in:
+68
-42
@@ -11,15 +11,18 @@ import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
|
||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||
import { buildAccountKeys } from '../utils/user-decryption';
|
||||
import { buildProfileResponse } from '../utils/profile-response';
|
||||
import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp';
|
||||
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_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
||||
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
||||
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
|
||||
|
||||
// CONTRACT:
|
||||
// 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;
|
||||
}
|
||||
|
||||
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>> {
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
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>> {
|
||||
const credentials = await getStoredYubicoCredentials(storage, env);
|
||||
void storage;
|
||||
const credentials = await getYubicoCredentials(env.DB);
|
||||
const canManageCredentials = user.role === 'admin' && user.status === 'active';
|
||||
return {
|
||||
...yubiKeyResponse(user),
|
||||
YubicoConfigured: !!credentials?.clientId,
|
||||
YubicoClientId: credentials?.clientId ?? '',
|
||||
YubicoSecretKey: credentials?.secretKey ?? '',
|
||||
YubicoCanManage: canManageCredentials,
|
||||
...(canManageCredentials
|
||||
? {
|
||||
YubicoClientId: credentials?.clientId ?? '',
|
||||
YubicoSecretKey: credentials?.secretKey ?? '',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1013,7 +998,7 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user
|
||||
readBodyString(body, ['key5', 'Key5']),
|
||||
];
|
||||
const publicIds: Array<string | null> = [];
|
||||
let credentials = await getStoredYubicoCredentials(storage, env);
|
||||
let credentials = await getYubicoCredentials(env.DB);
|
||||
let apiKeyBootstrapOtpIndex: number | null = null;
|
||||
for (const key of keys) {
|
||||
const trimmed = key.trim();
|
||||
@@ -1028,9 +1013,10 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user
|
||||
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;
|
||||
const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, trimmed);
|
||||
if (!initialized) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
|
||||
credentials = initialized.credentials;
|
||||
if (initialized.created) apiKeyBootstrapOtpIndex = publicIds.length;
|
||||
}
|
||||
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
|
||||
return errorResponse('Invalid YubiKey OTP.', 400);
|
||||
@@ -1071,6 +1057,7 @@ export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
if (user.role !== 'admin' || user.status !== 'active') return errorResponse('Forbidden', 403);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
@@ -1085,10 +1072,18 @@ export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env
|
||||
|
||||
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);
|
||||
if (!clientId || !secretKey) return errorResponse('Yubico Client ID and Secret Key are required.', 400);
|
||||
|
||||
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, clientId);
|
||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, secretKey);
|
||||
await replaceYubicoCredentials(env.DB, { clientId, 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));
|
||||
}
|
||||
@@ -1113,11 +1108,42 @@ export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, en
|
||||
|
||||
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);
|
||||
const existing = await getYubicoCredentials(env.DB);
|
||||
if (user.role !== 'admin' && existing) {
|
||||
return errorResponse('Yubico validation credentials are already configured.', 403);
|
||||
}
|
||||
|
||||
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
|
||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
|
||||
let credentials;
|
||||
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));
|
||||
}
|
||||
|
||||
+13
-14
@@ -25,7 +25,8 @@ import {
|
||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||
import { constantTimeEquals, verifyApiKey } from '../utils/api-key';
|
||||
import { isYubiKeyEnabled, userYubiKeyPublicIds, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp';
|
||||
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_PROVIDER_AUTHENTICATOR = 0;
|
||||
@@ -34,8 +35,6 @@ const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
||||
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
||||
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
||||
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
|
||||
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
|
||||
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains
|
||||
// compatible with older/local provider values.
|
||||
@@ -163,15 +162,6 @@ async function loginRateLimitKey(clientIdentifier: string, grantType: string, su
|
||||
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 {
|
||||
const isHttps = new URL(request.url).protocol === 'https:';
|
||||
const parts = [
|
||||
@@ -507,8 +497,17 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
const credentials = await getStoredYubicoCredentials(storage, env);
|
||||
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
|
||||
let credentials = await getYubicoCredentials(env.DB);
|
||||
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);
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
|
||||
import type { Env } from '../types';
|
||||
import { APP_VERSION } from '../../shared/app-version';
|
||||
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
|
||||
import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
|
||||
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
|
||||
import {
|
||||
getAttachmentObjectKey,
|
||||
@@ -111,7 +112,7 @@ function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
|
||||
const sanitized: SqlRow[] = [];
|
||||
for (const row of rows) {
|
||||
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) {
|
||||
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Env, User } from '../types';
|
||||
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
||||
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
|
||||
import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
|
||||
import {
|
||||
type BackupManifestAttachmentBlob,
|
||||
type BackupPayload,
|
||||
@@ -276,7 +277,9 @@ async function prepareImportedConfigRows(
|
||||
configRows: SqlRow[],
|
||||
userRows: 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 normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
|
||||
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,7 @@ export interface Env {
|
||||
WEBAUTHN_RP_ID?: string;
|
||||
WEBAUTHN_RP_NAME?: string;
|
||||
WEBAUTHN_ALLOWED_ORIGINS?: string;
|
||||
YUBICO_CLIENT_ID?: string;
|
||||
YUBICO_SECRET_KEY?: string;
|
||||
YUBICO_VALIDATION_URLS?: string;
|
||||
'globalSettings__yubico__clientId'?: string;
|
||||
'globalSettings__yubico__key'?: string;
|
||||
'globalSettings__yubico__validationUrls'?: string;
|
||||
}
|
||||
|
||||
|
||||
+12
-22
@@ -48,12 +48,6 @@ 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('');
|
||||
@@ -143,27 +137,25 @@ export async function requestYubicoApiCredentials(email: string, otpInput: strin
|
||||
export async function verifyYubicoOtp(
|
||||
env: Env,
|
||||
otpInput: string,
|
||||
credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env)
|
||||
credentials: YubicoApiCredentials | null
|
||||
): Promise<boolean> {
|
||||
const otp = normalizeYubiKeyOtp(otpInput);
|
||||
if (!isYubiKeyOtp(otp)) return false;
|
||||
|
||||
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 secretKey = String(credentials?.secretKey || '').trim();
|
||||
const params = new URLSearchParams({
|
||||
id: clientId,
|
||||
nonce,
|
||||
otp,
|
||||
});
|
||||
if (secretKey) {
|
||||
try {
|
||||
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const baseUrl of validationUrls(env)) {
|
||||
@@ -172,14 +164,12 @@ export async function verifyYubicoOtp(
|
||||
if (!response.ok) continue;
|
||||
const parsed = parseYubicoResponse(await response.text());
|
||||
if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue;
|
||||
if (secretKey) {
|
||||
if (!parsed.h) continue;
|
||||
const signedParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (key !== 'h') signedParams.set(key, value);
|
||||
}
|
||||
if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue;
|
||||
if (!parsed.h) continue;
|
||||
const signedParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (key !== 'h') signedParams.set(key, value);
|
||||
}
|
||||
if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue;
|
||||
return true;
|
||||
} catch {
|
||||
continue;
|
||||
|
||||
@@ -144,6 +144,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const [yubiKeyStoredKeys, setYubiKeyStoredKeys] = useState<[string, string, string, string, string]>(EMPTY_YUBIKEY_KEYS);
|
||||
const [yubiKeyNfc, setYubiKeyNfc] = useState(false);
|
||||
const [yubiKeyYubicoConfigured, setYubiKeyYubicoConfigured] = useState(false);
|
||||
const [yubiKeyYubicoCanManage, setYubiKeyYubicoCanManage] = useState(false);
|
||||
const [yubiKeyYubicoClientId, setYubiKeyYubicoClientId] = useState('');
|
||||
const [yubiKeyYubicoSecretKey, setYubiKeyYubicoSecretKey] = useState('');
|
||||
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
|
||||
@@ -340,6 +341,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setYubiKeyStoredKeys(settings.keys);
|
||||
setYubiKeyNfc(settings.nfc);
|
||||
setYubiKeyYubicoConfigured(settings.yubicoConfigured);
|
||||
setYubiKeyYubicoCanManage(settings.yubicoCanManage);
|
||||
setYubiKeyYubicoClientId(settings.yubicoClientId);
|
||||
setYubiKeyYubicoSecretKey(settings.yubicoSecretKey);
|
||||
}
|
||||
@@ -352,6 +354,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
|
||||
setYubiKeyNfc(false);
|
||||
setYubiKeyYubicoConfigured(false);
|
||||
setYubiKeyYubicoCanManage(false);
|
||||
setYubiKeyYubicoClientId('');
|
||||
setYubiKeyYubicoSecretKey('');
|
||||
setYubiKeyBootstrapOtp('');
|
||||
@@ -1010,8 +1013,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{yubiKeyYubicoConfigured && (
|
||||
<>
|
||||
{yubiKeyYubicoConfigured && yubiKeyYubicoCanManage && (
|
||||
<section className="settings-submodule yubikey-config-panel">
|
||||
<div className="settings-module-head">
|
||||
<h3>{t('txt_yubikey_validation_credentials')}</h3>
|
||||
@@ -1055,7 +1057,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{yubiKeyYubicoConfigured && (
|
||||
<>
|
||||
<ol className="settings-plain-steps">
|
||||
<li>{t('txt_yubikey_plug_in')}</li>
|
||||
<li>{t('txt_yubikey_select_empty_field')}</li>
|
||||
|
||||
@@ -687,6 +687,7 @@ function normalizeYubiKeySettings(raw: any): YubiKeyOtpSettings {
|
||||
],
|
||||
nfc: !!(raw?.nfc ?? raw?.Nfc),
|
||||
yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured),
|
||||
yubicoCanManage: !!(raw?.yubicoCanManage ?? raw?.YubicoCanManage),
|
||||
yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''),
|
||||
yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''),
|
||||
};
|
||||
|
||||
@@ -423,6 +423,7 @@ export interface YubiKeyOtpSettings {
|
||||
keys: [string, string, string, string, string];
|
||||
nfc: boolean;
|
||||
yubicoConfigured: boolean;
|
||||
yubicoCanManage: boolean;
|
||||
yubicoClientId: string;
|
||||
yubicoSecretKey: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user