# Design

# 加密方案

  • 算法 AES-256-GCM。Node 14.16.1 的 crypto.createCipheriv('aes-256-gcm', key, iv) 与浏览器 WebCrypto AES-GCM 双端都原生支持,无需新依赖。
  • 每篇独立 12 字节随机 IV,16 字节认证标签。IV 与标签不是秘密,与密文同存于 JSON。
  • 每篇独立密钥,派生自主密钥:key = HMAC-SHA256(masterSecret, documentId),输出 32 字节正好是 AES-256 密钥长度。
    • 选 HMAC 而非 HKDF:crypto.hkdfSync 自 Node 15 起才有,本项目基线是 Node 14.16.1。
    • 一篇密钥泄露不影响其余 139 篇;服务端只需保存主密钥,无需维护 140 个密钥。
  • 主密钥 PAID_DOC_SECRET.env(已在 .gitignore 中),构建时由环境变量注入生成脚本;后端持有同一份主密钥做相同派生。
  • 缺失 PAID_DOC_SECRET 时生成脚本直接报错退出,不静默降级成明文。

# 产物结构

.vuepress/generated-static-docs/<id>.json{ html, headers } 改为:

{
  "v": 1,
  "headers": [...],          // 明文,config.js 的 extendPageData 依赖
  "preview": "<html>",       // 明文预览段,承担 SEO
  "cipher": { "iv": "b64", "tag": "b64", "data": "b64" }
}
  • headers 保持明文:config.jsextendPageData 读它填 $page.headers,侧边栏锚点与 sidebarDepth: 4 依赖。目录结构泄露可接受,且对 SEO 有利。
  • v 字段用于新旧形态共存与回退,组件同时识别 v 缺失(旧 { html, headers })与 v: 1

# 预览段切分

  • 在生成脚本切,不在客户端切。客户端切的前提是先有全文,与本方案矛盾。
  • 规则:对渲染后 HTML 按顶层节点遍历累加,纯文本长度达到 1200 字符即在节点边界停止,硬上限 2500 字符。绝不在标签中间截断。
  • 首个标题连同其后正文一并纳入,保证预览段自身语义完整。
  • 对照:现有 slice(0, 25000) 是按 HTML 字符切,而单篇 JSON 的 html 长度多在 17000 上下,即现有"预览"对多数文章等于全文,这本身就是漏洞的一部分。

# 密钥下发接口

后端仓库 interview-koa2-backend(Koa 2 + koa-router 7,CommonJS,CloudBase 文档库)。

  • 新增 routes/interviewClient/doc.jsrouter.prefix('/api/interview/doc'),由 routes/ 的 require-directory 自动加载,无需改 app.js

  • GET /api/interview/doc/key?documentId=<id>

  • 鉴权直接复用现有中间件checkInterviewLoginmiddlewares/loginChecks.js)已经完成全部所需工作——校验 fe-token header 的 JWT(TOKEN_SECRET_KEY)、查 interviewSiteUsersctx.interviewUserInfo,并算好:

    ctx.isVip = Number(dayjs(interviewUserInfo.expiredDate).diff(new Date(), 'd', true).toFixed(2)) > 0
                && interviewUserInfo.status == 2
    

    路由内只需照 routes/interviewClient/video.js:92 的既有先例做一次闸门判断,不需要新写任何鉴权逻辑。

  • 响应沿用站内既有形状(成功码是 200,不是 0,见 model/ResModel.js 与各 interviewClient 路由):

    • 会员:{ code: 200, data: { key: "<base64>", expireAt: <ts> } }
    • 非会员 / 已过期:{ code: -1, message: '...' }
    • 未登录:中间件直接返回 { code: 401, message: '您尚未登录' }
    • 客户端对 401 与 -1 一律按「无权限」处理,展示预览段与现有 PayLockedCard
  • 主密钥走既有约定:.envPAID_DOC_SECRET,在 conf/secretKeys.js 中导出,与 TOKEN_SECRET_KEY 同级。不硬编码、不入库

  • 派生函数新增到 utils/不复用 utils/cryp.jsencrypt/decrypt:后者是 aes-256-cbc + 全零固定 IV,不适用于本场景(固定 IV 会让相同明文产生相同密文)。本次只用 crypto.createHmac('sha256', PAID_DOC_SECRET).update(documentId).digest()

  • 前端侧经 theme/util/api.js 暴露 getDocumentKey,走现有 request.js 实例与 token 拦截器,不另起 axios。

  • 客户端缓存:内存 Map 为主,sessionStorage 为二级缓存,TTL 30 分钟,键为 documentId。避免站内连续翻页重复请求。

