谁能连上你的 buzz 中继:Block 开源多 Agent 通信平台的三层门禁

2026-08-05

本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。

**buzz 的中继门禁不是一个开关,而是三块互不代替的机制拼起来的:准入判定管”这条连接现在还能不能继续说话”,邀请令牌管”一个陌生公钥凭什么变成成员”,webhook 密钥管”一个没有身份的外部系统凭什么触发你的工作流”。**只看其中任何一块,你都会对”我的中继是开放还是私有”这个问题给出错误答案。

这里说的 buzz 指 Block 开源的多 Agent 通信平台(仓库 https://github.com/block/buzz ,许可证 Apache-2.0,Copyright 2026 Block, Inc.),不是英文里那个”嗡嗡声”或者中文常说的”热度”。它建在 Nostr 协议之上,人和 Agent 用同一套身份和同一条消息流协作。

一、先把几个词对齐

Nostr 是什么。 一句话:它把消息定义成”带作者签名的事件”,事件由持有私钥的一方签名,服务端只负责收下、验签、存储、按订阅条件转发。承担这个服务端角色的进程就叫中继(relay)。所以 Nostr 里没有”账号密码注册”这回事——你的身份就是你的密钥对,公钥是你的 ID,私钥是你的凭据。

密钥自持的代价要先说清楚。 私钥在你手里,意味着丢了私钥就是丢了身份:中继那边没有你的密码哈希可以重置,也没有”找回账号”的入口。它只知道某个公钥曾经是成员。这一点在读下面三层门禁时要一直记着——所有判定都是围绕公钥展开的。

buzz 仓库长什么样。 你可以自己 ls 一遍核对:crates/ 下 28 个 crate(共 413 个文件),desktop/ 2359 个文件,mobile/ 467 个,web/ 65 个,admin-web/ 14 个,docs/ 53 个文件(其中 docs/nips/ 是 15 份 NIP 规范 md 加 2 份 fixtures json,另有 crates/buzz-core/src/pairing/NIP-AB.md 算第 16 份),migrations/ 27 份 SQL,根目录 8 份 VISION*.md。NIP 是 Nostr 生态里”协议增补提案”的通称,buzz 自己也写了一批放在 docs/nips/ 下。

仓库 README 这样定位自己:“A workspace where humans and agents build together, on a relay you own.” 这是项目自己的说法。根目录那 8 份 VISION*.md 写的是愿景文档,读的时候别当成已经落地的功能清单。

开放中继与私有中继,在代码里就是一个布尔值。 crates/buzz-relay/src/config.rs 里的 require_relay_membership 字段,由环境变量 BUZZ_REQUIRE_RELAY_MEMBERSHIP 控制,默认 false——config.rs 自己的单元测试就断言了这一点("require_relay_membership should default to false")。它为 false 时,成员检查是个空操作,所有通过认证的调用方一律放行。

判定结果的四种形态定义在 crates/buzz-relay/src/api/mod.rsrelay_members 模块里,枚举 MembershipDecisionOpenRelayMemberViaOwnerDenied 四个变体。check_relay_membership 的第一行逻辑就是:如果没开启成员制,直接返回 OpenRelayrelay_members根本不会被查询。这不是性能优化的副作用,是语义本身——开放中继下”成员”这个概念不参与任何判断。

ViaOwner 是给 Agent 留的口子:Agent 自己不是成员,但它带着一个 NIP-OA 的 auth 标签,能在密码学上证明自己的 owner 是谁;如果 owner 在成员表里,Agent 就被放行。这条路径要 allow_nip_oa_authBUZZ_ALLOW_NIP_OA_AUTH)显式打开才在私有中继上生效。

顺带一个部署上会绊人的约束:crates/buzz-relay/src/main.rs 里,一旦 require_relay_membership 为真,启动时会强制要求 RELAY_OWNER_PUBKEYBUZZ_RELAY_PRIVATE_KEY 都存在,否则直接报错退出。注释给的理由很直白——前者是为了不启动一个”没人能管理的中继”,后者是因为用临时密钥签的 NIP-43 事件重启后就没法验证了。

