Yoki

#security #web2 min read

在静态网站上实现端到端加密的日记

没有服务器,也能让私密内容只对自己可读。

做这个网站时,我想要一块只属于自己的角落。问题在于:网站是纯静态的,部署在免费的托管平台上,没有后端,也就没有传统意义上的“登录”。

答案是把加密挪到构建之前,把解密挪到浏览器里。服务器上只存放密文,钥匙只在我脑子里。

两道门

只靠一道门是不够的,所以这里有两道:

  1. 身份网关。/vault/ 路径前面挡着 Cloudflare Access,只有通过我自己邮箱验证的人才能拿到任何字节——包括密文本身。
  2. 端到端加密。即使网关被绕过、托管平台被入侵、仓库被公开,拿到的也只是一串无法阅读的字符。

流程

日记以 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

  1. 这也是为什么界面上会明确告诉你“密钥只存在于这个标签页的内存里”——让人知道边界在哪里,比让人觉得绝对安全更重要。 ↩

↑↓select↵openescclose