From 6e4a123dde57db559d8e1d1fdadc013b00ffa693 Mon Sep 17 00:00:00 2001 From: adevopg Date: Wed, 15 Jul 2026 17:11:22 +0000 Subject: [PATCH] El token de seguridad son 6 letras al azar, no base64url sesgado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El token se generaba con `randomBytes(4).toString('base64url').slice(0, 6)`, portado tal cual del Django original (`secrets.token_urlsafe(4)[:6]`), y tenía dos taras para un código que se lee y se teclea desde un correo: - Metía `-` y `_`. - 4 bytes son 32 bits, pero 6 caracteres base64 codifican 36: al último solo le llegaban 2 bits reales, así que SIEMPRE terminaba en A, Q, g o w (comprobado sobre 20.000 tokens: 4 valores distintos en esa posición). Ahora son 6 letras A-Z/a-z uniformes, con muestreo por rechazo porque 256 no es múltiplo de 52 y `byte % 52` favorecería a las primeras letras. Verificado con 300.000 tokens: todos casan /^[A-Za-z]{6}$/, las 52 letras aparecen en la última posición y la desviación por letra se queda en el 1,4% (ruido). Co-Authored-By: Claude Opus 4.8 (1M context) --- web-next/lib/security-token.ts | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/web-next/lib/security-token.ts b/web-next/lib/security-token.ts index ef18c2a..1756055 100644 --- a/web-next/lib/security-token.ts +++ b/web-next/lib/security-token.ts @@ -11,7 +11,37 @@ export interface Result { tokenDate?: string } -/** Solicita un token de seguridad (6 caracteres) por email. 1 cada 7 días. */ +/** Letras A-Z y a-z, sin dígitos: el formato del token es `uRlPBR`. */ +const TOKEN_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' +const TOKEN_LENGTH = 6 + +/** + * Genera el token de seguridad: 6 letras al azar. + * + * Antes era `randomBytes(4).toString('base64url').slice(0, 6)`, portado tal cual + * del Django original (`secrets.token_urlsafe(4)[:6]`). Tenía dos taras: metía + * `-` y `_` en un código que se lee y se teclea desde un correo, y sobre todo 4 + * bytes son 32 bits mientras que 6 caracteres base64 codifican 36 → al último + * carácter solo le llegaban 2 bits reales y SIEMPRE salía `A`, `Q`, `g` o `w`. + * + * Muestreo por rechazo: 256 no es múltiplo de 52, así que `byte % 52` haría más + * probables las primeras letras del alfabeto. Descartando los bytes >= 208 + * (52*4, el múltiplo de 52 más alto que cabe en un byte) el reparto es uniforme. + */ +function generateSecurityToken(): string { + const limit = 256 - (256 % TOKEN_ALPHABET.length) + let token = '' + while (token.length < TOKEN_LENGTH) { + for (const byte of crypto.randomBytes(TOKEN_LENGTH)) { + if (byte >= limit) continue + token += TOKEN_ALPHABET[byte % TOKEN_ALPHABET.length] + if (token.length === TOKEN_LENGTH) break + } + } + return token +} + +/** Solicita un token de seguridad (6 letras) por email. 1 cada 7 días. */ export async function requestSecurityToken(session: SessionData, ip: string): Promise { const userId = session.accountId ?? 0 const email = session.bnetEmail ?? '' @@ -26,7 +56,7 @@ export async function requestSecurityToken(session: SessionData, ip: string): Pr if (days < 7) return { success: false, error: 'cooldown' } } - const token = crypto.randomBytes(4).toString('base64url').slice(0, 6) + const token = generateSecurityToken() const expiresAt = new Date(Date.now() + 7 * 86400_000) await db(DB.default).query('DELETE FROM securitytoken WHERE user_id = ?', [userId])