4e089b5ebb
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>
160 lines
5.9 KiB
TypeScript
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)
|
|
}
|