二、第一层:准入判定,它管的是流量不是身份

crates/buzz-relay/src/admission.rs 这个文件只有几十行有效代码,但它的定位容易被误解。它不判断你是谁,它判断”你这个已经认证过的主体,此刻还有没有配额继续动作”。

核心函数是 check_principal,参数里带着租户上下文 TenantContext、公钥、LimitType、窗口秒数和上限。LimitType 定义在 crates/buzz-auth/src/rate_limit.rs,有 MessagesApiCallsWsEventsIpConnections 四个变体,各自映射到独立的 Redis 键后缀(msgapiwsconn),所以几类操作的额度互不串用。

返回值只有两种失败:AdmissionError::Exceeded { reset_in_secs }AdmissionError::Unavailable。第二种值得单独说——当共享计数器(Redis)本身出问题时,check_principal 记一条 warn 日志,然后照样拒绝。这是 fail closed 的取舍:宁可错杀,不放过。

ws_admission_budget 这个小函数解决的是一个很具体的现实问题。文件顶部注释写得很清楚:桌面端启动时会一口气建立好几条独立的实时订阅,如果按”每秒 N 次”的硬窗口卡,正常启动就会被自己的限流打死。它的做法是把窗口拉宽到常量 WS_BURST_WINDOW_SECS(5 秒),上限相应乘以 5,平均速率不变但允许一次有界的突发。乘法用的是 saturating_mul,所以配置成极大值也不会溢出回绕——单元测试里两条断言分别验了 ws_admission_budget(10) == (5, 50)ws_admission_budget(u64::MAX) == (5, u64::MAX)。同一段注释也承认这仍是固定窗口,Redis 支持的令牌桶才是更合适的长期方案;rate_limit.rs 文件顶部的警告更直接,固定窗口在边界上最多放行 2 倍突发。

调用点在 crates/buzz-relay/src/connection.rsenforce_ws_admission。这里有两个细节直接影响你怎么理解这层门:

第一,函数开头取认证状态,如果连接不处于 AuthState::Authenticated,直接 return true——未认证的连接不走这道闸。所以准入判定完全不是身份门,它是给已认证主体套的节流圈。

第二,人和 Agent 的额度只在”消息”这一档分开。判据是认证上下文里 agent_owner_pubkey.is_some():有 owner 的走 agent_standard_messages_per_min,没有的走 human_messages_per_min——Agent 的正常吞吐本来就该和人不一样,这是”人和 Agent 同处一网”这个前提下必须做的区分。但事件那一档并没有分:enforce_ws_admissionws_admission_budget 时,传进去的一律是 human_ws_events_per_sec,不看对方是人还是 Agent。所以”给 Agent 单独放宽”这个想法只在按分钟计的消息额度上成立,每秒事件预算是两者共用的。

第三个边界容易被忽略:这道闸只拦 EVENTREQCOUNT 三类客户端消息,其余协议消息(认证握手本身就在其中)压根不进这段逻辑。所以它既不管你是谁,也不管你连上来做的每一件事。

被拒时,客户端收到的是 Nostr 协议层的 CLOSED(带订阅 ID 时)或 NOTICE(不带时),文本里会写清楚 retry in {reset_in_secs}s。同时打一个 buzz_admission_rejections_total 计数器,带 transportreason 两个标签,reason 区分 quotaunavailable。排障时先看这个标签,能省掉一整轮猜测。

三、第二层:邀请令牌,让陌生公钥变成成员

这一层才是”谁能进来”。buzz 里有两代实现同时在跑,理解它们的差异就理解了这个设计的演进方向。

v1 是无状态的 HMAC 令牌。 crates/buzz-relay/src/invite_token.rs 顶部的文档注释把格式写死了:

code = base64url(payload_json) + "." + base64url(hmac_sha256(key, payload_json))

