Files
shiroko-monitor/prober/node/lib/totp.js
T
tamakyi 752fad412b feat: v0.12.0 —— 表格每行绝对等高 + 管理 WebUI(2FA 绑定才能进入)
① 每行绝对等高
- 行高与内容长度彻底解耦:新增 --row-h: 72px 作为唯一基准
- 名称列改为「文本区限两行 + 徽标区固定单行」两段式;文本区用 .clamp2
- 关键点:必须用 height 而非 max-height —— 后者会让短内容缩成一行、
  长内容撑到两行,反而产生 66px/71px 两种行高(实测踩过)
- 分组标题行用 :not(.group-row) 排除在数据行规则之外
- 事件表与监控表格共用同一个 --row-h,两张表视觉上也严格对齐
- 完整值仍可通过悬停 title 查看(已加断言强制所有截断单元格带 title)
- 卡片视图同样等高(13 视口 × 250px 全一致)

② 管理 WebUI + 2FA
- 新增 /admin 与 /api/admin/*;未绑定 2FA 时除绑定流程外一律 403
- 零依赖实现 TOTP(RFC 6238),用 RFC 4226/6238 官方测试向量验证(16 个向量)
- 内置 QR 码生成器(byte 模式 / ECC L / 版本 1-10,GF(256) RS 纠错 + 掩码评分),
  用独立解码器实测:版本 1 与版本 6 均能读出逐字节一致的内容
- 绑定流程:口令 → 生成密钥(扫码/手输/otpauth 链接)→ 验证 6 位码 → 10 枚恢复码;
  完成后凭本次验证码直接换取会话
- 安全:会话 id 32 字节随机数且服务端只存哈希;HttpOnly+SameSite=Strict;
  30 分钟空闲/2 小时最长;同时间步的码防重放;恢复码一次性;
  登录失败按 IP 指数退避锁定;不解析 X-Forwarded-For
- 管理功能:运行状态、探测范围、展示口径、维护窗口增删、立即重探、通知自检、
  目标只读列表、最近事件
- 所有写操作只落 local.monitors.json(私有不入库),绝不改 public/monitors.json

过程中修掉三个真 bug:
1. 会话 cookie 的 Path=/admin 覆盖不到 /api/admin/*,登录 200 但后续全部 401
2. 恢复码登录被 verifyTotp 的 invalid-format 提前挡掉(恢复码非 6 位数字)
3. 超大请求体用 req.destroy() 硬断连接,客户端只看到 fetch failed 拿不到状态码

测试:新增 scripts/verify-admin.cjs(102 项),全项目共 7 套 274 项全部通过
2026-10-14 12:37:07 +08:00

131 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
}