Shuoxun Wen GitHub

文章 SecureView

服务器读不了的文件分享

SecureView 里每个文件都在浏览器里加密,服务器手里没有任何能打开文件的密钥。在这个前提下怎么分享文件,忘了 passphrase 又怎么办。

SecureView 是我们团队在 Monash 做的毕业设计,一个文件分享平台,已经上线,合并的 203 个 PR 里有 182 个是我写的。它背后的规则说起来很简单:文件在浏览器里加密,服务器收不到任何能解密它的东西。文件本身、文件密钥、用户私钥、passphrase,都收不到。

规则好说,加上分享、忘记 passphrase、会话被盗这几种情况之后就难守了。先说这条规则带来的好处。

这样做有什么用

1. 服务器上只有加密后的文件。 服务器上没有原文件,也没有能打开文件的密钥。运营 SecureView 的人看不到用户的文件,就算服务器被攻破、数据库被整个拷走,拿到的也只是密文和封装好的密钥。没有每个用户自己的 passphrase,这些都没用。

2. 账号被盗不等于文件被盗。 假设别人拿到了你的登录密码并登录进来,对方能看到你的文件列表,但读不到任何内容。预览不了,因为服务器上根本没有原文件可以给对方看。下载原文件和分享也做不到,因为这两件事都要用你的私钥,而私钥只有你的 passphrase 能解开。每次下载、每次分享,浏览器都会重新要 passphrase。对方最多只能下载到加密后的文件。

下面讲这个设计怎么守住这两点,以及它管不到的地方。

两个密码,不是一个

每个账号有两个互相独立的秘密:

  • 登录密码用来向服务器证明你是谁。服务器存它的 Argon2id 哈希,和普通登录一样。
  • 加密 passphrase 从不离开浏览器。它解锁账号的私钥,私钥再解锁你的文件。

两者分开,上面第 2 点才成立。这也是忘了登录密码可以放心用邮件重置的原因:别人就算拿下了你的邮箱,也只能拿到会话,拿不到文件。

密钥链

注册时所有密钥都在浏览器里生成,服务器只收到封装好的副本:

passphrase  --Argon2id-->       KEK(密钥加密密钥)
KEK         --AES-256-GCM-->    打开 AMK(账户主密钥,随机 256 位)
AMK         --HKDF-SHA256-->    打开 RSA 私钥(默认 3072 位)
私钥        --RSA-OAEP-->       打开每个文件的 DEK(数据加密密钥)
DEK         --AES-256-GCM-->    解密文件

服务器存的是包装后的 AMK、加密后的私钥、公钥,以及 Argon2id 的盐和参数。这些全拿到,没有 passphrase 也打不开任何东西。注册前浏览器还会自检:把刚封装好的东西重新解开,确认私钥和公钥是一对。这一步服务器做不了,因为它解不开。

每次上传都生成一个新的随机 DEK。浏览器用它加密文件(默认 AES-256-GCM,用户也可以选另外五种 AEAD 算法),然后用 RSA-OAEP-SHA256 把 DEK 包装两次:一次给文件所有者的公钥,一次给一把私钥离线保管的恢复密钥。服务器收到的是密文和两份包装后的 DEK。下载时服务器把密文原样传回,由浏览器解密。

分享就是重新包装

要把文件分享给 Bob,服务器在密码学上帮不上忙,因为它没有 DEK。所以由发送方的浏览器来做:

  1. 取 Bob 的公钥,用公钥本身算出指纹。
  2. 显示 Bob 的名字和指纹,如果和上次分享给 Bob 时不一样就警告。
  3. 解锁发送方的私钥,解开这个文件的 DEK。
  4. 用 Bob 的公钥重新包装同一个 DEK,只把这个发给服务器。

服务器负责检查权限:发送方要对这个文件有 SHARE 权限,Bob 拿到的权限不能超过发送方自己有的。然后在一个事务里写入 Bob 的授权、包装后的 DEK 和一条审计记录。文件本身不用重新加密。

第 2 步存在是因为公钥是服务器发的。如果服务器把 Bob 的公钥换成自己的,分享给“Bob”的东西它就都能读。指纹警告只能在第一次分享之后起作用,而且要大家真的去和 Bob 在设置页看到的指纹对一下。这是真实存在的局限,文档里也写明了。

忘了 passphrase

“服务器不拿密钥”到这里开始有代价。passphrase 忘了,AMK 就没了,服务器上没有任何东西能把它找回来。

出路是注册时浏览器只显示一次的私钥文件。这个文件本来就能打开账号能访问的所有文件,所以让持有它的人设置新的 passphrase,并没有给他任何新能力。重置的过程是:

  • 在浏览器里读取密钥文件,确认它和账号的公钥匹配
  • 生成一个新的 AMK,用新 passphrase 包装它,再用它重新加密同一把私钥
  • 发送之前,先在本地把两个新信封重新打开一遍

密钥对不变,所以所有文件、分享和包装过的 DEK 都继续有效,什么都不用重新加密。passphrase 和密钥文件都丢了,文件就永远打不开了。注册页上用大白话写明了这一点。

被盗的会话不能换掉密钥

服务器没法检查新信封能不能打开,因为它从来见不到 passphrase 和 AMK。如果没有别的检查,拿到被盗会话的人可以上传一堆垃圾信封,让主人再也打不开任何文件。

所以替换信封必须证明持有私钥。服务器返回一个摘要,绑定账号、密钥、两个预期 revision,以及两个新信封的每一个字段。浏览器用私钥签名(RSA-PSS-SHA256),服务器先验签再写入:

POST /keys/me/reset-protection/signing-payload  ->  32 字节摘要
浏览器用账户私钥签名
POST /keys/me/reset-protection(信封 + 签名)
  别的密钥签的名   ->  422,什么都不改
  revision 过期    ->  409

每种需要签名的操作(重置、重新包装、添加密钥)在摘要里都有自己的域标签,一种操作的签名不能拿去重放成另一种。重置上线之后,记得旧 passphrase 时修改 passphrase 也走同一条路径。

服务器还能看到什么

密钥托管不等于什么都藏住了。用盗来的密码登录的人虽然读不了任何文件,但删除文件、取消分享这些普通的账号操作还是能做。服务器仍然能看到:

  • 文件名、MIME 类型和准确的文件大小
  • 谁在什么时候把什么分享给了谁
  • Argon2id 信封。数据库被偷的话,弱 passphrase 可以被离线猜出来

针对最后一点,浏览器会拒绝常见的、有规律的、或者基本就是用户名的 passphrase,并要求至少 10 个字符。改过的客户端可以跳过这些检查,它们保护的是用户不选弱密码,不是保护服务器不受攻击。

一个主动作恶的服务器还可以返回旧的密文或者换掉的密钥包。这个设计做到的是 API 层面的零知识密钥托管,不能防住一个恶意的运营方。

小结

  • 把服务器绝不能收到的东西写下来,每个接口都拿这张清单对一遍。
  • 服务器验证不了的东西,就让客户端来证明,比如签名后才能替换信封。
  • 恢复方案要和加密一起设计,不能事后补。这里是用户自己保管的一个文件,文档写清楚了丢了会怎样。
  • 把局限写出来。一个安全声明说清楚了自己不管什么,反而更可信。

更多细节见 docs/SECURITY_FLOWS.md,浏览器端代码在 frontend/src/crypto/(share.ts、passphrase-reset.ts)。