做这个网站时,我想要一块只属于自己的角落。问题在于:网站是纯静态的,部署在免费的托管平台上,没有后端,也就没有传统意义上的“登录”。
答案是把加密挪到构建之前,把解密挪到浏览器里。服务器上只存放密文,钥匙只在我脑子里。
两道门
只靠一道门是不够的,所以这里有两道:
- 身份网关。
/vault/路径前面挡着 Cloudflare Access,只有通过我自己邮箱验证的人才能拿到任何字节——包括密文本身。 - 端到端加密。即使网关被绕过、托管平台被入侵、仓库被公开,拿到的也只是一串无法阅读的字符。
流程
日记以 Markdown 写在本地的 private/journal/ 里,这个目录被 .gitignore 排除,还有一个提交前的钩子兜底。写完后运行:
npm run vault:seal
脚本会读取全部日记,用口令派生出密钥,把它们整体加密成一个 data.json。标题、日期、篇数都在密文里——只加密正文、却把“3 月 14 日 和 A 吵架了”留在列表上,等于什么都没做。
密钥派生
口令不能直接当密钥用。它需要经过一个刻意很慢、很耗内存的函数,让暴力破解的每一次尝试都变得昂贵:
const raw = await argon2id({
password: passphrase.normalize('NFC'),
salt, // 16 字节随机盐,每次加密重新生成
iterations: 3,
memorySize: 65536, // 64 MiB:显卡并行破解的主要障碍
parallelism: 1,
hashLength: 32,
outputType: 'binary',
});
const key = await crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['decrypt']);
在我的电脑上,解锁一次大约需要半秒。对我来说无感,对逐个猜口令的人来说则是噩梦。
参数一览
| 环节 | 选择 | 理由 |
|---|---|---|
| 密钥派生 | Argon2id,64 MiB × 3 轮 | 内存困难,抗 GPU / ASIC |
| 对称加密 | AES-256-GCM | 自带完整性校验,浏览器原生支持 |
| 附加认证数据 | 全部参数 + 盐 + IV | 任何参数被篡改都会解密失败 |
| 长度隐藏 | 补齐到 16 KiB 的整数倍 | 不从文件大小泄露写了多少 |
还剩什么风险
密码学通常不是最薄弱的一环,人才是1:
- 弱口令。所以脚本拒绝少于 14 个字符的口令,我用的是 6 个随机单词。
- 忘记上锁。页面闲置 15 分钟会自动上锁,离开页面也会立刻清空内存中的内容。
- 误提交明文。
.gitignore加提交钩子,两层拦截。 - 忘记口令。没有任何找回机制——这本来就是设计目标。口令要单独、妥善地记下来。
做完这些,我对这块角落的信任程度,大概和对一本锁在抽屉里的日记本差不多。也许还要更高一点:至少日记本不会被复制一万份拿去慢慢试钥匙。
Notes
-
这也是为什么界面上会明确告诉你“密钥只存在于这个标签页的内存里”——让人知道边界在哪里,比让人觉得绝对安全更重要。 ↩