Block 开源多 Agent 通信平台 buzz 的推送网关:授权、令牌与设备证明
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
在一张人人自持私钥的消息网络里,手机推送是唯一无法去中心化的一环——因为苹果只认一份提供商凭据,谁握着它谁就能给全体用户发通知。 buzz(Block 开源的多 Agent 通信平台,人和 Agent 在同一张 Nostr 消息网络里协作,仓库 https://github.com/block/buzz ,Apache-2.0,Copyright 2026 Block, Inc.;仓库 README 把自己定位成「一个人与 Agent 共同建造的工作区,跑在你自己拥有的中继上」)没有回避这个矛盾,而是把它压缩成一个单独的服务:crates/buzz-push-gateway。这个 crate 干的事只有一件——在不把 APNs 令牌交给任何中继的前提下,让中继有能力把你的手机叫醒。
先把底座术语摆平,后面才好谈机制。Nostr 是这个项目所依赖的协议:消息叫事件,是一个带公钥、内容和签名字段的 JSON 对象;中继(relay)是存转事件的服务器,客户端连上去写和读;密钥自持指身份就是一对密钥,没有账号找回,私钥丢了身份就没了;NIP 是这个协议生态的规范编号,buzz 仓库 docs/nips/ 下自带 15 份 NIP 规范 md 与 2 份 fixtures json(另有 crates/buzz-core/src/pairing/NIP-AB.md 是第 16 份)。推送这件事写在 docs/nips/NIP-PL.md 里,事件类型是 kind:30350,在 crates/buzz-core/src/kind.rs 里定义为 KIND_PUSH_LEASE。
一、这块到底解决什么问题
Nostr 是拉取式的。NIP-PL 的动机段落把困境说得很直白:移动操作系统会在几秒内终止后台 socket,所以可靠通知必须有一个服务端组件替客户端盯着流,再通过平台推送通道把应用叫醒。
于是需求分岔成两半。一半是「谁替你盯」——NIP-PL 把它交给执行方(executor),通常就是你自己的中继,它持有一张加密的推送租约,按租约里的过滤器匹配事件。另一半是「谁去敲苹果的门」——这需要 APNs 提供商密钥(一份 .p8 文件)、团队 ID、topic,以及每台设备的 APNs 令牌。
如果把这两半塞进同一个进程,结果是:任何一个中继部署都握着能给全平台用户发推送的凭据,还顺带存着所有人的设备令牌明文。docs/push-gateway-deployment.md 开头一句话就把这条路封死了——它要求用 Dockerfile.push-gateway 单独构建,不要在中继镜像里跑,也不要把 APNs 凭据给中继。
所以推送网关的定位是:一个拿得到凭据的中间人,但它拿到的每一样东西都被切成互不解锁的层。
二、三层是怎么切开的
第一层:设备证明,回答「你真的是那台设备上的那个 app 吗」
代码在 crates/buzz-push-gateway/src/app_attest.rs。它包了 Apple 的 App Attest:设备在安全环境里生成一对密钥,苹果给出一份证明链,服务端验证这份证明确实来自你注册的那个应用标识。
这个文件的克制程度值得看。AppAttestVerifier::new 会把传入的苹果根证书 PEM 做一次 SHA-256,和硬编码在文件里的 APPLE_APP_ATTEST_ROOT_PEM_SHA256 逐字节比对,不一致直接构造失败——网关连启动都启不来。部署文档同步给出了这份指纹:PEM 文件的 SHA-256 是 c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013,并且明确要求把苹果根证书轮换当成一次评审过的代码/配置发布,而不是随手换个挂载文件。
尺寸上限也全部写死:证明对象 MAX_ATTESTATION_BYTES 是 16 KiB,断言 MAX_ASSERTION_BYTES 是 1024 字节,解码后的 key_id 必须正好 32 字节。assertion_counter 这个函数更极端——它按 CBOR 解析断言,只接受 authenticatorData 和 signature 两个键,出现第三个键直接判非法;authenticatorData 必须正好 37 字节,签名计数从第 33 到第 37 字节按大端读出。文件顶部的注释说明了为什么敢这么读:只有在库已经验过同一段字节的 RP ID、签名和单调关系之后,抽出 signCount 才是安全的。
对你意味着什么:这一层不给任何设备留后门。文件开头写着「不受支持的设备没有绕过路径」。你要么过 App Attest,要么用不了推送,没有降级开关可拧。
第二层:投递授权,回答「这个中继现在还有权叫醒这台设备吗」
代码在 crates/buzz-push-gateway/src/grant.rs,数据结构在 src/model.rs 的 EndpointGrant。
它的核心判断是:中继需要的不是设备令牌,而是一张能力凭据。EndpointGrant 明文里有七个字段:v、delegation_id、relay_pubkey、app_profile、endpoint_epoch、generation、expires_at——model.rs 的注释点明了设计意图,里面没有 APNs 令牌,随机的 delegation_id 去持久化的权威状态里换取真实收件人,其余字段都是认证过的闸门,让过期的或跨中继的复用直接失败。
这张凭据用 AES-256-GCM 密封,附加认证数据是常量前缀 buzz-stateful-delivery-capability-v1: 拼上密钥 id,最终编码成 <key-id>.<base64url> 的形式,整串不超过 MAX_GRANT_BYTES(4096 字节)。GrantKeyring 分成一把 current 和若干只解密的 predecessor,轮换期间旧凭据仍能打开。
grant.rs 里那几个单测把边界钉得很死,其中一个叫 configured_route_id_is_authenticated_even_when_keys_match:即使两把密钥字节完全相同,把密文前缀从 current. 改成 previous.,也必须打不开——因为 key id 进了 AAD。这类测试是判断一个凭据实现是否认真的好指标。
第三层:令牌托管,回答「设备令牌到底躺在哪」
代码在 crates/buzz-push-gateway/src/token.rs。APNs 令牌在注册时就被 TokenKeyring::seal 加密,落进 PostgreSQL,只有在真要发推送的那一刻才解开。它用的是另一套密钥环,AAD 前缀是 buzz-apns-token-v1:,密文上限 MAX_CIPHERTEXT_BYTES 2048 字节。
src/config.rs 在启动时做了一件很关键的事:如果 BUZZ_PUSH_GRANT_KEYS 和 BUZZ_PUSH_TOKEN_KEYS 里出现相同的 id 或相同的密钥字节,直接返回配置错误。这不是文档里的一句劝告,是进程起不来。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 设备证明 | 验 App Attest 证明与断言,钉死苹果根证书指纹 | crates/buzz-push-gateway/src/app_attest.rs | 客户端首次注册、每次改配置时 |
| 投递能力凭据 | 密封/开封 EndpointGrant,支持密钥轮换 | crates/buzz-push-gateway/src/grant.rs | 中继每次请求投递 |
| 令牌托管 | 加密保管 APNs 令牌,独立密钥环 | crates/buzz-push-gateway/src/token.rs | 注册、端点轮换、真正发推送时 |
| 权威状态 | 挑战、安装、委托、配额、重放的事务性判定 | crates/buzz-push-gateway/src/authority.rs、crates/buzz-push-gateway/migrations/0001_push_gateway_authority.sql | 排查「为什么被拒」时 |
| HTTP 面 | 七条 /v1/ 路由 + 私有健康路由 | crates/buzz-push-gateway/src/http.rs | 对接客户端或中继时 |
| 配置与启动 | 环境变量解析、--migrate-only、优雅停机 | crates/buzz-push-gateway/src/config.rs、src/main.rs | 部署与运维时 |
| 部署约定 | 端口、密钥轮换、数据库权限、告警阈值 | docs/push-gateway-deployment.md | 上线前必读 |
| 协议侧规范 | 租约、匹配、投递语义与错误码 | docs/nips/NIP-PL.md | 想搞懂为什么这么设计时 |
三、一次投递实际走了哪些闸
src/http.rs 里的 deliver 函数是全篇最密的一段,顺序值得记:
- 严格解析请求体。两道锁分工不同:
strict_json这个模块只干一件事——在任意嵌套深度上拒绝重复的对象键(同一个键写两遍,很多 JSON 库会静默取后一个,这里直接判非法);DeliveryRequest上的#[serde(deny_unknown_fields)]则拒绝多出来的字段。两者任一不满足都是400 invalid_request;请求体上限MAX_REQUEST_BYTES8 KiB。另外v字段必须等于WIRE_VERSION,版本对不上同样直接退回。 - 取
Authorization头做 NIP-98 校验。NIP-98 是 Nostr 的 HTTP 鉴权约定:请求方用自己的私钥签一个事件塞进请求头,服务端验方法、URL、请求体哈希。这里调的是nostr::nips::nip98::verify_auth_header,验完拿到的事件公钥就是中继身份。 - 开封
endpoint_grant,然后比对grant.relay_pubkey != relay就拒。签名者必须就是凭据里写死的那个中继,转手给别人不生效。 - 时间窗三连:凭据没过期、请求没过期、请求过期时间不得晚于凭据过期时间。
- 调
authority.authorize_delivery,在一个事务里同时完成重放拦截与端点配额预留。数据库里为此专门有push_gateway_delivery_auth_replays和push_gateway_delivery_request_replays两张表,主键分别是(relay_pubkey, auth_event_id)和(relay_pubkey, request_id)。 - 比对 profile,开封令牌,发 APNs,再回来写投递处置。
值得单独说的是推送内容。model.rs 里有一个编译进二进制的常量 APNS_RECONNECT_PAYLOAD,正文就是一句「Reconnect to your relay now」。NIP-PL 把这条规则写成了强制要求:每一次实际发送的应用体都必须等于这个常量,中继请求里的任何字段都不许进入负载。也就是说,苹果那边能看到的只有「这个安装收到了一次唤醒」加上时间,看不到任何事件内容。
失败路径同样克制。凭据、签名者、权威状态、重放、过期、配额,六类失败全部塌缩成 404 invalid_grant,不告诉调用方到底是哪一层不通——按 NIP-PL 的说法,拒绝不得泄露某个安装、委托或端点是否存在。
四、为什么它必须单独部署
- 凭据物理隔离。
Dockerfile.push-gateway是独立的构建入口,部署文档要求 APNs 密钥、两套 AEAD 密钥环都从密钥管理器挂载,绝不进镜像、清单、日志或指标标签。 - 端口分面。公开监听走
BUZZ_PUSH_BIND_ADDR(默认0.0.0.0:8080),健康探针/_liveness、/_readiness和 Prometheus 的/metrics一律在私有的BUZZ_PUSH_HEALTH_ADDR(默认0.0.0.0:8081)。http.rs的router_with_metrics在代码层面就把 metrics 挂在私有 router 上。 - 数据库独占。网关只创建六张
push_gateway_*表加 SQLx 的迁移历史表,从不跑中继的迁移。部署文档要求DATABASE_URL指向专属数据库,理由很具体:SQLx 把_sqlx_migrations放在public,共库会和别的应用的迁移历史撞车。 - 权限降级是硬性的。迁移 Job 用 DDL 角色跑完之后,会收回运行时角色的数据库
CREATE和 schemaCREATE,只留CONNECT、USAGE和六张表上的SELECT, INSERT, UPDATE, DELETE;就绪探针会拒绝一个仍然保有CREATE权限的运行时角色。main.rs里对应的入口是--migrate-only这个启动参数。 - 多副本不放大滥用面。所有副本必须共用同一个 PostgreSQL,因为投递授权、重放准入、端点配额预留都是在那里事务化完成的,副本数不会把滥用上限乘上去。
main.rs 还起了一个每 300 秒跑一次的回收协程,清理过期挑战、重放行、闲置配额行、过期或已撤销的委托,以及到期安装连同其加密令牌密文;失败会打 push_gateway_reaper_failures_total。这条线决定了「数据落地多久」不依赖进程重启。
五、边界与代价
这套设计放弃了不少东西,写清楚比夸它有用。
只支持 APNs。 NIP-PL 明说:FCM 要等一个网关自有常量数据消息和它的线上测试注册之后才算合规 v1 profile;UnifiedPush 因为任意分发端点与任意消息体不满足固定负载的权威边界,v1 也不是合规 profile。配置里能开的只有 buzz-ios-production 和 buzz-ios-sandbox 两个值。
通知永远是那一句话。 没有富文本预览、没有发件人、没有未读数。NIP-PL 的非目标一节直接写明:中继提供的通知内容不得经过推送通道。想要更好的通知体验,只能在客户端侧本地拼。
不保证送达。 规范把推送定义为有损的尽力而为,重复和遗漏都合法,中继才是唯一真相源;投递那节还补了一句:不保证对提供商的精确一次投递。
网关不管匹配,也不管租户授权。 谁能读哪条事件、租约怎么匹配、去重与合并、租约代际失效,全在中继侧。网关只做最后一跳。反过来说,你排查「该收到通知却没收到」时,八成不该先看网关。
它依赖苹果,而且是刚性依赖。 根证书指纹钉死在代码里,苹果轮换就得发版本。配额那两个参数(BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS 与 BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES)文档写得很老实:它们是 buzz 自己的策略假设,不是苹果公布的限制,需要在压力下调。
数据落在哪要说清楚。 PostgreSQL 里存着加密后的 APNs 令牌密文、令牌指纹、App Attest 公钥与签名计数、委托关系、配额与重放行。部署文档因此要求数据库备份按服务机密同等级别做访问控制与留存管理。指标是有意做成低基数的:端点、设备令牌、中继公钥、请求 id 一律不做标签。
密钥自持的代价照样存在。 用户身份是一对 Nostr 密钥,私钥丢了身份就没了,这一层网关帮不上忙;而设备侧的安装授权走的是另一条线——App Attest 密钥加断言计数器,换设备或重装意味着重新走一遍注册流程。两条线是分开的,别把它们当成一件事。
六、上手与避坑清单
两套密钥环共用同一把密钥。 会踩是因为它们都是 32 字节 AES 密钥、格式一模一样,运维复制粘贴很自然。避法:不用记,config.rs 会在启动时比对 id 和字节,撞了就报 BUZZ_PUSH_TOKEN_KEYS 无效——把它当成一次有意的失败,别去绕。
改了 Secret 却没重启。 会踩是因为 Kubernetes 不会因为引用的 Secret 字节变化而重启 Pod。部署文档给的做法是:轮换后显式做一次滚动重启(例如 kubectl rollout restart deployment/<release>-buzz-push-gateway),并在移除前任密钥之前先验就绪。
前任密钥删太早。 会踩是因为轮换看起来只是「把新密钥放到第一位」。规则是保留只解密的前任,直到用它们加密过的凭据与令牌全部过期或已重新加密——凭据活多久由 BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS 决定,安装默认 90 天、最长一年。
和中继共用数据库。 会踩是因为看起来省一套实例。结果是 _sqlx_migrations 冲突。避法:一开始就建独立库,并让迁移角色和运行时角色分家。
把 8081 也放出去。 会踩是因为很多编排模板默认给 Service 开全部端口。避法:默认不给 8081 任何 pod ingress,需要抓取时才把 podMonitor.enabled 与网络策略里的监控来源一起打开,并且只放行你的抓取器。
投递 URL 配错。 会踩是因为它看起来只是个普通配置项,实际上它是 NIP-98 签名的一部分——中继签的 URL 和网关校验的 URL 必须逐字节一致,不一致就是全量 401 invalid_auth。config.rs 还额外要求它是 https、主机为 push.buzz.xyz、路径为 /v1/deliveries/apns,且不带端口、查询串、片段和用户名密码。中继侧对应的变量是 BUZZ_PUSH_GATEWAY_DELIVERY_URL,置空字符串等于显式关闭推送。
把 410 invalid_endpoint 当成设备已经没了。 会踩是因为它读起来就是「永久失效」。但规范要求中继只在该 generation 仍然是当前代际时才应用它——端点刚轮换过的话,这条响应说的是旧代际。
忘了续期。 会踩是因为注册成功之后一切正常,直到安装过期。客户端必须在到期前续,过期后回收协程会把安装连同令牌密文一起清掉。
结尾:一份自检清单
这篇讲的是一件很窄的事:当凭据必须交给一个中间人才能完成最后一跳时,怎么把它切开。站内 API Key 安全管理 讲的是你自己那把密钥怎么存、怎么轮换,AI 数据安全风险 讲的是数据流出边界,MCP 安全边界 讲的是工具调用侧怎么收口权限;本篇补的是它们之间那块空白——凭据交出去之后的结构设计。如果你正在给自己的 Agent 系统设计类似组件,可以顺手对照 最小权限设计 和 密钥轮换 两篇。
拿这套结构去审你自己的凭据中间人,四个问题够用了:谁证明请求方是它声称的那个设备或服务,证明有没有降级开关;下游拿到的是真凭据还是一张打不开的能力票,票里的每个字段是否都进了认证范围;真正的敏感凭据加密时用的密钥,是否和对外发的票用了同一套;准入判定是不是事务性的,多副本会不会把上限乘上去。
想继续读源码,顺序建议是:先 crates/buzz-push-gateway/src/model.rs 看清所有线上类型,再 src/http.rs 的 deliver 函数走一遍闸门顺序,然后 src/grant.rs 和 src/token.rs 对照看两套密钥环的差别,最后 crates/buzz-push-gateway/migrations/0001_push_gateway_authority.sql 确认六张表的约束(网关的迁移历史是独立作用域的,别去根目录的 migrations/ 里找,那是中继的)——那里的 CHECK 条件基本就是这套设计的边界声明。协议层面的取舍,答案都在 docs/nips/NIP-PL.md。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源多 Agent 平台 buzz:一张图上传的四道关 和 Block 开源多 Agent 平台 buzz:语音在中继与本地如何分工。