跳到主要内容

加密提醒

开启端到端加密后,发送端在发送前加密提醒的标题、正文和链接,只有持有频道密钥的设备才能读取。Gregor 服务器保存并投递密文,但永远看不到内容。

服务器不保存、不分发、不验证也不恢复频道密钥。你需要自己把密钥复制给每个发送端和设备,没有密钥的设备无法读取这些提醒。

  1. 在频道页打开频道的操作菜单,选择频道设置。
  2. 在端到端加密下打开要求加密消息并保存。此后频道会以 encryption_required 拒绝明文提醒。
  3. 重新打开频道设置,选择管理频道密钥,再选择生成密钥。
  4. 选择复制密钥,把密钥交给发送端和其他需要读取提醒的设备。在这些设备上打开管理频道密钥,选择导入密钥。

不要求加密的频道同时接受明文和加密提醒。

导出的密钥格式如下:

ck1:<channel_id>:<key_version>:<key>

<key> 是 32 个随机字节,使用不带填充的 base64url 编码。请把整个字符串当作机密保管。

轮换密钥会生成版本号加一的新密钥。设备会保留旧版本,因此之前加密的提醒仍然可以读取。新密钥需要再次复制给发送端和设备,Gregor 不会替你分发。

频道设置控制加密提醒的投递和显示:

  • 加密消息投递:所有接收者、仅选中的接收者或排除选中的接收者。它控制加密提醒发给谁;服务器无法知道谁真正持有密钥。
  • 本机没有密钥时:显示占位消息表示收到了一条无法读取的加密提醒;从普通视图隐藏则把它隐藏起来。

要求加密的频道不支持心跳,因为心跳提醒由服务器生成,服务器无法加密。

命令行工具会替你完成下面所有步骤:设置 CHANNEL_KEY 后照常发送即可。要自己编写发送端,请按以下步骤操作。

用 XChaCha20-Poly1305 加密一个包含提醒内容的 JSON 对象:

{ "title": "备份失败", "body": "nas:退出码 2", "url": "https://nas.example.com" }

附加认证数据(AAD)是下面这个 JSON 对象的 UTF-8 编码。它的值必须与请求外层的字段一致,没有发送的字段用空字符串;AAD 不一致的提醒会被设备拒绝:

{
"schema": "alert-inbox.message-aad.v1",
"channel_id": "<channel_id>",
"key_version": 1,
"event_type": "backup.failed",
"event_subject": "host:nas",
"thread_key": ""
}

用加密结果代替 alert 发送,二进制值都使用不带填充的 base64url 编码。ciphertext 是密文字节加上 16 字节的认证标签:

{
"message_encryption": "e2ee",
"key_version": 1,
"level": "high",
"event": { "type": "backup.failed", "subject": "host:nas" },
"encrypted": {
"algorithm": "xchacha20-poly1305",
"nonce": "<24 个随机字节>",
"aad": "<AAD 字节>",
"ciphertext": "<密文和认证标签>"
}
}

级别、事件字段、thread key 和确认选项保持明文,这样服务器才能路由提醒并识别重复事件。不要在这些字段里放机密信息。

一个使用 PyNaCl 的完整 Python 发送端:

import base64, json, os, urllib.request
from nacl.bindings import crypto_aead_xchacha20poly1305_ietf_encrypt
def b64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def unb64(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
_, channel_id, version, key = os.environ["CHANNEL_KEY"].split(":")
event = {"type": "backup.failed", "subject": "host:nas"}
aad = json.dumps({
"schema": "alert-inbox.message-aad.v1",
"channel_id": channel_id,
"key_version": int(version),
"event_type": event["type"],
"event_subject": event["subject"],
"thread_key": "",
}).encode()
nonce = os.urandom(24)
content = json.dumps({"title": "备份失败", "body": "nas:退出码 2"}, ensure_ascii=False, separators=(",", ":")).encode()
ciphertext = crypto_aead_xchacha20poly1305_ietf_encrypt(content, aad, nonce, unb64(key))
request = {
"message_encryption": "e2ee",
"key_version": int(version),
"level": "high",
"event": event,
"encrypted": {"algorithm": "xchacha20-poly1305", "nonce": b64(nonce), "aad": b64(aad), "ciphertext": b64(ciphertext)},
}
urllib.request.urlopen(urllib.request.Request(
os.environ["SEND_URL"], data=json.dumps(request).encode(),
headers={"Content-Type": "application/json"}, method="POST"))

每条提醒都要使用新的随机 nonce。

字段 要求
key_version 1 或更大,与所用密钥一致
encrypted.nonce 正好 24 字节
encrypted.aad 1 到 4096 字节
encrypted.ciphertext 17 到 4112 字节:内容加 16 字节的认证标签

不符合这些要求的信封会以 invalid_encrypted_message 拒绝;内容超过下面的大小限制时除外。

加密的内容,即包含 title、body 和 url 的 JSON 对象,最多 4096 字节。这是加密提醒内容的唯一限制:

  • alert.title、alert.body 和 url 的字符数限制不适用。服务器读不到内容,设备按解密结果原样显示。
  • 服务器按解码后的 ciphertext 长度减去 16 字节认证标签计算内容大小。
  • 超过时以 422 alert_too_long 拒绝,其中 field 为 encrypted.ciphertext,limit 为 4096,actual 为内容的字节数。

这个限制按字节而不是字符计算:一个 ASCII 字符占 1 字节,一个汉字占 3 字节,一个表情符号占 4 字节。把 JSON 编码为 UTF-8,不要用 \u 转义(示例中的 ensure_ascii=False),才能放下最多的文字。