import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const RECOVERY_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; const TOTP_PERIOD_SECONDS = 30; function encryptionKey() { const configured = String(process.env.TOTP_ENCRYPTION_KEY || ''); if (process.env.NODE_ENV === 'production' && configured.length < 32) { throw new Error('生产环境启用 TOTP 前必须设置至少 32 个字符的 TOTP_ENCRYPTION_KEY'); } const material = configured || `development-only:${process.env.INITIAL_ADMIN_PASSWORD || 'local-exam-system'}`; return createHash('sha256').update(material).digest(); } export function assertTotpConfiguration() { encryptionKey(); } export function createTotpSecret() { const bytes = randomBytes(20); let bits = ''; for (const byte of bytes) bits += byte.toString(2).padStart(8, '0'); let encoded = ''; for (let index = 0; index < bits.length; index += 5) { encoded += BASE32_ALPHABET[Number.parseInt(bits.slice(index, index + 5).padEnd(5, '0'), 2)]; } return encoded; } function decodeBase32(value) { const normalized = String(value || '').toUpperCase().replace(/[^A-Z2-7]/g, ''); let bits = ''; for (const character of normalized) { const index = BASE32_ALPHABET.indexOf(character); if (index < 0) throw new Error('TOTP 密钥格式无效'); bits += index.toString(2).padStart(5, '0'); } const bytes = []; for (let index = 0; index + 8 <= bits.length; index += 8) bytes.push(Number.parseInt(bits.slice(index, index + 8), 2)); return Buffer.from(bytes); } export function totpAtStep(secret, step) { const counter = Buffer.alloc(8); counter.writeBigUInt64BE(BigInt(step)); const digest = createHmac('sha1', decodeBase32(secret)).update(counter).digest(); const offset = digest[digest.length - 1] & 0x0f; const binary = (digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000; return String(binary).padStart(6, '0'); } export function verifyTotp(code, secret, { now = Date.now(), window = 1, lastUsedStep = null } = {}) { const normalized = String(code || '').replace(/\s/g, ''); if (!/^\d{6}$/.test(normalized)) return null; const currentStep = Math.floor(now / 1000 / TOTP_PERIOD_SECONDS); for (let offset = -window; offset <= window; offset += 1) { const step = currentStep + offset; if (lastUsedStep != null && step <= Number(lastUsedStep)) continue; const expected = Buffer.from(totpAtStep(secret, step)); const supplied = Buffer.from(normalized); if (expected.length === supplied.length && timingSafeEqual(expected, supplied)) return step; } return null; } export function buildOtpAuthUri({ secret, account, issuer }) { const label = `${issuer}:${account}`; const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: String(TOTP_PERIOD_SECONDS) }); return `otpauth://totp/${encodeURIComponent(label)}?${params}`; } export function encryptTotpSecret(secret) { const iv = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv); const encrypted = Buffer.concat([cipher.update(String(secret), 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${encrypted.toString('base64url')}`; } export function decryptTotpSecret(value) { const [version, ivValue, tagValue, encryptedValue] = String(value || '').split('.'); if (version !== 'v1' || !ivValue || !tagValue || !encryptedValue) throw new Error('TOTP 密钥数据无效'); const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivValue, 'base64url')); decipher.setAuthTag(Buffer.from(tagValue, 'base64url')); return Buffer.concat([decipher.update(Buffer.from(encryptedValue, 'base64url')), decipher.final()]).toString('utf8'); } function normalizeRecoveryCode(code) { return String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); } export function hashRecoveryCode(code) { return createHmac('sha256', encryptionKey()).update(normalizeRecoveryCode(code)).digest('hex'); } export function createRecoveryCodes(count = 8) { return Array.from({ length: count }, () => { let value = ''; const bytes = randomBytes(10); for (let index = 0; index < 10; index += 1) value += RECOVERY_ALPHABET[bytes[index] % RECOVERY_ALPHABET.length]; return `${value.slice(0, 5)}-${value.slice(5)}`; }); } export function consumeRecoveryCode(code, hashes = []) { const candidate = Buffer.from(hashRecoveryCode(code)); const index = hashes.findIndex(hash => { const stored = Buffer.from(String(hash || '')); return stored.length === candidate.length && timingSafeEqual(stored, candidate); }); if (index < 0) return null; return hashes.filter((_, itemIndex) => itemIndex !== index); }