payload 是个四字段的 JSON:c 是社区 UUID,r 是角色,e 是过期的 unix 秒,n 是随机 nonce。HMAC 是”用一把共享密钥给数据算的防篡改校验码”,没有密钥就伪造不出来。这把密钥不是单独配的,而是从中继自己的签名私钥派生:sha256(relay_secret_key_bytes || "buzz-invite-v1"),那个字符串标签是做域分离用的,避免同一把密钥在别处的 HMAC 用途互相污染。

验证函数 verify_invite 的顺序被注释特意点出来了:先做常量时间的 MAC 校验,通过之后才去信 payload 里的任何声明(过期、社区、角色)。而且错误变体故意做得很粗,InviteError 的五个变体在 HTTP 层几乎都被映射成同一个笼统拒绝,理由写在注释里——不让这个端点变成伪造邀请码的”预言机”。所谓预言机,是指一个肯如实回答”你哪一步错了”的接口:只要它肯区分”签名不对”和”签名对了但社区不对”,攻击者就能拿这个区别一点点校准手里的假码。统一回一个笼统拒绝,这条路就断了。入口处还有一道 MAX_CODE_LEN(1024 字节)的长度闸,在做任何解析之前就把荒唐输入挡掉。

角色被硬钉死在 member:铸造路由限制一次,verify_invite 里再检查一次。测试 signed_role_other_than_member_rejected 专门验证了”即使是一个 MAC 完全正确、角色写成 admin 的载荷,也必须验证失败”——防的是未来某个铸造调用方写出 bug。

v1 的坦白也写在同一段注释里:码在过期前是可以反复使用的,服务端没有”已使用”标记位;撤销手段很粗,只能轮换中继密钥对(那会让所有在途邀请一起失效),或者事后把人从成员表里删掉。

v2 把状态放回了数据库。 共享契约在 crates/buzz-core/src/invite.rs:前缀常量 V2_PREFIX"v2."V2_SECRET_LEN 是 32 字节随机数,validate_v2_code 不光解码,还把解码结果重新编码回去比对,用来拒绝补 = 之类的非规范别名编码。hash_v2_code完整的码(含前缀)做 SHA-256——测试里专门断言了带前缀和不带前缀的哈希不相等。

对应的表在 migrations/0025_relay_invites.sql。这张表只存 token_hashCHECK (length(token_hash) = 32)),从不存明文的 bearer 密钥,所以数据库泄露不等于直接拿到可用邀请码。role 上挂着 CHECK (role = 'member'),从 schema 层面保证邀请链接永远给不出管理员。max_uses 允许为 NULL(不限次),非 NULL 时约束在 1 到 10000 之间。唯一键是 (community_id, token_hash)——迁移文件的注释说明了原因:不存在”只按哈希跨租户查”的路径,A 社区的码拿到 B 社区去只会得到 Invalid。

兑换的原子性在 crates/buzz-db/src/relay_invite.rsSELECT FOR UPDATE 锁住邀请行,成员插入、加入政策证据插入、use_count 自增在同一个事务里提交,所以跨进程并发抢最后一个名额时只有一个能赢。结果被建模成 ClaimOutcome 的五个变体:JoinedAlreadyMemberExpiredExhaustedInvalid,HTTP 层可以映射成不同响应而不用去猜数据库错误。

生命周期常量都在 buzz-core/src/invite.rs:最短 60 秒(MIN_INVITE_TTL_SECS),默认 72 小时(DEFAULT_INVITE_TTL_SECS),最长 30 天(MAX_INVITE_TTL_SECS)。

路由层的两个判断值得单独记。 crates/buzz-relay/src/api/invites.rs 里,POST /api/invites(铸造)要求调用方在本租户社区里是 owneradmin,注释说这是对齐 kind:9030(NIP-43 的加成员事件)的授权。而 POST /api/invites/claim(兑换)故意豁免成员门——申请人本来就还不是成员,这里靠两件事顶住:NIP-98 签名证明调用方控制这个公钥,码上的 HMAC 或数据库行证明有管理员授权过这次加入。

