122 lines
4.8 KiB
JavaScript
122 lines
4.8 KiB
JavaScript
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);
|
|
}
|