Files
Inna 4e089b5ebb Añadir ruta /security-2falogin (verificación en 2 pasos / TOTP)
Activación de 2FA (TOTP) para el login del juego, guardando el secreto en
acore_auth.account.totp_secret tal como lo lee el bnetserver 3.4.3.

- lib/two-factor.ts: TOTP (HMAC-SHA1, 30s, 6 dígitos, ±1) idéntico al core
  (verificado contra los vectores RFC 6238), Base32, otpauth, y codificado
  del secreto: crudo por defecto o AES-128-GCM (ciphertext‖IV12‖tag12) si se
  define TOTP_MASTER_SECRET (mismo hex que TOTPMasterSecret del servidor).
- Flujo seguro sin bloqueos: el secreto se genera y queda PENDIENTE en la
  sesión; sólo se escribe en la cuenta tras verificar un código válido.
- /api/account/2fa: activar (password + token de seguridad), verificar y
  desactivar (con código actual). QR generado en el servidor (el secreto no
  sale a terceros).
- Página + componente fieles al markup (preview, pasos, copiar clave, ojos).

Verificado E2E: activar -> QR/secreto -> verificar -> totp_secret = 20 bytes
(igual al secreto escaneado) -> desactivar -> NULL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:37:13 +00:00

160 lines
5.9 KiB
TypeScript

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<boolean> {
const [rows] = await db(DB.auth).query<RowDataPacket[]>(
'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<Buffer | null> {
const [rows] = await db(DB.auth).query<RowDataPacket[]>(
'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<void> {
await db(DB.auth).query('UPDATE account SET totp_secret = ? WHERE id = ?', [encodeSecretForDb(raw), accountId])
}
export async function clearAccountTotpSecret(accountId: number): Promise<void> {
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)
}