安全
PKU harness Cloud 怎样让服务器读不到用户数据:设备密钥对、带工作量证明的注册、短时效的签名插件 token、XChaCha20-Poly1305 信封、放在 URL 片段里的分享链接密钥、只能认领一次的通道,以及分享链接脱敏。
云服务的目标是让服务器只能转发和保存它读不懂的密文,并且不知道用户是谁。本页依次说明实现这一目标的各个机制:设备怎样获得身份,怎样换取 token,数据怎样加密,分享链接怎样保护密钥,以及还存在哪些无法完全消除的风险。
设备身份
每台设备在第一次使用云服务时生成两对密钥。私钥保存在系统密钥库里,只能在本机使用,并且要在设备首次解锁后才能读取。
| 密钥 | 算法 | 用途 |
|---|---|---|
| 签名密钥 | Ed25519 | 签名 token 挑战 |
| 加密密钥 | X25519 | 密钥协商 |
服务器只通过这两把公钥认识设备,不知道学号、姓名或手机号。「设置 → 云服务」会显示签名密钥的指纹,并提供重置身份和删除云端数据两个操作。删除云端数据会调用 DELETE /v1/devices/me,App 在完全重置之前也会调用这个接口。
注册时的工作量证明
注册设备(POST /v1/devices)不需要账号,但设备要先完成一次工作量证明(proof of work)。工作量证明是一道需要一定计算量才能解出的题,验证答案却很快。这样,一次注册的代价很小,批量伪造大量身份的代价就很高。
另一种做法是让服务器验证请求确实来自官方 App,但这会让自己编译的 APK 无法使用,所以我们没有采用。
题目的定义如下:
prefix = UTF-8("pku-harness-cloud/v1/pow\n" + salt + "\n" + signPub + "\n" + boxPub + "\n")
hash = SHA-256(prefix ‖ UTF-8(nonce)) # nonce:十进制,无前导零,最多 15 位
valid = hash has ≥ difficulty leading zero bits # 哈希值的前导零位数不少于 difficultysalt 和 difficulty 来自 GET /v1/meta,服务器同时接受当前和上一个 salt。默认难度为 16,在手机上大约要算一秒。客户端会拒绝高于 28 的难度,因此恶意服务商无法让手机无休止地计算。两把公钥都写进了前缀,所以一个解只能注册一个身份。
插件 token
插件发出的每个请求都带一个 bearer token。一个 token 只对应一台设备、一个插件和一组服务,有效期为 10 分钟。设备用 Ed25519 签名密钥签名下面这段挑战,以此换取 token:
pku-harness-cloud/v1/auth
<aud> 服务商的源,小写的 scheme://host[:port]
<deviceId>
<plugin>
<services> 去重、排序后用 "," 连接
<ts> Unix 毫秒
<nonce> 16 个随机字节,base64url 编码调用服务端函数用的 token 还会多一行 doc <hash> <link>,记录用户同意过的插件文档版本。
这段挑战的各部分分别防范不同的攻击:
aud防止某个服务商把签名拿到另一家服务商那里重放。- 服务器只接受满足
|now − ts| ≤ 5 min的请求,并拒绝在这个时间窗口内重复出现的 nonce。 - 存储的命名空间和通道的归属都取自 token 里的
sub和plg,从不取自请求路径,因此一个插件碰不到另一个插件的数据。 - 插件永远看不到 token。token 由宿主签发,并由宿主附加到请求上。
信封
所有加密数据,无论是在传输中还是保存在服务器上,都使用同一种格式,我们称之为「信封」:
{ "v": 1, "n": "<24-byte nonce, base64url>", "c": "<ciphertext ‖ 16-byte tag, base64url>" }信封使用 XChaCha20-Poly1305 算法和随机生成的 24 字节 nonce。加密时可以附带关联数据,例如通道 id 或帧类型,解密方必须提供相同的关联数据才能解开。
这套加密只实现了一份,位于 @pku-harness/cloud-protocol,基于经过审计的 noble 库(纯 JavaScript)。App(运行在 Hermes 上)、参考服务器(运行在 Bun 上)和查看页(运行在没有 WebCrypto 的微信内置浏览器里)用的都是这一份实现。
分享链接与通道
分享链接的格式如下:
https://<provider>/s/<channelId>#k=<base64url 32 bytes>链接里的密钥 k 放在 URL 片段(# 之后的部分)里。浏览器从不把片段发给服务器,所以服务器拿不到 k。设备和查看者从 k 派生出两把密钥:
authKey = HKDF-SHA256(k, "pku-harness-cloud/v1/join"),用来加入通道。服务器只保存joinHash = base64url(sha256(authKey)),因此它能校验加入请求,自己却无法加入。encKey = HKDF-SHA256(k, "pku-harness-cloud/v1/enc"),永远不离开设备。它用来加密通道的meta,并参与派生每个对端的密钥。
查看者加入时,发送 authKey 和一把临时生成的 X25519 公钥。此后,机主和这个对端之间的数据帧使用下面的密钥加密:
HKDF-SHA256(ikm = X25519(own, other) ‖ encKey, salt = chanPub ‖ peerPub, info = "pku-harness-cloud/v1/peer")通道还有以下保护:
- 认领:
maxPeers默认为 1。第一个加入的人认领通道,之后再来的人会收到409 conflict,所以转发出去的链接没有用。机主可以踢掉对端来释放名额。 - 服务器无法验证的检查,例如打码的姓名提示和口令,都在机主的设备上完成。服务器只限制加入频率(每个 IP 每分钟 20 次),并在
authKey错误 5 次后锁定通道。
不让 AI 模型接触分享链接
分享链接里含有解密密钥,所以 App 不让它进入 AI 模型的上下文:
- SDK 中的
SecretLink是不透明对象,JSON.stringify(link)只会得到"[secret link]"。 - 宿主会从工具输出和日志里抹掉符合分享链接格式的字符串(
/s/<id>#k=…、pkuharness://share/<id>#k=…),具体实现见scrubShareLinks。 - 工具从不返回链接。页面通过
host.shareLink或<QRCode link>把链接展示给用户。
限额
限流按 (deviceId, plugin, service) 和按 IP 分别使用令牌桶算法,当前状态写在 RateLimit-* 响应头里。服务器返回 429 时,宿主会抛出 CloudError('rate_limited', retryAfterS)。默认值如下:
| 资源 | 默认值 |
|---|---|
| 存储 | 每台设备每个插件 1 MB、1,000 个键,单个值 64 KB |
| 通道 | 每台设备同时开 5 个,有效期 60 秒到 24 小时(默认 15 分钟),最多 5 个对端,每帧 4 KB,每分钟 60 帧,64 个信箱槽位 |
| 加入通道 | 每个 IP 每分钟 20 次 |
| 推送(计划中) | 100 个待发的定时推送 |
| 服务端函数(官方服务器不开启) | 每分钟 60 次调用 |
剩余风险
上面的机制不能消除所有风险。下面三类风险仍然存在。
元数据。服务商能看到请求时间、数据大小和 IP 地址。插件的数据说明里必须写明这一点。
查看页运行的是服务商提供的 JavaScript。恶意运营者可以提供一个泄露 k 的页面,从而看到那一次分享期间通道里传输的内容,但拿不到校园凭据。我们用三种方式降低这一风险:查看页是静态页面,由插件仓库可复现地构建;页面带有严格的内容安全策略(Content Security Policy,CSP),即 connect-src 'self';装了 App 的查看者通过深链接在 App 内解密,完全不运行服务商的 JavaScript。
服务端函数按设计会处理明文。它们必须在 PLUGIN.md 里声明并写明数据说明,经用户同意后才能使用,服务器也只运行用户同意过的那个文档版本。官方服务器不开启这项功能。