2fauth-worker项目的安全性分析和代码审计

24次阅读
没有评论

项目地址:项目地址:https://github.com/nap0o/2fauth-worker

先说结论,再拆细节。

一句话结论

  • 这个项目整体安全设计是有意识且比较现代的,没有明显“一眼就 NO”的硬伤。
  • 但它本质是「自建 2FA 管理器」,安全基线=密码管理器级别,风险集中在:
    • 配置是否严格(密钥、白名单);
    • Cloudflare 账号是否安全
    • 前端有无 XSS(一旦有,所有 TOTP secret 都能被拖走)。
  • 如果你愿意把 2FA 秘钥托管在自己的 Cloudflare 账号上,并且能严格按要求管好 ENCRYPTION_KEY / JWT_SECRET / 白名单,可以考虑使用,但要当成高敏感资产来运维。

下面是我基于 backend 代码的具体审计(只看了后端 TypeScript / Hono / D1 部分,前端没完全展开)。


架构 & 安全特性概览

后端入口:backend/src/app/index.ts

  • 基于 Hono + Cloudflare Workers + D1 (SQLite)
  • 全局中间件:
    • logger():记录请求日志。
    • cors():Origin 动态反射,允许携带 Cookie,开放 X-CSRF-Token 头。
    • secureHeaders():设置 CSP / X-Frame-Options / 等安全头:
      • default-src 'self'
      • script-srcimg-srcconnect-srcframe-src 都从 CSP_POLICY 配置。
  • 路由:
    • /api/oauth → OAuth 登录相关。
    • /api/vault → 2FA 金库(TOTP 账户管理)。
    • /api/backups → WebDAV/S3 备份。
    • /api/telegram → Telegram Webhook。
    • /api/tools → 工具接口。
  • 静态资源全部通过 env.ASSETS.fetch(),再由 Hono 包一层加 CSP 头。

这套设计:前后端合仓 + 严格 CSP + API/静态统一走一层 Worker,对 XSS / 混乱源有积极作用。


核心敏感点:TOTP Secret 存储 & 加密

相关文件:

  • src/shared/db/schema.ts
  • src/shared/db/db.ts
  • src/shared/utils/crypto.ts
  • src/features/vault/vaultService.ts

数据库结构(D1)

export const vault = sqliteTable('vault', {
  id: text('id').primaryKey(),
  service: text('service').notNull(),
  account: text('account').notNull(),
  category: text('category'),
  secret: text('secret').notNull(), // 加密后的密文
  digits: integer('digits').default(6),
  period: integer('period').default(30),
  algorithm: text('algorithm').default('SHA-1'),
  createdAt: integer('created_at').notNull(),
  createdBy: text('created_by'),
  updatedAt: integer('updated_at'),
  updatedBy: text('updated_by'),
});

TOTP 的原始 secret 存在 secret 字段里,不是明文,而是 JSON 化的加密结构。

加密实现

