Files
svfs/docs/encryption-design.md
chakcy 1620d3eb5a docs: 补 README 能力章节、缺陷报告与两份设计说明
- README:新增「数据能力」四节(内容寻址去重 / 快照与时间旅行 / 完整性校验与签名 /
  增量打包)与「发布到对象存储」一节,附实测对比表;重写「要不要用 --compress」
  (结论从"别用"反转为"默认就该开")、「迁移旧格式」;修正项目结构与字段表
- BUG_REPORT.md:13 节,含 P0/P1 逐条分析、P2/P3 清单、五批后续追加的记录,
  以及过程中被推翻的两处结论(第九节的压缩建议、P3-1 的 uv sync 判断)
- docs/encryption-design.md:加密的设计说明——去重与随机 nonce 的冲突、
  标准库没有密码学原语、以及现有 freq_blob 可作为每块附加数据的槽位
- docs/evidence-*.txt:各阶段的测试运行证据(修复前 / 修复后 / 双环境)
2026-09-14 00:11:53 +08:00

5.7 KiB
Raw Permalink Blame History

加密设计说明(写代码之前必须先定的两件事)

这份说明是给"后面考虑加加密"准备的。它不实现任何东西,只把两个必须先决定的 约束摆在前面——因为它们都会反过来改数据结构,等到写完再发现就要返工。


一、去重与随机 nonce 是冲突的

这是最要紧的一条。当前设计里 blobs.hash 是 sha256(原始内容) 并且是主键, "相同内容只存一份"完全建立在这上面。加密会直接冲击它:

方案 nonce 怎么来 去重 代价
A. 确定性(收敛)加密 nonce 由 HMAC(key, 明文hash) 派生 保留 密文相同 ⇒ 泄露"这两份内容一样"(其实去重本来就泄露);可被确认攻击:拿到明文猜测的人能判断库里有没有这份内容
B. 每块随机 nonce os.urandom 包内保留,跨包丢失 同一份内容在不同包里密文不同 → 对象名(当前是明文 hash)不再能当 URL;vfsfetch 的"名字即 sha256"校验失效;同一个 CDN 路径可能返回不同字节
C. 按文件加密 每个文件一份 完全丢失 相同的两个文件各存一份密文,体积翻倍
D. 只加密容器/对象,不动 blob 不适用 保留 服务方(插件/CDN 之外的那一层)拿不到明文;本地 .svfs 仍是明文

我的建议

先明确你要防谁,答案不同、方案不同:

  • 防"存储方/传输方看到内容"(S3、CDN、对象存储被拖库)→ 选 D:发布前把对象加密 (或直接用带 SSE 的桶 + TLS),本地 .svfs 保持明文。代价最低,完全不动现有结构, 而且不影响去重与对象名。这是我最推荐的起点。
  • 防"本地库被拿走也读不出"(笔记本丢了、镜像层被人翻)→ 需要 A 或 B:
    • 如果你同时要"跨包去重 + 对象名稳定"(也就是现在的发布能力),只能选 A, 并接受确认攻击这个已知代价。这在"内容多半是公开前端资源"的场景里通常可以接受。
    • 如果你只要"包内去重",B 更安全,但发布能力要跟着改: 对象名不能再用明文 hash,manifest.json 里得同时记录明文 hash 与密文 hash, vfsfetch 改为校验"解密后再算 hash"(那就意味着客户端必须持有密钥)。

一句话:A 与现在的发布/去重能力兼容,B 与它不兼容。这个岔路口必须先选。


二、标准库没有密码学原语,所以"高效"指向一个依赖

Python 标准库只有 hashlib / hmac / secrets / os.urandom——没有任何分组密码。 所以:

选项 性能(量级) 评价
cryptography(pyca)的 AES-256-GCM 1~5 GB/s(AES-NI 硬件加速) 推荐。单一依赖、AEAD 一次过、有认证
同库的 ChaCha20-Poly1305 1~3 GB/s(纯软件即很快) 没有 AES-NI 的机器上更稳
纯 Python 实现 AES ~1 MB/s 不可用(慢三个数量级),且自己写密码学实现本身就是风险
用 HMAC-SHA256 / SHA-256 计数器模式自建流密码 + HMAC 认证 约 0.5~1.5 GB/s 构造上是成立的(PRF 派生 keystream + encrypt-then-MAC),但必须自己保证"同一密钥下计数器永不重复",且要正确实现认证——省一个依赖,多一份责任

"尽可能高效"的实际含义:用 AEAD(AES-GCM 或 ChaCha20-Poly1305), 而不是"自己写一个更快的"。自己写在 Python 里赢不了硬件指令。

顺带三条实现纪律:

  1. 先压缩再加密。压缩靠的是内容里的冗余,密文是随机-looking 的,压不动。 顺序反了等于白压。
  2. 认证必须做(AEAD 或 encrypt-then-MAC)。只加密不认证,攻击者可以翻动密文比特, 密文解出来就是"看起来合法但已被改动"的数据——而我们整个完整性体系(sha256 + 摘要
    • HMAC 签名)就是为了不让这种事发生。
  3. 密钥不要进库。库里的 signatures 表存的是 HMAC 签名,那是防篡改用的, 不是密钥保管。密钥从环境变量/密钥管理服务来。

三、现有结构对加密友好的地方

好消息是几乎不用改 schema:

  • blobs.freq_blob(BLOB)本质是一个通用的"每块附加数据"槽。它现在是哈夫曼频率表, 加密的 nonce + 认证标签可以放这里,不需要加列。
  • blobs.compress_algo(TEXT)现在存 'none' / 'zlib' / 'huffman'。加一档 'aes-gcm' 就走通了——compression.py 是唯一的算法分发点(见该模块 docstring 的契约说明)。
  • 如果"先压后加"需要同时表达两层,建议加一列 encrypt_algo 而不是把算法名拼成 'zlib+aes-gcm' 这种字符串:拼字符串会让 decompress_with 的分发逻辑变脆, 而且查询"这个包里加密了吗"要靠 LIKE。加列的成本是一次 v4 迁移(迁移机制已经在了, migration.py + vfsmigrate)。

四、建议的推进顺序

  1. 先只做 D(发布层的信封加密):不动 blob、不动去重、不动对象名。 验证一下"发布到不可信存储"这个需求是不是就满足了——大概率是。
  2. 如果确实需要"静态加密整个库",再做 A(确定性 nonce),并接受确认攻击; 这时要同步改:manifest.json 增加密文字段、vfsfetch 的校验时机、 以及"未授权方拿不到密钥就什么也读不出"的密钥分发方案。
  3. 无论走哪条,先给 compression.py 加一层算法链的契约测试 (tests/test_compression.py 已有骨架),保证加算法不破坏现有往返。