拆解 Block 开源多 Agent 通信平台 buzz 的两套鉴权设计
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
一个数字签名不等于一个密码。密码天生是一次性交换的秘密,签名天生是可复制、可转发、可反复出示的公开证据——你要把它当凭据用,就得自己补上「绑到谁」「什么时候失效」「用过一次就作废」这三件事。 Block 开源的多 Agent 通信平台 buzz 把这三件事拆进了 crates/buzz-auth 这一个 crate 的四个文件里:连接层一套、HTTP 请求一套,外加一层重放 seen-set 和一层作用域枚举。哪个文件缺一块,洞就正好开在那儿。
这篇按源码顺序走一遍这四块,重点不是「它有多完整」,而是每一块在补哪个具体的洞、放弃了什么。站内已有的 API Key 安全管理 讲的是静态长期凭据怎么存怎么轮换,Agent 权限台阶 讲的是权限该怎么分级发放,AI 数据安全风险 讲的是数据面的暴露;这篇只管一件更窄的事——当凭据本身是一次签名而不是一串秘密时,验证方必须额外做哪些检查。
一、先把底座讲清楚:事件、签名、中继、密钥自持
buzz 不是自己发明的消息协议,它建在 Nostr 之上。四个词先各给一句话:
事件(event)是 Nostr 里唯一的数据单位,一条 JSON 对象,带发送方公钥、时间戳、类型编号(kind)、标签数组和正文。发消息是事件,建频道是事件,git 仓库公告也是事件。签名是发送方用私钥对这条事件算出的一段数据,任何人拿公钥就能验证「确实是这个公钥的持有者写的,且一个字节没改过」,Nostr 用的是 Schnorr 签名。中继(relay)是收下事件、存起来、再转发给订阅者的服务器,不是区块链节点,没有共识,就是一个带订阅能力的事件日志服务,buzz 的中继实现就是这个仓库的主体。密钥自持指私钥在客户端手里,服务端只见公钥——代价要说透:服务端没有你的私钥,所以没有「忘记密码」这条路,私钥丢了就是身份丢了。
这四件事在 buzz 里的落点是 crates/buzz-core/src/verification.rs 里的 verify_event。它做两步:先重算事件 id 看对不对,再验签名。
pub fn verify_event(event: &Event) -> Result<(), VerificationError> {
if !event.verify_id() {
let computed = EventId::new(
&event.pubkey,
&event.created_at,
&event.kind,
&event.tags,
&event.content,
)
.to_hex();
return Err(VerificationError::InvalidId { computed, got: event.id.to_hex() });
}
if !event.verify_signature() {
return Err(VerificationError::InvalidSignature);
}
Ok(())
}
顺序值得留意:先校 id 再校签名。事件 id 是把公钥、时间、kind、标签、正文一起算出来的哈希,也就是内容寻址——改任何一个字段,id 就变了。这个性质后面在重放防护里会被直接当作前提用。文件头的注释还写了一条工程约束:这函数是 CPU 密集的,异步上下文里必须走 tokio::task::spawn_blocking,不能直接在 async 任务上跑。
二、连接层:NIP-42 挑战应答补的是「新鲜性」
NIP 是 Nostr 的规范编号(类似 RFC)。NIP-42 是 WebSocket 连接上的挑战应答鉴权,实现在 crates/buzz-auth/src/nip42.rs。流程在文件头三行就写清了:中继先发 ["AUTH", "<challenge>"],客户端签一条 kind:22242 事件带上 challenge 和 relay 标签,中继调 verify_nip42_event 验。
挑战本身是 32 字节 CSPRNG(密码学安全随机数)的十六进制串,也就是 64 个 hex 字符——测试 challenge_is_64_hex_chars_and_unique 就是钉这一点的。它由服务端在建连时生成一次:crates/buzz-relay/src/connection.rs 里 handle_active_connection 调 generate_challenge(),存进 AuthState::Pending { challenge },然后把 RelayMessage::auth_challenge(&challenge) 发出去。
验证一侧检查五件事,一件不过就拒:kind 必须是 Kind::Authentication、签名有效、challenge 标签匹配、relay 标签匹配、created_at 落在 ±60 秒内(TIMESTAMP_TOLERANCE_SECS = 60)。challenge 是关键——它是这套设计里唯一真正的「新鲜性证明」:签名本身可以被无限重放,但服务端刚随机生成、只在这条连接上有效的挑战,攻击者事先签不出来。relay 标签的比对走 normalize_relay_url,做两件归一化:localhost 和 ::1 都折成 127.0.0.1,路径尾部斜杠去掉。
更容易踩的坑在「expected 值从哪来」。crates/buzz-relay/src/api/bridge.rs 里有个小函数专门管这个:
pub(crate) fn nip42_expected_relay_url(config_relay_url: &str, tenant: &TenantContext) -> String {
let scheme = if config_relay_url.trim_start().starts_with("wss://") {
"wss"
} else {
"ws"
};
format!("{scheme}://{}", tenant.host())
}
它只从配置里取协议头,主机名一律用当前连接解析出的租户 host。同文件的测试 verify_nip42_rejects_event_signed_for_wrong_communitys_host 把这个理由写明了:部署级的 config.relay_url 是全局静态值,攻击者也知道;如果拿它当 expected 值,一条为社区 A 的 host 签好的 AUTH 事件就能在连到社区 B 时通过。多社区部署里,这是一条真实存在的串门路径。
验过之后产出 AuthContext,crates/buzz-auth/src/lib.rs 里的 verify_auth_event 给它填的是 Scope::all_known()——纯 Nostr 模式下所有通过 NIP-42 的连接都拿全量作用域,代码注释直说了原因:逐频道的访问权交给 NIP-29 成员判定,不由作用域承担。同一个文件的「安全不变量」段落还立了一条硬规矩:kind:22242 的 AUTH 事件永不入库、永不写日志,因为它可能携带 bearer token。
三、HTTP 层:NIP-98 无状态,代价是丢了挑战
crates/buzz-auth/src/nip98.rs 处理的是另一条路:HTTP 请求。客户端签一条 kind:27235(Kind::HttpAuth)的短期事件,把 URL、HTTP 方法、可选的请求体 SHA-256 哈希写进标签,然后按 Authorization: Nostr <base64(JSON 序列化的事件)> 发出去。
文件头把校验列成了八步,verify_nip98_event 的实现和它逐条对齐:解析 JSON、kind 必须 27235、验 Schnorr 签名、created_at 在 ±60 秒内、u 标签的 URL 匹配、method 标签大小写不敏感匹配、若 payload 标签存在且调用方传了 body 就比对 SHA-256、返回 event.pubkey。
两个细节容易被读快过去。一个是标签名:注释专门标了 NIP-98 用的是单字母 u 标签而不是多字母的 url 标签,代码里对应 SingleLetterTag::lowercase(Alphabet::U),名字写错一个字符,签出来的事件在任何符合规范的服务端都过不去。
另一个是归一化策略故意和 NIP-42 相反。nip98.rs 里的 normalize_url 只做小写化和去尾斜杠,注释里加粗写着「No loopback aliasing」——localhost、::1、127.0.0.1 在这里是三个不同的主机。理由是多租户下 u 标签的 host 承担社区绑定:一旦把三者折叠,为 localhost 签的事件就能过一个解析到 127.0.0.1 的社区,反向也一样,这是一条主机绑定的侧门。测试 loopback_aliases_are_distinct_hosts 正反两个方向都钉了。同一个概念在两个文件里给出相反处理,恰好说明归一化不是通用最佳实践,得看这个字段在你的系统里承担什么职责。
payload 标签的语义也值得注意:只有「标签存在」且「调用方传了 body」两个条件同时满足才校验哈希。测试 payload_tag_absent_with_body_passes 明确接受了「有 body 但没签哈希」的情况——规范里客户端 SHOULD 带,不是 MUST。这意味着是否强制 body 绑定的决定权在调用方,crates/buzz-relay/src/api/bridge.rs 因此专门给了个 require_payload 参数,在需要的接口上先自己检查 TagKind::Payload 存不存在,缺了直接返 401。
真正的结构性缺口是无状态本身。NIP-42 有服务端发的挑战,NIP-98 什么状态都不留,所以它没有任何东西能证明「这条签名是这一次请求才产生的」。同一个 Authorization 头,在 ±60 秒窗口内截下来重放多少次都算合法。这一层的洞得靠下一块补。
四、重放 seen-set 与作用域:两块外挂
crates/buzz-auth/src/nip98_replay.rs 的文件头把定位说得很干脆:NIP-98 的校验是「结构上完整」的,签名、kind、时间窗、URL、方法、body 哈希全查了,但它不查这个 event id 有没有被用过——那需要共享状态。多个中继 pod 的部署下,进程内缓存(moka、DashMap 之类)没法把新鲜性证明带过 pod 边界,所以这条被列成了硬门槛。
要求是共享状态(Redis)、原子的 set-if-absent、TTL 不低于 120 秒、键按社区隔离:
pub fn nip98_replay_key_for_scope(scope: &str, event_id: &EventId) -> String {
format!("buzz:{scope}:nip98:{}", event_id.to_hex())
}
社区前缀的理由写得很坦白:事件 id 是内容寻址的,跨社区自然碰撞概率为零,但这道门要的是「失败即隔离」——同一个 id 在两个社区重放,必须落到两行不同的记录上。
TTL 的两个常量各有出处。DEFAULT_REPLAY_TTL_SECS = 120 是下限,来自校验方的 ±60 秒容差:一个重复 id 有可能出现的时间跨度就是 2×60。MAX_REPLAY_TTL_SECS = 3600 是上限,注释给了两条理由——超过一小时对 NIP-98 重放毫无意义,且 Redis 的 EX 参数按 64 位有符号整数解析,不夹一下会被过大值搞成解析失败。同文件里 default_ttl_meets_gate_floor、ttl_floor_below_ceiling、max_ttl_fits_in_redis_signed_ex 三条测试专门断言在常量本身上,注释直接把它们叫做「常量漂移绊线」——把 120 改小、把上下限改到交叉、或者把上限抬过 Redis 能解析的范围,都会当场红掉。
调用顺序在文档注释里被单独强调,配了一段用法示例:
let pubkey = buzz_auth::verify_nip98_event(json, url, method, body)?;
if !replay.try_mark(&ctx, &event_id, buzz_auth::DEFAULT_REPLAY_TTL_SECS).await? {
return Err(AuthError::Nip98Replay);
}
先验签,再标记。 反过来写就多出一个攻击:知道受害者某条未来事件 id 的攻击者可以先拿伪造事件把那个 id 的槽位烧掉,让合法请求被判成重放。这是个很典型的「顺序即安全属性」的例子。
生产实现在 crates/buzz-pubsub/src/nip98_replay.rs,一条 Redis 命令解决:
let ttl = ttl_secs.clamp(DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS);
let result: Option<String> = redis::cmd("SET")
.arg(&key).arg("1").arg("NX").arg("EX").arg(ttl)
.query_async(&mut *conn).await
NX 让它成为原子的 set-if-absent,Some("OK") 是首次占用(放行),None 是键已存在(判重放),其它返回值当内部错误处理。trait 契约里还写了一条不许妥协的规矩:Redis 不可达之类的 Err 必须 fail closed,不能退化成「尽力而为、出错放行」——那等于把新鲜性证明整个丢掉。bridge.rs 里的 check_nip98_replay_with_guard 就是按这个写的,Err 分支打 warn 日志然后返 401。
crates/buzz-auth/src/scope.rs 管的是另一件事:验过身之后能干什么。16 个已知作用域,落到传输和存储里的字符串(源码注释叫 wire format)是冒号分隔的两段:
Self::MessagesRead => "messages:read",
Self::AdminChannels => "admin:channels",
Self::ReposWrite => "repos:write",
设计上有两处取舍。一是存库用 TEXT[] 而不是枚举类型,加新作用域不用改表结构;配套的是 Scope::Unknown(String),认不出来的字符串原样留着,让旧版本中继能和新版本共存。二是 all_known() 给 16 个、all_non_admin() 给 14 个(去掉 admin:channels 和 admin:users),后者服务于开发模式:require_auth_token=false 时 X-Pubkey 头就能声明身份,没有 token 可以推导作用域,但管理类操作即使在开发模式下也要求真 token。
作用域真正被强制的地方在写入路径。crates/buzz-relay/src/handlers/ingest.rs 里有一张按事件 kind 到作用域的映射表:git 仓库公告与仓库状态映射到 Scope::ReposWrite,NIP-29 建群和画布映射到 ChannelsWrite,kind:9002 改元数据还按标签细分——带 archived 标签走 AdminChannels,否则走 ChannelsWrite,末尾一条 _ => Err("restricted: unknown event kind") 保证认不出的 kind 一律拒。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 事件 id 与签名校验 | 重算 id 哈希 + 验 Schnorr 签名,两条路径共用的地基 | crates/buzz-core/src/verification.rs | 任何一条鉴权路径的第一步 |
| NIP-42 挑战应答 | 生成挑战、验 kind:22242 事件的挑战/中继/时间戳 | crates/buzz-auth/src/nip42.rs | 建立 WebSocket 连接时 |
| 挑战下发与连接状态 | 建连时生成挑战、存进 AuthState::Pending、发给客户端 | crates/buzz-relay/src/connection.rs | 排查「客户端收不到 AUTH」时 |
| NIP-98 HTTP 校验 | 验 kind:27235 的 URL、方法、可选 body 哈希 | crates/buzz-auth/src/nip98.rs | 调 HTTP 接口、git clone/push |
| 重放 seen-set 契约 | 定义原子 set-if-absent、TTL 上下限、社区隔离键 | crates/buzz-auth/src/nip98_replay.rs | 自己实现 guard 或改 TTL 时 |
| Redis 重放实现 | 一条 SET NX EX 完成占位判重 | crates/buzz-pubsub/src/nip98_replay.rs | 配 Redis、排查「replay detected」 |
| 作用域枚举 | 16 个作用域的字符串格式、解析、前向兼容 | crates/buzz-auth/src/scope.rs | 加新能力、审权限清单 |
| 逐 kind 作用域映射 | 把事件 kind 映射到所需作用域 | crates/buzz-relay/src/handlers/ingest.rs | 新增事件 kind 时 |
| 每租户 URL 重建 | 用租户 host 而非全局配置构造 expected 值 | crates/buzz-relay/src/api/bridge.rs | 多社区、反向代理部署 |
五、边界与代价:这套设计明确不管什么
它不管消息级授权。 NIP-42 只回答「这条连接背后是哪个公钥」,scope.rs 的注释直说逐频道访问由 NIP-29 成员判定负责,access.rs 里的 check_read_access / check_write_access 是作用域检查加成员检查两步走。把作用域当频道权限用是理解错位。
git HTTP 路由主动放弃了三层保护。 crates/buzz-relay/src/api/git/transport.rs 的注释三处让步都写了原因:方法绑定放弃了,因为 git 的凭据助手用 method=GET 签一次再在 POST 拿包数据时复用同一个 token,代码干脆把事件自己的 method 传回去让校验必然通过,注释里注明这个「同义反复」是刻意的;body 哈希放弃了,因为流式 pack 数据没法缓冲下来算哈希,传的是 body=None;重放去重也放弃了,因为一次 clone/push 会话里同一个 token 跨多请求复用,去重会打断正常操作。剩下的保护是 ±60 秒时间窗、URL 锁定、生产环境 HTTPS,以及推送授权走 pre-receive 钩子。注释把这些标成 v1 取舍,逐请求签名需要改协议。
重放防护拿可用性换正确性。 fail closed 意味着 Redis 一挂,整条 NIP-98 HTTP 路径全拒。这不是 bug,是写进 trait 契约的要求。你的可用性预算要为此留位置。
很多作用域是预留的。 ReposRead 的文档注释写着「Reserved for future use」,git HTTP 路由不查它,走的是 NIP-98 加所有者检查;ReposWrite 在 WebSocket 摄入 kind:30617/30618 时强制,但 git HTTP 推送路由同样不查。读权限清单时别把枚举项的存在当成「已经在拦」。
开发模式的口子很大。 require_auth_token=false 时 X-Pubkey 头直接声明身份,没有任何签名;重放检查在这条路上也被跳过(事件 id 是全零哈希就直接放行)。lib.rs 里的 derive_pubkey_from_username 更狠——用 SHA-256("buzz-test-key:{username}") 当私钥材料,注释里加了醒目警告:任何知道用户名的人都能算出对应私钥,这个函数被 #[cfg(any(test, feature = "dev"))] 挡着,绝不能编进生产构建。如果你自建中继,这两个开关的状态就是你的暴露面本身。
Agent 的权限就是它公钥的权限。 这套设计里人和 Agent 走的是同一条鉴权路径、同一套作用域。一个拿到 messages:write 的 Agent 能往它所在频道发消息,拿到 repos:write 的 Agent 能提交仓库公告与状态事件。它不做「这次改动合不合理」的语义判断——那是另一个层面的问题,参见 Agent 最小权限设计。
顺带一句关于定位:仓库 README 是这样定位自己的:一个人和 Agent 一起搭东西的工作区,跑在你自己拥有的中继上。仓库根目录另有 8 份 VISION 系列文档。这些都是项目自己的说法和愿景陈述,不等于当前代码已实现的功能,读的时候要和源码分开对待——本文所有判断只来自上面点名的那几个 .rs 文件。
六、上手与避坑清单
把 expected URL 从租户解析结果里取,别用全局配置。 会踩是因为配置里那个 relay_url 看着就像「本服务的地址」,直接拿来当比对基准最省事。后果是多社区部署下跨社区串门。避法是照 nip42_expected_relay_url 和 git 路径里 git_expected_url 的做法——协议头可以来自配置,host 必须来自当次请求解析出的租户。
验签在标记之前。 会踩是因为写代码时「先占位再慢慢验」看着更防并发。后果是把伪造事件的 id 写进了 seen-set,攻击者能定向 DoS 合法请求。避法就是照 nip98_replay.rs 文档注释里那段示例的顺序抄。
反向代理后面要自己重建 URL。 会踩是因为容器里看到的 scheme 是 http、host 是内网地址,而客户端签的是外部 HTTPS 地址,u 标签怎么都对不上,表现为全量 401。nip98.rs 的参数文档明确写了:反代部署要先从 X-Forwarded-Proto / X-Forwarded-Host 重建再传进来。注意 transport.rs 的注释同时强调了不信任转发头本身——签名里的 u 标签是拿去和权威社区表解析出的 host 比对的,不是和客户端随便给的值比对。
别顺手给 NIP-98 的 URL 比对加 loopback 归一。 会踩是因为你可能刚在 nip42.rs 里见过一次归一化,觉得两边应该一致。后果是开了一条主机绑定侧门。避法是先问这个字段在系统里承担什么——NIP-98 的 u 标签 host 承担社区绑定,NIP-42 的 relay 标签不承担。
时钟同步是硬前置,TTL 别往 120 秒以下调。 前者会踩是因为 ±60 秒在本地开发时宽裕得像不存在,容器时钟漂移几分钟后所有请求一起挂,错误信息只说时间戳超窗——排查 401 先看两端时间差。后者会踩是因为觉得缩短能省 Redis 内存,但低于 ±60 秒容差的两倍,窗口边缘的重放就漏过去了;契约允许实现把小于下限的值抬到下限,绝不允许按给定值执行,生产实现用的是两头都夹的 clamp。
开发模式开关别带进生产。 会踩是因为 X-Pubkey 在本地调试太顺手,配置文件一路 copy 上去。避法是把 require_auth_token 和 dev feature 纳入上线前检查清单,构建产物里 grep 一遍 derive_pubkey_from_username 有没有被编进去。
验签放到阻塞线程池,AUTH 事件不要打日志。 前者会踩是因为 Schnorr 验证是纯 CPU 计算,直接在 async 任务里跑会把 executor 线程占死,症状是高并发下延迟飙升而不是报错——verification.rs 和 nip42.rs 都写了这条,AuthService::verify_auth_event 的实现就是包在 spawn_blocking 里的。后者会踩是因为排查鉴权失败时最自然的动作就是把整个事件打出来看,而 lib.rs 的安全不变量和 nip42.rs 的文件头都注明这类事件可能携带 bearer token;要打就打 AuthError 的分类信息,error.rs 的错误变体是专门设计成可以安全返回给调用方的。
收尾
把这套设计压成一句话:签名给你的是「这是谁写的」,剩下的「写给谁的」「什么时候写的」「写过没有」「有没有资格写」,得靠 relay/u 标签绑定、±60 秒时间窗、共享 seen-set、作用域这四件事分别补上。四件事在 buzz 里分在四个文件,任何一处退化都不会让编译失败,只会让洞悄悄张开——这也是那几个测试文件里写满「这个断言在什么情况下会咬人」注释的原因。
自己动手核的话,建议的阅读顺序是:先 crates/buzz-core/src/verification.rs 看地基,再拿 crates/buzz-auth/src/nip42.rs 和 nip98.rs 对比两套检查项的差异,然后读 nip98_replay.rs 的文件头注释(它把「为什么需要共享状态」讲得比代码本身清楚),最后跳到 crates/buzz-relay/src/api/git/transport.rs 那段长注释——几处放弃都写明了原因和代偿手段。整个仓库 crates/ 下 28 个 crate,鉴权只占其中两个,却是其余所有 crate 的前置条件。想看「协议怎么承载 Agent 协作」的横向对比,可以接着读 Agent 协议生态对比。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源 buzz:多 Agent 通信平台的四道发布订阅闸门 和 Block 开源 buzz 多 Agent 平台:多租户隔离线要画三遍。