兑换路径还有一道进程内的固定窗口限速,窗口 60 秒、每个公钥 10 次,缓存容量硬顶在 10000 个公钥。注释解释得很到位:NIP-98 只证明你持有某把密钥,不证明造一把新密钥有多贵,所以如果不设容量上限,限流器自身就会变成内存耗尽的攻击面。

加入政策是挂在邀请流程上的第四件事。 运营方可以配 join_policy(条款 markdown、隐私 markdown、是否要求年龄声明、版本号)。客户端先调 POST /api/invites/accept-policy 换一张收据,收据由 mint_policy_acceptance 铸造,载荷里 csha256(邀请码)v 是政策版本、e 是十分钟后的过期时间。兑换时必须带上这张收据,verify_policy_acceptance 会同时校验它绑的码和版本对不对得上。落库证据在 migrations/0020_join_policy_acceptances.sqlpolicy_version 上有 CHECK (length(policy_version) = 64),外键指向 relay_members,成员被删时证据级联删除。

四、第三层:webhook 密钥,给没有身份的调用方开的门

前两层的主体都有 Nostr 公钥。webhook 这条路上没有——发起方是 GitHub、CI、某个内部系统,它们不签 Nostr 事件。

密钥的存法在 crates/buzz-relay/src/webhook_secret.rs:存在 workflow 定义 JSON 的 "_webhook_secret" 键下。为什么要塞进定义里而不是单开一列?注释给了理由——这样 definition_hash 才能覆盖到它。随之而来的是一条硬顺序契约,文件顶部用四步伪代码写死了:先构建定义,再 inject_secret然后definition_hash,最后落库。注释还点名说,2 和 3 反过来是之前踩过的一个 bug,那样算出来的哈希永远对不上存下来的定义。

出参方向有 strip_secret,返回给 API 调用方之前把这个键滤掉;密钥只在创建时通过一个独立的 webhook_secret 字段返回一次。

比对用 verify_secret,XOR 折叠成一个字节再判零,不在第一个不同的字节处短路,防的是通过响应延迟一位一位试出密钥。注释也如实交代了没做到的部分:长度不等会立刻返回,这一步不是常量时间;理由是密钥由 generate_webhook_secret 生成,就是一个 UUID v4 的连字符字符串(注释说这给出 122 位随机性),长度恒定为 36 字节,攻击者本来就知道,泄露长度不提供额外信息。

触发端点是 POST /hooks/{id},实现在 crates/buzz-relay/src/api/bridge.rsworkflow_webhook。这个函数的执行顺序本身就是一份门禁清单:

  1. 先从 Host 头绑定租户。注释把这一步叫”row zero”——租户由 host 决定,不由 workflow 行决定,所以同一个 workflow UUID 在两个社区都存在时,A 社区的 host 只能碰到 A 社区的那一行。未映射的 host、查询失败、本社区不存在这个 workflow,三种情况一律回同一个笼统 404,让调用方探测不出别的租户有什么。
  2. 检查触发器类型确实是 webhook。
  3. 取密钥比对。优先读 X-Webhook-Secret 头,读不到才回落到 ?secret= 查询参数——注释给的理由是”头不会被大多数代理记进日志”。如果这个 workflow 压根没配密钥,返回 401,提示重新保存工作流以生成一个。
  4. SEC-006 那一段是这层最关键的判断:webhook 密钥认证的是调用方,但这次运行用的是 workflow 拥有者的常驻权限,所以密钥本身不够。创建运行记录之前,要再检查 workflow 是否 enabled、状态是否 Active、是否有频道作用域,然后调 check_owner_authority 重新核一遍拥有者当前的频道成员身份和角色。全部失败都退化成同一个 404——一个拥有者已被撤权的 workflow,对外表现得和不存在一样。

这一段值得工程上多想一层:握着 webhook 密钥的人,实际调用的是别人的权限。密钥不该被当成”只能触发一个无害动作”的低危凭据。

五、这套门禁明确不管什么

设计取舍必然有代价。把不管的部分列清楚,比列能力清单更有用。

