/** * TOTP(RFC 6238)+ Base32,零依赖实现 * * 只用 node:crypto 的 HMAC,不引第三方库。验证器 App(Google Authenticator / * Microsoft Authenticator / 1Password / Aegis 等)都用同一套算法。 * * 参考:RFC 6238 TOTP、RFC 4226 HOTP、RFC 3548 Base32。 */ import crypto from 'node:crypto'; const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; /* ---------- Base32 ---------- */ export function base32Encode(buf) { let bits = 0; let value = 0; let out = ''; for (const byte of buf) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31]; return out; } // 容忍用户输入里的小写、空格与连字符(很多 App 展示密钥时会分组) export function base32Decode(str) { const clean = String(str ?? '').toUpperCase().replace(/[\s-]/g, ''); let bits = 0; let value = 0; const out = []; for (const ch of clean) { const idx = BASE32_ALPHABET.indexOf(ch); if (idx < 0) continue; // 跳过无法识别的字符(如误粘贴的 '0'/'1'/'8'/'9') value = (value << 5) | idx; bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; } } return Buffer.from(out); } /* ---------- 密钥 ---------- */ // 20 字节 = 160 位,符合 RFC 4226 对 HMAC-SHA1 的建议强度 export function generateSecret(bytes = 20) { return base32Encode(crypto.randomBytes(bytes)); } /* ---------- TOTP ---------- */ export const DEFAULT_DIGITS = 6; export const DEFAULT_PERIOD_SEC = 30; // 允许 ±1 个时间窗:手机时间与服务端有几秒偏差是常态,也容忍一次网络延迟 export const DEFAULT_WINDOW = 1; export function hotp(secretBuf, counter, digits = DEFAULT_DIGITS) { const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(counter)); const hmac = crypto.createHmac('sha1', secretBuf).update(msg).digest(); // RFC 4226 动态截断:取最后一个字节的低 4 位作偏移 const offset = hmac[hmac.length - 1] & 0x0f; const code = ((hmac[offset] & 0x7f) << 24) | (hmac[offset + 1] << 16) | (hmac[offset + 2] << 8) | hmac[offset + 3]; return String(code % 10 ** digits).padStart(digits, '0'); } export function totp(secretBuf, { atMs = Date.now(), periodSec = DEFAULT_PERIOD_SEC, digits = DEFAULT_DIGITS } = {}) { return hotp(secretBuf, Math.floor(atMs / 1000 / periodSec), digits); } /** * 校验用户输入的验证码。 * 返回 null 表示通过;返回字符串表示拒绝原因(供接口回显,不泄露密钥相关信息)。 */ export function verifyTotp(secret, code, { atMs = Date.now(), periodSec = DEFAULT_PERIOD_SEC, digits = DEFAULT_DIGITS, window = DEFAULT_WINDOW } = {}) { const clean = String(code ?? '').replace(/\s|-/g, ''); if (!new RegExp(`^\\d{${digits}}$`).test(clean)) return 'invalid-format'; const secretBuf = base32Decode(secret); if (secretBuf.length < 10) return 'invalid-secret'; const counter = Math.floor(atMs / 1000 / periodSec); // 用定长比较,避免逐字符提前返回带来的时序侧信道 const a = Buffer.from(clean, 'utf8'); for (let w = -window; w <= window; w++) { const expect = Buffer.from(hotp(secretBuf, counter + w, digits), 'utf8'); if (expect.length === a.length && crypto.timingSafeEqual(expect, a)) return null; } return 'invalid-code'; } // 当前验证码所属的时间步:用于「同一步内不得重复使用」的防重放 export function totpCounter({ atMs = Date.now(), periodSec = DEFAULT_PERIOD_SEC } = {}) { return Math.floor(atMs / 1000 / periodSec); } /* ---------- otpauth URI(给验证器扫码用) ---------- */ export function otpauthUri({ secret, label, issuer, digits = DEFAULT_DIGITS, periodSec = DEFAULT_PERIOD_SEC }) { const q = new URLSearchParams({ secret, digits: String(digits), period: String(periodSec) }); // issuer 同时出现在路径与查询参数里:兼容各家 App 的两种解析方式 if (issuer) q.set('issuer', issuer); return `otpauth://totp/${encodeURIComponent(issuer ?? label)}:${encodeURIComponent(label)}?${q}`; } /* ---------- 恢复码 ---------- */ // 每个码 10 字符,形如 X7K2-9QMD4;只存哈希,明文只在绑定成功时展示一次 export function generateRecoveryCodes(count = 10) { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去掉易混淆的 0/O/1/I const codes = []; for (let i = 0; i < count; i++) { let s = ''; const bytes = crypto.randomBytes(10); for (let j = 0; j < 10; j++) s += alphabet[bytes[j] % alphabet.length]; codes.push(`${s.slice(0, 5)}-${s.slice(5)}`); } return codes; } // 归一化后再哈希:用户从别处抄码常漏掉连字符或用小写,两种都要能登录 export function hashRecoveryCode(code, salt) { const norm = String(code ?? '').toUpperCase().replace(/[\s-]/g, ''); return crypto.createHash('sha256').update(`${salt}:${norm}`).digest('hex'); }