import crypto from 'node:crypto' import type { RowDataPacket } from 'mysql2' import { db, DB } from './db' /** * TOTP para el login del juego (Battle.net / AzerothCore 3.4.3). * * El secreto se guarda en `acore_auth.account.totp_secret` (VARBINARY). El * servidor lo lee y valida en cada inicio de sesión. Reproduce exactamente la * implementación del core (src/common/Cryptography/TOTP.cpp): HMAC-SHA1, ventana * de 30 s, 6 dígitos y una tolerancia de ±1 intervalo. * * Almacenamiento (igual que `.account 2fa setup` del core): * - Sin master key (por defecto, TOTPMasterSecret vacío): se guarda el secreto * en crudo (20 bytes). * - Con master key (TOTP_MASTER_SECRET = mismo hex que TOTPMasterSecret del * bnetserver): se cifra con AES-128-GCM y se guarda `ciphertext‖IV(12)‖tag(12)`. */ const SECRET_LENGTH = 20 const INTERVAL = 30 /* --------------------------------- Base32 ---------------------------------- */ const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' export function base32Encode(buf: Buffer): string { let bits = 0 let value = 0 let out = '' for (const byte of buf) { value = (value << 8) | byte bits += 8 while (bits >= 5) { out += B32_ALPHABET[(value >>> (bits - 5)) & 31] bits -= 5 } } if (bits > 0) out += B32_ALPHABET[(value << (5 - bits)) & 31] return out // sin relleno '='; los autenticadores lo aceptan } /* ---------------------------------- TOTP ----------------------------------- */ /** Genera el token de 6 dígitos para un instante dado (mismo algoritmo que el core). */ export function generateToken(secret: Buffer, timestamp: number): number { let counter = Math.floor(timestamp / INTERVAL) const challenge = Buffer.alloc(8) for (let i = 7; i >= 0; i--) { challenge[i] = counter & 0xff counter = Math.floor(counter / 256) } const digest = crypto.createHmac('sha1', secret).update(challenge).digest() const offset = digest[19] & 0xf const truncated = (((digest[offset] & 0x7f) << 24) | (digest[offset + 1] << 16) | (digest[offset + 2] << 8) | digest[offset + 3]) >>> 0 return truncated % 1000000 } /** Valida un token con tolerancia de ±1 intervalo (now-30, now, now+30). */ export function validateToken(secret: Buffer, token: number): boolean { const now = Math.floor(Date.now() / 1000) return ( token === generateToken(secret, now - INTERVAL) || token === generateToken(secret, now) || token === generateToken(secret, now + INTERVAL) ) } /* -------------------------------- otpauth ---------------------------------- */ export function otpauthUrl(issuer: string, label: string, secretBase32: string): string { const iss = encodeURIComponent(issuer) const lbl = encodeURIComponent(label) return `otpauth://totp/${iss}:${lbl}?secret=${secretBase32}&issuer=${iss}&algorithm=SHA1&digits=6&period=30` } /* ------------------------ Cifrado para la base de datos --------------------- */ // Clave AES de 16 bytes desde el master key hex, en little-endian (igual que // BigNumber::ToByteArray del core, cuyo valor por defecto es littleEndian=true). function masterKeyBytes(): Buffer | null { const hex = (process.env.TOTP_MASTER_SECRET || '').trim() if (!hex) return null let n: bigint try { n = BigInt('0x' + hex.replace(/^0x/i, '')) } catch { return null } const buf = Buffer.alloc(16) let v = n for (let i = 0; i < 16; i++) { buf[i] = Number(v & 0xffn) v >>= 8n } return buf } /** Codifica el secreto para guardarlo en `account.totp_secret` (crudo o AES-GCM). */ export function encodeSecretForDb(raw: Buffer): Buffer { const key = masterKeyBytes() if (!key) return raw const iv = crypto.randomBytes(12) const cipher = crypto.createCipheriv('aes-128-gcm', key, iv, { authTagLength: 12 }) const ct = Buffer.concat([cipher.update(raw), cipher.final()]) const tag = cipher.getAuthTag() // 12 bytes return Buffer.concat([ct, iv, tag]) // ciphertext ‖ IV(12) ‖ tag(12) } /** Decodifica el secreto almacenado (crudo o AES-GCM). Devuelve null si falla. */ export function decodeSecretFromDb(stored: Buffer): Buffer | null { const key = masterKeyBytes() if (!key) return stored if (stored.length < 24) return null const tag = stored.subarray(stored.length - 12) const iv = stored.subarray(stored.length - 24, stored.length - 12) const ct = stored.subarray(0, stored.length - 24) try { const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv, { authTagLength: 12 }) decipher.setAuthTag(tag) return Buffer.concat([decipher.update(ct), decipher.final()]) } catch { return null } } /* --------------------------- Acceso a la cuenta ---------------------------- */ export async function is2faEnabled(accountId: number): Promise { const [rows] = await db(DB.auth).query( 'SELECT totp_secret FROM account WHERE id = ?', [accountId], ) return Boolean(rows[0] && rows[0].totp_secret != null) } /** Devuelve el secreto TOTP en crudo (descifrado) de la cuenta, o null si no tiene 2FA. */ export async function getAccountTotpSecret(accountId: number): Promise { const [rows] = await db(DB.auth).query( 'SELECT totp_secret FROM account WHERE id = ?', [accountId], ) const stored = rows[0]?.totp_secret if (stored == null) return null return decodeSecretFromDb(Buffer.from(stored)) } export async function setAccountTotpSecret(accountId: number, raw: Buffer): Promise { await db(DB.auth).query('UPDATE account SET totp_secret = ? WHERE id = ?', [encodeSecretForDb(raw), accountId]) } export async function clearAccountTotpSecret(accountId: number): Promise { await db(DB.auth).query('UPDATE account SET totp_secret = NULL WHERE id = ?', [accountId]) } /** Genera un secreto TOTP aleatorio (20 bytes, como RECOMMENDED_SECRET_LENGTH del core). */ export function generateSecret(): Buffer { return crypto.randomBytes(SECRET_LENGTH) }