组成部分它负责什么仓库位置你什么时候会碰到它
准入判定已认证主体的速率配额与 fail-closed 拒绝crates/buzz-relay/src/admission.rs、调用点 crates/buzz-relay/src/connection.rs客户端收到 rate-limited 的 CLOSED/NOTICE 时
限流类型与默认值定义 LimitType 与各层级额度字段crates/buzz-auth/src/rate_limit.rs调参、判断人与 Agent 走了哪条额度时
成员判定开放/私有中继的总开关与四态判定crates/buzz-relay/src/config.rscrates/buzz-relay/src/api/mod.rs决定要不要开 BUZZ_REQUIRE_RELAY_MEMBERSHIP
v1 邀请令牌无状态 HMAC 码的铸造与验证crates/buzz-relay/src/invite_token.rs排查老邀请链接为什么还能用时
v2 邀请契约前缀、长度、规范编码、哈希crates/buzz-core/src/invite.rs需要限次邀请、或自己写客户端时
邀请落库与兑换原子兑换、次数扣减、跨租户隔离migrations/0025_relay_invites.sqlcrates/buzz-db/src/relay_invite.rs并发兑换、名额对不上时
邀请 HTTP 路由铸造授权、兑换豁免、claim 限速、政策收据crates/buzz-relay/src/api/invites.rs接入自己的邀请页时
webhook 密钥生成、注入、剥离、常量时间比对crates/buzz-relay/src/webhook_secret.rs定义哈希对不上、密钥泄进响应体时
webhook 触发端点租户绑定、密钥校验、拥有者权限复核crates/buzz-relay/src/api/bridge.rs外部系统触发工作流返回 404/401 时

准入判定不做身份判断。 未认证连接直接跳过这层。你不能靠它挡住陌生人,它挡的是已进门者的过量动作。

开放中继下成员表形同虚设。 require_relay_membership 默认 false,此时 check_relay_membership 第一行就返回 OpenRelay,你往 relay_members 里加多少行都不影响谁能连。“我配了 owner 所以是私有的”——这个推断是错的。

邀请码是 bearer 凭据。 谁拿到链接谁就是被邀请的人,中间没有二次确认。v1 更松:过期前可反复使用,没有次数概念。要限次必须走 v2 并在铸造时传 max_uses

撤销粒度很粗。 v1 的单码撤销做不到,invite_token.rs 的注释直接写了,per-code 撤销需要 relay_invites 表这个增量。而轮换中继密钥对会把所有 v1 邀请码和已发出的政策收据一起作废——因为它们的 HMAC 密钥都从同一把中继私钥派生。

claim 限速是进程内的。 state.invite_claim_rate_limiter 是本进程的缓存,多副本部署下每个副本各有一份配额。跨副本的共享计数走的是另一条路(admission 那条),别把两者混为一谈。

webhook 这条路上没有 NIP-98 那样的重放检查。 邀请路由里明确调了 check_nip98_replayworkflow_webhook 里没有对应的东西,它就是一次 bearer 比对。是否需要幂等,得你自己在工作流那侧兜。

密钥自持不可逆。 成员身份绑在公钥上,用户丢了私钥,中继这边没有任何恢复路径——只能由 owner/admin 重新把新公钥加成成员。

这三层都不管进门之后的事。 Agent 进来能读哪些频道、能不能改仓库、发出去的事件会不会被审核,那是频道授权、角色和审核链路的范畴,不在本文这三个文件里。

六、上手与避坑清单

1. 别以为配了 owner 就是私有中继。 会踩是因为 RELAY_OWNER_PUBKEY 名字听起来像门禁,实际它只是”谁被自动 bootstrap 成 owner 角色”。避法:显式设 BUZZ_REQUIRE_RELAY_MEMBERSHIP=true,并且准备好 BUZZ_RELAY_PRIVATE_KEY——少了它中继会在启动时直接报错退出,理由是临时密钥签的 NIP-43 事件重启后没法验证。