src/shared/utils/crypto.ts 里有两个层次:

  1. 备份文件的“重型版”:// encryptBackupFile: PBKDF2 + AES-GCM + salt + iv // ITERATIONS = 100000, SALT_LEN = 16, IV_LEN = 12 这是用于“导出备份文件 + 用户密码”场景的,比较重,但合理。
  2. 数据库存储用的“极速模式”:async function getFastKey(secret: string): Promise<CryptoKey> { const encoder = new TextEncoder(); const keyBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(secret)); return crypto.subtle.importKey( 'raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] ); } export async function encryptData(data: any, masterKey: string) { const encoder = new TextEncoder(); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await getFastKey(masterKey); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, encoder.encode(JSON.stringify(data)) ); return { encrypted: Array.from(new Uint8Array(encrypted)), iv: Array.from(iv) }; } export async function decryptData(encryptedData, masterKey: string) { // 如果有 salt,用 PBKDF2,否则走 getFastKey }

DB 层封装:

export async function encryptField(data: any, key: string) {
    const encrypted = await encryptData(data, key);
    return JSON.stringify(encrypted);
}

export async function decryptField(encryptedStr: string, key: string) {
    const encryptedObj = JSON.parse(encryptedStr);
    return await decryptData(encryptedObj, key);
}

Vault 中真正存的是:

  • 业务字段(service/account/…)明文;
  • secret 字段是 JSON string:
    • { encrypted: number[], iv: number[] }
    • 密钥 = SHA-256(ENCRYPTION_KEY 或 JWT_SECRET)。

VaultService 里怎么用

src/features/vault/vaultService.ts

constructor(env: EnvBindings, repository: VaultRepository) {
    this.env = env;
    this.repository = repository;
    this.encryptionKey = env.ENCRYPTION_KEY || env.JWT_SECRET;
}
  • 如果 ENCRYPTION_KEY 设置了,就用它;
  • 否则退回 JWT_SECRET

创建账号:

const normalizedSecret = secret.replace(/\s/g, '').toUpperCase();
const encryptedSecret = await encryptField(normalizedSecret, this.encryptionKey);

读取时:

secret: await decryptField(item.secret, this.encryptionKey) || ''

评价:

  • 正向肯定:
    • 数据库里的 TOTP secret 至少不是明文,落地是 AES-GCM 密文 + 随机 IV;
    • 解密需要掌握 ENCRYPTION_KEY(或 JWT_SECRET),即 CF 环境密钥。
  • 风险/建议:
    1. 密钥来源复用:ENCRYPTION_KEY 和 JWT_SECRET 共用
      • 如果 ENCRYPTION_KEY 没配,就用 JWT_SECRET。
      • 这会把两个安全域绑死:
        • JWT_SECRET 泄露 ⇒ 不仅会话可伪造,还能解开所有 TOTP secret;
        • ENCRYPTION_KEY 泄露 ⇒ 所有 TOTP secret 暴露,同时 JWT 也在同一个环境(虽然算法不同,但“高价值钥匙全在一处”)。
      • 建议:
        • 强制要求配置独立的 ENCRYPTION_KEY(高熵随机);
        • 不推荐回退到 JWT_SECRET 做加密密钥,至少生产模式禁止。
    2. “fast mode” 没有 per-row salt
      • 备份文件是 PBKDF2+salt,DB 加密则是 AES-GCM(key=SHA256(masterKey), iv=random)
      • 如果 masterKey 足够长随机,这个问题不算致命 —— 攻击者即使拿到 DB dump,也得先拿到 Cloudflare 环境变量才能解。
      • 但从纯密码设计角度,还是更建议:
        • 强制 ENCRYPTION_KEY 高熵;
        • 或者在 encryptData 里加一个 per-record salt(当前 decryptData 代码已经兼容带 salt 的老格式)。

身份认证 & 会话安全

相关文件:

  • src/features/auth/authService.ts
  • src/shared/utils/crypto.ts (JWT)
  • src/shared/middleware/auth.ts
  • 各 provider:src/features/auth/providers/*.ts

JWT 实现

generateSecureJWT / verifySecureJWT

  • 自己实现了 HS256 JWT(header+payload base64url,HMAC-SHA256 签名)的逻辑;
  • payload 里加了 iat / exp / jti
  • 验证时:
    • 重新 HMAC 验签;
    • 解析 payload;
    • 检查 exp 过期。

优点:

  • 不依赖第三方 JWT 库,减少依赖风险;
  • 用的是 WebCrypto HMAC-SHA256,签名验证本身 OK;
  • 不信任 header 里的算法,直接按 HS256 verify,不存在典型 alg=none / RS256->HS256 混淆。

注意点:

  • 它是“自用”token(只在服务端生成和验证),不接受外部 JWT,因此攻击面还好;
  • 但前提是你绝对不要在别的系统里用同一个 JWT_SECRET 去生成其它用途的 token。

会话存储 & CSRF

src/shared/middleware/auth.ts

const token = getCookie(c, 'auth_token');
const csrfCookie = getCookie(c, 'csrf_token');
const csrfHeader = c.req.header('X-CSRF-Token');

if (!token) 401;
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) 403;

const payload = await verifySecureJWT(token, c.env.JWT_SECRET);
if (!payload || !payload.userInfo) 401;

c.set('user', payload.userInfo);
  • 会话 token 放在 auth_token Cookie;
  • CSRF: Double Submit Cookie 模式:
    • Cookie 里存一个 csrf_token
    • 前端必须在请求头带 X-CSRF-Token,两者一致才过;
  • 只要所有敏感 API 都挂了 authMiddleware,CSRF 防护是到位的。

我看了 Vault 路由:

src/features/vault/vaultRoutes.ts

vault.use('/*', authMiddleware);

说明:

  • 所有 /api/vault/* 都经过 JWT + CSRF 检查;
  • 相当于你的 2FA 数据所有读写、导入导出,都需要合法 session + 正确 CSRF token,基础 OK。

TODO / 风险点:

  • 没有检查其它模块(backup / tools / telegram)的路由是否都加了 authMiddleware,这里没完全展开,但 Vault 这块是 OK 的;
  • 如果有新路由忘记加 authMiddleware,就可能变成“未授权导出/导入”等问题 —— 这需要作者在代码上持续保持 discipline。

OAuth 登录 & 白名单控制

src/features/auth/authService.ts

async handleOAuthCallback(providerName, body) {
    const provider = getOAuthProvider(providerName, this.env);

    // Telegram 特别处理,其它 provider 用 code
    ...
    const userInfo = await provider.handleCallback(params, body.codeVerifier);

    // 白名单检查
    this.verifyWhitelist(userInfo, provider.whitelistFields);

    // 生成系统 token
    const token = await this.generateSystemToken(userInfo);
    const deviceKey = await this.generateDeviceKey(userInfo.id);

    return { token, userInfo, deviceKey };
}

白名单逻辑:

private verifyWhitelist(userInfo, whitelistFields) {
    const allowedUsersStr = this.env.OAUTH_ALLOWED_USERS || '';
    const allowedIdentities = allowedUsersStr
        .split(',')
        .map(e => e.trim().toLowerCase())
        .filter(Boolean);

    ...

    if (allowedIdentities.length > 0 && this.env.OAUTH_ALLOWED_USERS !== this.env.JWT_SECRET) {
        ...
        if (!isAllowed) throw new AppError('Unauthorized user...', 403);
    }
}

这里有个有点“hacky”的设计:

  • 只有在:
    • OAUTH_ALLOWED_USERS 非空,且
    • OAUTH_ALLOWED_USERS !== JWT_SECRET
      的时候才真正启用白名单;
  • 这明显是为了某种“开发模式”方便(比如把 OAUTH_ALLOWED_USERS 配成一个特殊值等于 JWT_SECRET 时,禁用白名单)。

安全性评价:

  • 导致两个风险:
    1. 配置失误时,白名单悄悄失效
      • 如果你不小心把 OAUTH_ALLOWED_USERS 填成了和 JWT_SECRET 一样的值(或者脚本复制错了),白名单逻辑直接 bypass,任何通过 OAuth 的用户都可以登录 2FA 管理器。
    2. 模糊了配置语义:
      • 正常使用者不会想到“把白名单 env 值填成和 JWT_SECRET 一样就等于关掉白名单”这种逻辑。

建议:

  • 生产环境强烈建议:
    • OAUTH_ALLOWED_USERS 明确填 email/username 列表;
    • 不要使用 “等于 JWT_SECRET 即关闭白名单” 这种隐式开关。如果要 dev 模式,应该显式用 DEV_ALLOW_ALL=true 之类 env;
    • 部署前最好自己写个小脚本或 checklist 确认:
      • OAUTH_ALLOWED_USERS 非空;
      • 且与 JWT_SECRET 不相等。

TOTP 逻辑 & 输入校验

src/shared/utils/totp.ts

  • Base32 校验:validateBase32Secret: /^[A-Z2-7]+=*$/.test(cleaned) && cleaned.length >= 16;
  • TOTP 生成:自己实现 HMAC-SHA1 和动态偏移,逻辑符合 RFC 6238。
  • otpauth:// 解析:// parseOTPAuthURI: - 有长度上限 1000,避免超长 payload - protocol 必须 otpauth: - type 必须 totp 或 hotp - secret 必须通过 Base32 校验 - digits 有范围,period 有范围

在 vaultRoutes 里还有一个“宽松版 add-from-uri”,专门给扫码用,解析更宽松,但创建账号时仍然会经过 createAccount 的 Base32 校验,这层是 OK 的。


备份 & 外部存储(WebDAV / S3)

这里只部分看了 DB 和 crypto 侧:

  • 备份提供方表 backupProviders 里,存的是 加密后的 config JSON 和自动备份密码等;
  • 说明外链存储用的敏感配置(WebDAV/S3 密码)也会用和 vault 相同的加密机制保护;
  • 风险和 vault 一样:ENCRYPTION_KEY 管不好 = 所有云备份配置 + TOTP secret 一起暴露

潜在风险 & 需要你自己权衡的点

  1. Cloudflare 账号是“单点失败”
    • Workers 代码、环境变量、D1 数据库都在 CF 账号下;
    • 一旦 Cloudflare 账号被接管,攻击者可以:
      • 查看/篡改 ENCRYPTION_KEY / JWT_SECRET;
      • 改 Worker 代码植入后门;
      • dump D1 数据库;
    • 对等于你的 2FA 密钥全部暴露。
    • 所以要当成“密码管理器 + Cloudflare 运营商”的组合风险来看:
      • CF 账号必须开强 2FA;
      • 尽量用单独的 CF 账号,只跑这一个 Worker,减少 blast radius。
  2. 前端 XSS = 直接 Game Over
    • 后端会把解密后的 secret 返回给前端(否则你没法生成 OTP),这点没法避免;
    • 只要前端存在 XSS(包括第三方脚本引入、富文本渲染等),攻击者可以:
      • 在已经登录的浏览器里直接读取所有 secret;
      • 或者窃取 JWT + CSRF token,远程用 API dump 整个 vault。
    • 后端通过 CSP / secureHeaders 已经做了一些防御,但真正的 XSS 风险还得看前端代码有没有 v-html / 动态注入脚本之类,这里没有完全审。
  3. 配置失误导致的“白名单失效 / 密钥复用”
    • ENCRYPTION_KEY 缺失时用 JWT_SECRET;
    • OAUTH_ALLOWED_USERS==JWT_SECRET 时白名单不生效;
    • 这些设计对“懂的人”来说是 feature,对搞运维的人来说是坑:
      • 若你用 GitHub Actions 自动注入 env,写错一次,整个系统可能就变成“任何人能登录 + 所有 2FA 密钥都只靠一个弱 JWT_SECRET 保护”。

给你的实际建议(如果你真考虑用)

如果你打算评估“可不可以自己用 / 推荐给别人用”,建议按下面 checklist 看:

  1. 只考虑自用 / 小范围使用,不当成公共 SaaS
    • 面向互联网开放但只白名单你自己的 GitHub/Google/Telegram,用它保护你自己的其他服务 2FA,是一个合理 use case;
    • 不建议拿这套去做公开的“2FA 托管服务”,那是完全不同的威胁模型。
  2. 部署前的强制配置:
    • JWT_SECRET
      • 至少 32 字节随机(openssl rand -hex 32);
      • 不和任何别的系统共享;
    • ENCRYPTION_KEY
      • 单独生成,同样高熵;
      • 和 JWT_SECRET 不同;
    • OAUTH_ALLOWED_USERS
      • 填你的 email / username 列表;
      • 确认它和 JWT_SECRET 不相等;
    • Cloudflare:
      • 开 2FA;
      • API Token 只给 D1 + Workers 最小权限。
  3. 部署后做两件事:
    • 代码级验证(你可以自己 grep 一下):
      • 确认所有 /api/vault/api/backups/api/tools 等敏感路由都 use(authMiddleware)
    • 手工 smoke 测试:
      • 不登录时访问 /api/vault,应该 401;
      • 用浏览器跨站表单 POST 尝试写入,看没有 X-CSRF-Token 的请求会被 403 拒绝。
  4. 把它当成“半成品密码管理器”:
    • 一旦前端有 XSS,或 Cloudflare 账号被拿下,你的 2FA 秘钥都算泄露;
    • 所以你自己的安全标准要拉高:
      • 定期备份导出(加密导出);
      • 定期 rotate ENCRYPTION_KEY(要迁移数据,这个项目目前没现成迁移逻辑,/migrate-crypto 已经弃用);
      • 重要账号的 2FA,可以考虑仍然保留一份硬件 token / 另一家 2FA app 做备份。
正文完
 0
评论(没有评论)