# Design
# 加密方案
- 算法 AES-256-GCM。Node 14.16.1 的
crypto.createCipheriv('aes-256-gcm', key, iv)与浏览器 WebCryptoAES-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 个密钥。
- 选 HMAC 而非 HKDF:
- 主密钥
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.js的extendPageData读它填$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.js,router.prefix('/api/interview/doc'),由routes/的 require-directory 自动加载,无需改app.js。GET /api/interview/doc/key?documentId=<id>鉴权直接复用现有中间件:
checkInterviewLogin(middlewares/loginChecks.js)已经完成全部所需工作——校验fe-tokenheader 的 JWT(TOKEN_SECRET_KEY)、查interviewSiteUsers填ctx.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
- 会员:
主密钥走既有约定:
.env加PAID_DOC_SECRET,在conf/secretKeys.js中导出,与TOKEN_SECRET_KEY同级。不硬编码、不入库。派生函数新增到
utils/而不复用utils/cryp.js的encrypt/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 不能承担本接口的限流,两个独立问题:
- 它在
app.js:134是注释掉的,当前根本没挂载。 - 即便挂上也无效:
count/firstTime是模块级全局变量,所有用户所有接口共用一个计数器,且判断分支写反了——3 秒窗口内一律count++后直接next(),只有窗口过期后的第一个请求才可能被拒,随后计数即清零。既非按用户维度,也拦不住 140 次连续请求。
因此需为本接口单独实现按 ctx.interviewUserId 维度的限流(如 1 分钟 20 次),存储可用 CloudBase 或进程内 LRU。
已付费会员循环请求取得全部密钥是本档方案的固有上限,限流只降低批量导出速度,不改变性质。
# 组件与状态机
StaticInterviewDocument.vue从require.context同步取document.html改为:- SSR 阶段与首帧渲染
preview(window/crypto.subtle访问需typeof window !== 'undefined'守卫) - 客户端 mounted 后请求密钥、WebCrypto 解密、替换为全文
- SSR 阶段与首帧渲染
Page.vue的getPageData()现以.static-interview-document的innerHTML.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。