2. 别把 v1 邀请链接当一次性码发。 会踩是因为大多数 IM 的邀请链接默认限次,而 v1 的注释白纸黑字写了”multi-use until expiry”。避法:确认你的铸造走的是 v2 路径(码以 v2. 开头),并在请求体里传 max_uses

3. 别顺手轮换中继密钥对。 会踩是因为轮换看起来是个孤立的安全操作,实际邀请 HMAC 密钥是从中继私钥派生的,轮换会连带作废所有在途 v1 邀请和政策收据。避法:把轮换当成一次会影响所有待入场用户的变更来排期,轮换后重发邀请。

4. 别用 ?secret= 传 webhook 密钥。 会踩是因为它比配请求头省事,很多 CI 的配置界面也只给 URL 一个框。避法:用 X-Webhook-Secret 头。查询参数会进访问日志、代理日志和各种链路追踪,密钥一旦进日志就等于泄露。

5. 改完 workflow 定义别急着算哈希。 会踩是因为”注入密钥”和”计算哈希”看起来是两件独立的事。避法:严格按 webhook_secret.rs 顶部写的四步顺序——构建定义、inject_secret、算 definition_hash、落库。顺序反了,之后每次比对都会失败。

6. 别把 workflow 定义原样返给前端。 会踩是因为定义就是一个 JSON,直接 Json(workflow.definition) 太顺手了。避法:出参前过一遍 strip_secret

7. 别把 Unavailable 当网络抖动重试。 会踩是因为客户端看到的都是 “rate-limited” 字样。避法:看 buzz_admission_rejections_totalreason 标签——quota 是真的超额,等 reset_in_secs 就好;unavailable 是共享计数器故障,客户端重试多少次都会继续被拒,要去看 Redis。

8. 别用 claim 限速当作暴力破解的唯一防线。 会踩是因为它确实叫 rate limiter。避法:记住它是进程内的固定窗口,副本一多总配额就翻倍;真正撑住这条线的是 v2 码的 32 字节随机量和数据库里只存哈希这两个设计。

七、收束

这篇只拆了一个具体仓库里”谁能连上来”这一段。团队层面怎么保管和轮换各类密钥,看 API Key 安全管理;Agent 通过工具协议对外动手时的边界怎么划,看 MCP 安全边界;数据往模型侧流动的风险面,看 AI 数据安全风险。这三篇讲的是通用工程原则,本文讲的是一份可以当场打开核对的具体实现——两者互补,别互相替代。想再往外看一层,最小权限设计开源 Agent 平台盘点 也在同一条线上。

给你一份上线前的自检清单:

  • BUZZ_REQUIRE_RELAY_MEMBERSHIP 的值是你想要的吗,你确认过而不是假设过吗?
  • 如果开了成员制,BUZZ_ALLOW_NIP_OA_AUTH 你是有意打开还是默认没管?Agent 通过 owner 进场是不是你要的语义?
  • 你发出去的邀请是 v1 还是 v2?有没有一批过期时间很长、次数不限的老码还在别人的聊天记录里?
  • webhook 密钥是走头还是走查询参数?历史日志里有没有已经泄出去的?
  • 每个配了 webhook 的 workflow,它的拥有者现在还有相应的频道权限吗?

接下来该读哪个文件,取决于你卡在哪一层:想看被拒消息怎么发回客户端,读 crates/buzz-relay/src/connection.rsenforce_ws_admissionsend_admission_result 这两个函数;想看兑换的并发语义,读 crates/buzz-db/src/relay_invite.rs 顶部那段关于 SELECT FOR UPDATE 的注释;想看第三方客户端怎么接进来,读根目录的 NOSTR.md:里面有「Relay Membership (NIP-43)」一节讲 buzz-admin 增删成员的命令用法,也用 What Works / What Doesn’t Work 两段列了直连客户端当前能做什么、做不到什么——这份”做不到”的清单,比能力清单更能帮你判断要不要走第三方客户端这条路。

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源 buzz 多 Agent 通信平台:中继内部三层转发buzz 中继网状互联:Block 开源多 Agent 平台的四块拼图

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。