Zulip 移动端推送通知的端到端加密(E2EE)协议与载荷格式详解

发布时间:2026/9/11 22:07:12
Zulip 移动端推送通知的端到端加密(E2EE)协议与载荷格式详解 Zulip 移动端推送通知的端到端加密E2EE协议与载荷格式详解【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulipZulip Server 11.0 为移动端推送通知引入了端到端加密E2EE本文基于 Zulip 官方 API 文档 api_docs/mobile-notifications.md系统讲解 E2EE 推送协议中加密载荷encrypted payload的完整 JSON 格式、各类通知场景的字段语义以及 Zulip 推送通知服务mobile push notifications service在 FCM 与 APNs 两大平台上的实际下发数据结构。读完本文你将掌握 Zulip 服务端生成的加密载荷中每个字段的含义与适用场景、自托管服务器与官方推送服务之间的交互格式并能对照源码 zerver/lib/push_notifications.py 与 zilencer/lib/push_notifications.py 验证协议实现细节。一、协议背景为什么需要 E2EE 推送Zulip 服务器的移动推送通知统一经由 Zulip 官方提供的移动推送通知服务即常说的 bouncer中转再由该服务通过平台专属通道——Google 的 Firebase Cloud MessagingFCM或 Apple 的 Apple Push Notification serviceAPNs——送达移动设备。在这个链路上消息内容与元数据会被推送服务这一中间环节触及。Zulip Server 11.0 起提供的端到端加密推送解决了这一信任问题只有 Zulip 服务器与移动客户端掌握解密密钥推送通知的消息内容与元数据对中间环节包括 Zulip 官方推送服务、FCM、APNs不可见。只有注册了 E2EE 推送设备通过/api/register-push-device接口见 api_docs/include/rest-endpoints.md的移动客户端才会收到由其 Zulip 服务器端到端加密后的移动通知。本文档所描述的加密 JSON 载荷格式在 FCM 与 APNs 两条通道上完全一致——区别仅在于外层封装FCM 要求载荷字段全部为字符串而 APNs 则把载荷作为 JSON 字典放入 APNs 推送的message中。二、待加密载荷payload示例与字段详解E2EE 推送的核心设计是服务端先构造一个明文 JSON 载荷再用设备专属的推送密钥push key通过 NaCl 加密后连同push_key_id一起交给推送服务。以下各节展示的是加密前的明文 JSON 示例。2.1 新频道消息New channel message当用户所在的频道有新消息触发移动通知时加密前的载荷如下{ channel_id: 10, channel_name: Denmark, content: test_user_group, mentioned_user_group_id: 41, mentioned_user_group_name: test_user_group, message_id: 45, realm_name: Zulip Dev, realm_url: http://zulip.testserver, recipient_type: channel, sender_avatar_url: https://secure.gravatar.com/avatar/818c212b9f8830dfef491b3f7da99a14?didenticonversion1, sender_full_name: aaron, sender_id: 6, time: 1754385395, topic: test, type: message, user_id: 10 }字段语义要点recipient_type取值为channel标识这是频道消息旧版客户端协议中此字段为stream。channel_id/channel_name为频道标识topic为话题显示名服务端会按用户默认语言做话题名显示转换见 zerver/lib/push_notifications.py 中get_topic_display_name的调用。mentioned_user_group_id与mentioned_user_group_name仅当消息提到一个包含当前用户的用户组、且移动通知正是因该组提及而触发时才存在。例如消息同时直接提到用户本人又提到包含该用户的组则这两个字段不会出现因为直接提及优先级更高。sender_avatar_url为发送者头像地址客户端可据此渲染通知气泡中的头像。time为 Unix 时间戳秒。Changes该载荷新增于 Zulip 11.0feature level 413。2.2 新私信New direct message私信场景的加密前载荷如下{ content: test content, message_id: 46, realm_name: Zulip Dev, realm_url: http://zulip.testserver, recipient_type: direct, recipient_user_ids: [6,10,12,15], sender_avatar_url: https://secure.gravatar.com/avatar/818c212b9f8830dfef491b3f7da99a14?didenticonversion1, sender_full_name: aaron, sender_id: 6, time: 1754385290, type: message, user_id: 10 }字段语义要点recipient_type取值为direct标识私信旧版协议中为private。recipient_user_ids是该私信会话全部参与者 user ID 的升序排序数组同时包含user_id接收通知的用户与sender_id发送者。从源码看该数组由会话显示收件人集合构造并处理了自聊self-DM边界若收件人集合只有 1 人则补入sender_id最终sorted()排序zerver/lib/push_notifications.py。Changes在 Zulip 12.0feature level 429中pm_users字段被recipient_user_ids取代。旧的pm_users字段仅出现在群私信3 人及以上会话中是一个包含逗号分隔、已排序 user ID 的字符串。2.3 移除通知Remove notifications当一批此前已触发过移动通知的消息被标记为已读、被删除、对用户变得不可访问或其他任何导致不应再向用户展示的情况时服务端会发送一条移除通知指示客户端撤回/隐藏对应的本地通知。加密前载荷如下{ message_ids: [ 31, 32 ], realm_name: Zulip Dev, realm_url: http://zulip.testserver, type: remove, user_id: 10 }type为remove用于区分普通消息通知。message_ids列出需要从设备上移除通知的那些消息 ID。值得注意的是移除通知同样携带加密载荷走 E2EE 通道但其平台参数与普通消息不同详见下文平台参数差异小节。源码中该载荷由get_remove_payload_gcm构造zerver/lib/push_notifications.py。Changes新增于 Zulip 11.0feature level 413。2.4 测试推送通知Test push notification用户可通过/api/e2ee-test-notify接口向其选定的移动设备或全部移动设备发送一条 E2EE 测试推送用于验证端到端加密链路是否工作正常。加密前载荷如下{ realm_name: Zulip Dev, realm_url: http://zulip.testserver, time: 1754577820, type: test, user_id: 10 }type为test载荷仅含基础字段与时间戳不包含任何消息内容。源码中测试载荷由get_base_payload(user_profile, for_legacy_clientsFalse)构造并显式追加type与timezerver/lib/push_notifications.py。Changes新增于 Zulip 11.0feature level 420。2.5 基础字段所有载荷共用对比以上四种载荷可以发现realm_url、realm_name、user_id是所有类型共有的基础字段它们正是源码中get_base_payload所构造的公共字段用于让移动应用支持多组织、多服务器登录场景zerver/lib/push_notifications.py字段含义realm_url组织realm的 URL客户端据此区分来自不同服务器的通知realm_name组织名称user_id接收通知的用户 ID三、Zulip 服务器发送到官方推送服务的数据格式自托管服务器不直接与 FCM/APNs 打交道而是把待发送请求批量 POST 到 Zulip 官方推送服务的push/e2ee/notify端点zerver/lib/push_notifications.py。该请求的 JSON 结构如下{ realm_uuid: e502dde1-74fc-44b3-9e3a-114c41ed3ea4, push_requests: [ { token_id: AAAAAAAAAAE, http_headers: { apns_priority: 10, apns_push_type: alert }, payload: { push_key_id: 10, encrypted_data: uOGQ9m8bdnLab/2Qq6WLdJnFUsU/NlX0955rF6GgpiZylQB/HSDlrHct0KUXdCneufnGOuBMAGkYolSLlbvdsnePn/f6wSvMDbm3iffcgiz2u8TywUlmQL/Q7Ruj5RSpLgEhpFitL/WjwQBtrA31vsqMHycmROjutOhFlVjmzJmYy3o7ZQDi/YeB2YCnA5EuuXjckBYSjL4vi/YaEJXmeHvJ8Pk3T/WwXvo8CFZYlafiqSw0vC/2bkjPTFFAFVo/49nAUI5Rpa90wJUVChsrkKTclOs4Ih1dNIDYr6WoIKJTtIR9zgDg3YOkVHBZhlt7Se3i40WAs5JAb1PViMpAp2hbU36z1Qq0g90nmfRjXN9FRdAPaKlbFTT2PkEtS9wVBv9T14ufkhbOwaMLfx5iaHKw3XHoWo7Fe0IF9ZJ77uhCZoA1kyFKDhl7AZ8K4DOvib8gsfkeAR4XXXnXVmLtAyjBhMrWYNsECo9j4UeE6M90z3xIVR8, aps: { mutable-content: 1, alert: { title: New notification } } } }, { token_id: AAAAAAAAAAI, fcm_priority: high, payload: { push_key_id: 20, encrypted_data: OzPhtLiyU1U3ynqyTxkFt83N5GN7t3Uw8/OkCoFKFo/cu3GAzCMMbAAhPghflkrFK37SNOuxpPiL1TzPy5tQJqdSKpQrgu6cp0Y6VVA1aV/zsCDAcSABaWeaOeC5mVLxFpmFeEbhzUaOLchbRn4kBO4m8gqDU/rAn0cKFY1F7tyCgCfvvcczP05itDLpkwZMnrADGp3tSHFldr4iGO1pWJxFTXFFhg63UyH1FcMXKFzBPek7hLbpLsqu5OFEQv2TtDbAYdWZr1LXRqnkHTDmMd6NAdkOsVcnk31jHThFPDqaM5zDXb24hGHW79OpBnGAQWydfeChS4pC4yHWCO6ZRDqwvJX9IydSV7S91KCl0QSToaXvgW7Q3zvHunzu7L/rw0dQQRgPM3qIOHr7gGtptkZpmKuT6icdDGgjRtgP/L0TfxdRKa37fn6nF64HH60wLPYWOz7vZjgTrA20MrbA3ogMfhFYpwjppidFGVWjrLpkpeQjHB1sY } } ] }3.1 顶层结构与字段说明realm_uuid组织realm的 UUID推送服务据此定位对应的 RemoteRealm 记录。push_requests推送请求数组每个元素对应一台设备的推送任务。从上文示例可见同一条消息会为每台设备生成一个独立的push_requests元素且各设备的加密载荷互不相同每台设备拥有独立的推送密钥。3.2 每个 push_request 的字段字段说明token_idBase64 编码的设备推送 token 内部标识下详http_headers仅 APNs 请求携带含apns_priority与apns_push_typefcm_priority仅 FCM 请求携带取值为high或normalpayload统一包含push_key_id与encrypted_dataAPNs 额外携带apstoken_id的编码细节token_id并非设备 token 本身而是服务器数据库中设备推送 token 记录的内部整数 IDDevice.push_token_id/RemotePushDevice.token_id的 8 字节大端有符号整数经 Base64 编码的结果。对应编解码函数位于 zerver/lib/devices.pyb64encode_token_id_int用int.to_bytes(8, byteorderbig, signedTrue)后 Base64 编码b64decode_token_id_base64反向解码并校验 Base64 合法性。示例中的AAAAAAAAAAE、AAAAAAAAAAI即 ID 为 1、2 的 token 记录。push_key_id与密钥轮换push_key_id引用设备对应的推送密钥push key记录 ID。在 Zulip 12.0feature level 468中device_id与push_account_id字段被替换为token_id与push_key_id其目的是支持 FCM/APNs 提供的设备 token 轮换以及推送加密密钥的轮换——token 与密钥可以独立更新而设备记录的身份标识保持稳定。3.3 服务端的加密实现encrypted_data的生成逻辑位于get_encrypted_datazerver/lib/push_notifications.py推送密钥push key存储为字节串首字节为算法类型标识字节algorithm_type_byte其余为密钥本体secret_bytes由parse_push_key解析同文件 zerver/lib/push_notifications.py。当前实现断言算法类型字节为0x31该字节预留用于未来加密体系升级如更换密码学方案时做算法协商。加密使用 PyNaCl 的SecretBox即 NaCl 的crypto_box对称加密将orjson.dumps(payload_data_to_encrypt)序列化后的明文加密输出经 Base64 编码的密文字符串。也就是说上文第二节展示的所有明文 JSON 正是被此函数加密的对象。3.4 平台参数差异消息 vs 移除通知从send_push_notifications的实现zerver/lib/push_notifications.py可以明确看到不同载荷类型对应的平台参数策略载荷类型FCM 优先级APNs 优先级APNs push_type普通消息 / 测试通知high10alert移除通知removenormal5background此外APNs 载荷中的aps字典对两类通知也有差异普通消息包含mutable-content: 1、alert: {title: New notification}与sound: default而移除通知只保留mutable-content: 1同文件 zerver/lib/push_notifications.py。mutable-content标记允许客户端扩展Notification Service Extension在展示前解密载荷并替换通知内容。四、推送服务侧的数据FCM 与 APNs 的实际下发结构Zulip 官方推送服务收到上述push_requests后会将其转换为平台 SDK 的调用参数。服务端按token_kind分流APNs 设备走 aioapnsFCM 设备走 Firebase Admin Python SDK。4.1 发送到 FCM 的数据Zulip 的推送服务使用Firebase Admin Python SDK访问 FCM。SDK 内部用于构造 FCM 载荷的messages参数示例传给firebase_admin.messaging.send_each[ firebase_admin.messaging.Message( data{ push_key_id: 20, encrypted_data: OzPhtLiyU1U3ynqyTxkFt83N5GN7t3Uw8/OkCoFKFo/cu3GAzCMMbAAhPghflkrFK37SNOuxpPiL1TzPy5tQJqdSKpQrgu6cp0Y6VVA1aV/zsCDAcSABaWeaOeC5mVLxFpmFeEbhzUaOLchbRn4kBO4m8gqDU/rAn0cKFY1F7tyCgCfvvcczP05itDLpkwZMnrADGp3tSHFldr4iGO1pWJxFTXFFhg63UyH1FcMXKFzBPek7hLbpLsqu5OFEQv2TtDbAYdWZr1LXRqnkHTDmMd6NAdkOsVcnk31jHThFPDqaM5zDXb24hGHW79OpBnGAQWydfeChS4pC4yHWCO6ZRDqwvJX9IydSV7S91KCl0QSToaXvgW7Q3zvHunzu7L/rw0dQQRgPM3qIOHr7gGtptkZpmKuT6icdDGgjRtgP/L0TfxdRKa37fn6nF64HH60wLPYWOz7vZjgTrA20MrbA3ogMfhFYpwjppidFGVWjrLpkpeQjHB1sY, }, tokenpush-device-token-3, androidfirebase_admin.messaging.AndroidConfig(priorityhigh), ), ]实现要点zilencer/lib/push_notifications.pyFCM 的data字典只允许字符串值因此整数类型的push_key_id会被显式str()转换示例中为20。通过AndroidConfig(priority...)设置 FCM 优先级取自上节所述的fcm_priority字段。最终经firebase_messaging.send_each(fcm_requests, appfcm_app)批量发送若服务器未配置ANDROID_FCM_CREDENTIALS_PATHfcm_app is None则记录错误并丢弃通知zilencer/lib/push_notifications.py。批量响应用response.success逐一判定FCMUnregisteredError表示设备 token 已失效推送服务会把对应token_id记入delete_token_ids返回给 Zulip 服务器由服务器清除本地的push_token_idzerver/lib/push_notifications.py。Changes在 Zulip 12.0feature level 468中push_account_id字段被替换为push_key_id以支持推送加密密钥的轮换。4.2 发送到 APNs 的数据Zulip 的推送服务使用aioapns库访问 APNs。传入aioapns.APNs.send_notification的request参数示例aioapns.NotificationRequest( apns_topicremote_push_device_ios_app_id, device_tokenpush-device-token-1, message{ push_key_id: 10, encrypted_data: rUNqoWOBEQmjThJyXhDptmUrHyzSx4DPlvShzrM7XGdRVMG5qNuH0dnGQDVza9frnWNVOF3vFcuYvDnUnYRBf1j/n1ML1K2CBnsThCGl3KJNWrKcf5fME7Q1dU2xtJ3RAKuLtZ9y2gq6DWamui7WfQ75m1eJpYRDbbHIQEiSIZpo7X2Lie3aHkQBgE8SN5MJ6N3VM33DM6i1xGpIeWiFyhqNloGyEI2qf6xV0SjvvkNHbGticben4atBkAuAIKi0gIYMPyMihH26T1sEhOH3IDyO3KvaHe1NIdj0naT9RoFkN5UgdxIchXQ7qkVEjivA2E/HefpvZYlhems6TAosfJwgCMB8HuydqdImjixkugRQfugroTTG97p6xQIJSFWCOyrpuBDElI0Ale8XjmzaVo4Dbgqz5kIAhmJWtlwgJw8nt7Orr3EWUVjnIAi0nHCFObAXNShedAbyuLeC1qezqC4FZe/GOLLi4DPWgWSdk8PV5vGw9YCXcZ38dqQogtpG7dpzMwwsqzLBmlzQ, aps: { mutable-content: 1, alert: {title: New notification} } }, priority10, push_typealert, )实现要点zilencer/lib/push_notifications.pyapns_topic取自设备注册时上报的 iOS App IDRemotePushDevice.ios_app_iddevice_token为真实 APNs 设备 token二者均由推送服务从自身数据库中按token_id解析出来。message字典即为上节payload的展开push_key_id、encrypted_data、aps。priority与push_type直接取自http_headers中的apns_priority与apns_push_type。与 FCM 不同APNs 的push_key_id保持整数类型无需字符串化。Changes在 Zulip 12.0feature level 468中push_account_id字段被替换为push_key_id以支持推送加密密钥的轮换。五、端到端流程梳理综合以上各节一条 E2EE 移动推送的完整生命周期如下设备注册移动客户端通过/api/register-push-device注册 E2EE 推送设备服务端为设备分配push_token_id与推送密钥push_key含push_key_id密钥仅服务器与客户端各自持有。载荷构造服务器在消息触发通知时调用get_message_payload_gcm/get_remove_payload_gcm等函数构造明文 JSON 载荷zerver/lib/push_notifications.py。加密与请求组装send_push_notifications遍历用户的 E2EE 设备Device.push_token_id IS NOT NULL用每台设备的推送密钥经SecretBox加密明文组装成FCMPushRequest/APNsPushRequest列表zerver/lib/push_notifications.py。中转自托管服务器把{realm_uuid, push_requests}POST 到官方推送服务push/e2ee/notify若服务器本身启用了ZILENCER_ENABLED即服务器自身即为推送服务则直接调用zilencer.lib.push_notifications.send_e2ee_push_notifications本地处理zerver/lib/push_notifications.py。平台下发推送服务按设备类型分流——APNs 设备构造aioapns.NotificationRequest走send_notificationFCM 设备构造firebase_admin.messaging.Message走send_eachzilencer/lib/push_notifications.py。结果回收推送服务汇总apple_successfully_sent_count、android_successfully_sent_count与delete_token_idsFCMUnregisteredError/ APNs 失效 token 对应的token_id返回给 Zulip 服务器服务器据此清除失效设备的push_token_id并累加mobile_pushes_sent::day统计zerver/lib/push_notifications.py。客户端解密移动客户端收到通知后利用本地保存的推送密钥解密encrypted_data得到第二节所示的明文 JSON据此渲染或移除通知。六、版本演进速查E2EE 推送协议自 Zulip 11.0 引入后经历了三次重要的字段演进客户端与服务端实现时需留意 feature level 兼容性Feature level版本变更内容413Zulip 11.0引入 E2EE 推送新增消息、移除、服务器到推送服务等载荷格式420Zulip 11.0新增测试推送载荷type: test对应/api/e2ee-test-notify429Zulip 12.0私信载荷pm_users替换为recipient_user_ids覆盖单人私信与群私信468Zulip 12.0device_id→token_id、push_account_id→push_key_id支持 token 与密钥轮换七、进一步阅读官方协议文档api_docs/mobile-notifications.mdE2EE 设备注册接口api_docs/include/rest-endpoints.md 中 Register E2EE push device 与 Send an E2EE test notification 条目服务端载荷构造与加密实现zerver/lib/push_notifications.pyget_base_payload/get_message_payload、zerver/lib/push_notifications.pyget_encrypted_data/send_push_notificationstoken_id 编解码zerver/lib/devices.py推送服务侧平台适配zilencer/lib/push_notifications.pysend_e2ee_push_notification_android/send_e2ee_push_notification_apple/send_e2ee_push_notifications自托管服务器移动推送的完整运维文档见 docs/production/mobile-push-notifications.md【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考