# 限流(现有设施不可用,需新建)

middlewares/interfaceLimit.js 不能承担本接口的限流,两个独立问题:

  1. 它在 app.js:134注释掉的,当前根本没挂载。
  2. 即便挂上也无效:count / firstTime 是模块级全局变量,所有用户所有接口共用一个计数器,且判断分支写反了——3 秒窗口内一律 count++ 后直接 next(),只有窗口过期后的第一个请求才可能被拒,随后计数即清零。既非按用户维度,也拦不住 140 次连续请求。

因此需为本接口单独实现按 ctx.interviewUserId 维度的限流(如 1 分钟 20 次),存储可用 CloudBase 或进程内 LRU。

已付费会员循环请求取得全部密钥是本档方案的固有上限,限流只降低批量导出速度,不改变性质。

# 组件与状态机

  • StaticInterviewDocument.vuerequire.context 同步取 document.html 改为:
    • SSR 阶段与首帧渲染 previewwindow / crypto.subtle 访问需 typeof window !== 'undefined' 守卫)
    • 客户端 mounted 后请求密钥、WebCrypto 解密、替换为全文
  • Page.vuegetPageData() 现以 .static-interview-documentinnerHTML.trim() 判就绪,并有 40 次与 20 次两处重试。改造后:
    • 预览段首帧即非空,就绪判定不再依赖解密完成,showDocumentLoading 不会卡在永久骨架屏
    • 解密成功后走一次 schedulePageData() 重算,复用现有 requestAnimationFrame 时序
    • 删除客户端 contentHtml.slice(0, 25000) 预览逻辑,预览已由产物提供
  • 两个已在源码注释中记录的历史坑必须回归验证:永久骨架屏、空壳被当作预览内容。

# 降级路径

情形 表现
未登录 / 非会员 预览段 + PayLockedCard,与现状一致
密钥接口超时或 5xx 预览段 + 可重试提示,不白屏、不永久骨架
解密失败(密文与密钥不匹配) 预览段 + 错误提示,上报自有埋点
WebCrypto 不可用(非安全上下文、老浏览器) 预览段 + 升级浏览器提示

生产域名为 HTTPS,localhost 亦属安全上下文,WebCrypto 在目标环境均可用;该分支只作兜底。

# 仓库卫生

  • .gitignore 新增 .vuepress/static-doc-sources/:该目录是生成脚本留的可审核明文全文,不入库。
  • .vuepress/generated-static-docs/ 加密后只含密文、预览与 headers,可以入库,且必须入库——CI 构建不跑同步脚本(同步脚本依赖同级 interview-website-next 仓库,CI 环境没有)。
  • 明文形态期间该目录不得提交。

# 回退方案

  • 生成脚本读 PAID_DOC_ENCRYPT 环境变量,置 0 时输出旧形态 { html, headers }(不含 v)。
  • 组件与 config.js 同时兼容两种形态,回退只需翻开关、重跑同步脚本、重新构建,无需改代码。
  • 密钥轮转:更换 PAID_DOC_SECRET 后必须同时重跑生成脚本并更新后端主密钥,两侧不同步会导致全站付费页解密失败——上线顺序在 tasks 中固定。

# 兼容性

  • Vue 2 Options API,不引入 Tailwind / React / TypeScript / 新运行时依赖。
  • 解密用浏览器原生 WebCrypto,不引入 crypto-js 一类库。
  • 生成脚本用 Node 内置 crypto,兼容 Node 14.16.1。
webapp
公众号
开发者导航
切换夜间模式
折叠侧边栏
收起全部