Block 开源 buzz 多 Agent 平台的设备配对:一份 NIP 规范加一份形式化模型

2026-08-05

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

在一套密钥自持的系统里,最容易被攻破的不是加密算法,而是「把新设备接进来」这三秒钟。 Block 开源的多 Agent 通信平台 buzz 把这三秒钟单独拎出来,写了一份协议规范、一份 Rust 实现、一份 Tamarin 形式化模型,还配了一个只为握手服务的一次性中继和一个联调用的命令行工具。这个投入比例本身就是一个判断:配对不是 UI 上的一个扫码框,它是整个身份体系的入口闸门。

先把底座讲清楚。buzz 建在 Nostr 之上:每条消息都是一个带 Schnorr 签名的事件(event)——Schnorr 是 secp256k1 曲线上的一种签名算法,Nostr 全网统一用它,验签逻辑在 crates/buzz-core/src/verification.rs 里,同时校验事件 ID 的哈希和签名本身。事件按 kind 编号区分用途,由中继(relay,一个存转事件的 WebSocket 服务)负责收发。用户和 Agent 各持自己的私钥,签名即身份——这就是密钥自持(self-custody)。它的代价很直白:没有找回密码这回事,私钥丢了等于身份丢了,私钥泄漏等于身份被人接管。仓库 README 把 buzz 定位成人和 Agent 共处同一批房间、跑在你自己拥有的中继上的协作空间,Agent 拥有自己的密钥与审计轨迹——这是项目自己的说法,不是本文替它下的结论。

本站已有几篇相邻主题:AI 权限模型怎么设计 讲的是拿到身份之后能做什么,API Key 安全管理 讲的是长期凭据怎么存怎么轮换,Agent 权限台阶 讲的是权限怎么分级放开。本篇只管一件事:身份凭据从一台设备搬到另一台设备的那一次传输,怎么才算做对了。

一、配对为什么是最脆的一环

密钥自持体系的日常状态其实相当稳固:私钥不出设备,每次操作本地签名,中继看到的只是签名后的字节。整条链上唯一需要把私钥(或等价的授权凭据)跨设备搬运的时刻,就是配对。

这个时刻脆在三处。

第一,它必须借助一条你不信任的通道。两台设备并不在同一个进程里,中间隔着网络和中继。规范文件 crates/buzz-core/src/pairing/NIP-AB.md 的动机一节列了配对在实践中的三种常见做法:直接粘贴 nsec 私钥、走 NIP-46 远程签名、手输 NIP-06 助记词。第一种明文过网且无认证,第三种手动易错,第二种解决的其实是「长期委托签名」而不是「一次性搬运」——两类需求并不重合。

第二,它是唯一一个用户会被要求「确认一下」的环节,而用户确认历来是最容易被绕过的一环。规范的安全考虑一节点名提到,Signal 的设备链接因为省掉了短认证码比对而被伪造二维码利用过。这不是理论风险。

第三,一旦配对完成,这套协议的安全保证就结束了。规范的局限一节写得很直接:没有吊销机制,配对完成后无法作废;目标设备日后被攻破,转过去的密钥就是被攻破的;密钥同时存在于两台设备,是密钥转移相对远程签名的固有代价。换句话说,配对是一次性的、不可撤销的、结果由收方设备的存储安全兜底的操作。这样的操作,只能靠事前把闸门做严。

二、这份 NIP 规范到底规定了什么

NIP 是 Nostr 生态里的协议提案文档格式。buzz 仓库的 docs/nips/ 下放了 15 份 NIP 规范 md 加 2 份 fixtures json,而配对这一份没有放在那里——它躺在实现代码旁边,路径是 crates/buzz-core/src/pairing/NIP-AB.md,是全仓第 16 份 NIP 文档。规范里标注的状态是 draft optional

协议的骨架是这样的。持有密钥的一方叫 source(比如桌面端),想拿到密钥的一方叫 target(比如手机)。source 现场生成一对临时密钥和一个 32 字节随机的 session secret,编码成 QR:

nostrpair://<source_ephemeral_pubkey_hex>?secret=<session_secret_hex>&relay=<wss://relay.example.com>&v=1

这行 URI 里有两处细节容易在自研实现里做丢:relay 的值是百分号编码过的完整 WebSocket URL,不是裸地址;而且它允许出现多次,规范把二维码本身当成权威中继列表,另有一节专门讲多中继下该怎么退避。

target 扫码后生成自己的临时密钥对,通过 QR 里指定的中继发出一个 offer。所有配对消息都走同一个事件 kind:24134(在 crates/buzz-core/src/kind.rs 里定义为常量 KIND_PAIRING),内容用 NIP-44 v2 加密——NIP-44 是 Nostr 生态里那份定义端到端加密载荷格式的规范,v2 是当前在用的版本——收件人写在 p 标签里。中继看到的只有两个临时公钥之间的密文,跟真实身份对不上号。

真正撑起安全性的是三次密钥派生,都是 HKDF-SHA256,都带不同的域分隔标签,实现在 crates/buzz-core/src/pairing/crypto.rs。HKDF 是一个把「一段有熵但不均匀的原始材料」拉伸成定长密钥的标准做法,分 extract 和 expand 两步,输入除了原始材料还有 salt 和 info;域分隔说的就是 info 这一栏——同一份原始材料配不同的 info 标签,派生出的三个值在密码学意义上互不相关,拿到其中一个推不出另外两个。这三个标签在代码里是写死的常量:

const INFO_SESSION_ID: &[u8] = b"nostr-pair-session-id";
const INFO_SAS: &[u8] = b"nostr-pair-sas-v1";
const INFO_TRANSCRIPT: &[u8] = b"nostr-pair-transcript-v1";

第一次派生出 session_id,target 把它放进 offer 里,等于在不泄漏 session secret 的前提下证明自己扫到了那张码。第二次以 ECDH 共享点的 x 坐标为输入材料、session secret 为 salt,派生出 sas_input——ECDH 是椭圆曲线上的密钥协商:两边各拿自己的私钥乘对方的公钥,算出同一个点,中间只交换过公钥,旁观者拿不到这个点——再取前 4 字节大端解释成整数对 1000000 取模,得到 6 位十进制的 SAS(Short Authentication String,短认证字符串)。两台设备各算各的,算出来一样才说明中间没人。第三次把 session_id、双方公钥、sas_input 拼成 128 字节转录,派生出 transcript_hash,由 source 在 sas-confirm 里发给 target 核对。

值得单独说一句 ECDH 那一步的坑。规范在密码学原语一节挂了一个警告:不少 secp256k1 库默认会把 ECDH 结果做一次 SHA-256,而这份协议要的是未经哈希的 x 坐标。这种「库默认行为和规范默认行为不一致」的地方,是跨实现互通时最常见的失败点,规范特意要求上线前先验证自家库的行为。

三、四道闸门是怎么摆的

从代码看,这套设计把「能不能继续」拆成了四道独立的闸,任何一道不过都不进入下一步。

第一道在扫码解析。crates/buzz-core/src/pairing/qr.rsdecode_qr 在做任何密码学操作之前,先把 URI 本身审一遍:总长超过 2048 字符直接拒(规范里 MAX_URI_LEN 就是这个值);公钥和 secret 都必须是恰好 64 个小写十六进制字符,大写都不收;secret 全零拒收;relay 必须能被 URL 解析器解析出来,scheme 只认 wssws,且必须有 host;v 不是 1 就拒。代码注释里点了为什么不用前缀匹配判断 relay——前缀匹配会放过畸形 URL,把崩溃留给下游。

第二道在 offer 校验。session.rshandle_offer 先查会话是否过期、状态是否为 Waiting、角色是否为 Source,再验事件签名、查重复事件 ID、核对 kind、核对 p 标签是否指向自己,然后才解密。解出来的 session_idct_eqsubtle::ConstantTimeEq 包的常数时间比较)跟本地派生值比对,不匹配返回 InvalidSessionId。通过之后立刻把对端公钥锁死,后续所有事件都只认这一个作者。

第三道是双人确认。这是整套设计里最反直觉、也最要紧的一处:source 侧用户确认 SAS 之后,target 侧还得再确认一次。代码上体现为一个单独的状态 AwaitingConfirmation 和一个必须显式调用的方法 confirm_target_sas。source 发完 sas-confirm 就紧接着发 payload,不等对方回执,所以密文可能在 target 用户点头之前就已经落地了——协议要求 target 在两个条件都满足之前不许把 payload 当密钥用。仓库的单元测试 target_must_confirm_sas_before_payload 就是在钉这条:跳过 confirm_target_sas 直接调 handle_payload,必须报错。

第四道是转录哈希核对。target 在 handle_sas_confirm 里自己算一遍 transcript_hash,同样用常数时间比较,不一致就把状态打到 Aborted 并返回 TranscriptMismatch。规范特别澄清了这一步的定位:它是检测机制,不是拦截闸门,因为 payload 可能已经在路上了。真正阻止中间人的是 source 侧用户在按下确认之前的那一次肉眼比对。

组成部分它负责什么仓库位置你什么时候会碰到它
协议规范正文定义 URI 格式、事件结构、状态迁移表、常量、测试向量crates/buzz-core/src/pairing/NIP-AB.md做互通实现、对齐字段名时
Tamarin 形式化模型在 Dolev-Yao 攻击者假设下证明协议性质crates/buzz-core/src/pairing/NIP-AB.spthy想知道哪些结论被证过、哪些没有
HKDF 派生函数derive_session_id / derive_sas / derive_transcript_hash / format_sas / ct_eqcrates/buzz-core/src/pairing/crypto.rs自己实现一遍并对测试向量时
QR 编解码与校验encode_qr / decode_qr,长度、大小写、全零、relay scheme 校验crates/buzz-core/src/pairing/qr.rs处理扫码入口、排查「码扫不进去」时
会话状态机PairingSession 与 7 个 SessionState,纯计算不做 I/Ocrates/buzz-core/src/pairing/session.rs接自己的 UI 或传输层时
消息类型定义PairingMessage 五种消息、PayloadTypeAbortReasoncrates/buzz-core/src/pairing/types.rs需要知道线上 JSON 长什么样时
事件 kind 常量KIND_PAIRING = 24134crates/buzz-core/src/kind.rs写中继订阅过滤器时
联调命令行二进制 buzz-pair,子命令 source / target / test-vectorscrates/buzz-pairing-cli/想在两个终端里跑通一遍时
握手专用中继只服务配对握手的一次性中继,资源上限写死crates/buzz-pair-relay/不想让握手流量走主中继时

状态机本身值得看一眼。session.rsPairingSession 明确写了「纯计算,无 I/O,无 async」,中继通信和用户交互全留给调用方。这个切法的好处是状态机可以被穷举测试:仓库里那一组测试覆盖了乱序调用、重复 payload、错误作者、过期会话、complete(success: false)、以及一个挺细的场景——先用 handle_abort 探测一个非 abort 事件失败之后,真正的处理函数还必须能接受同一个事件(测试名 speculative_abort_does_not_poison_dedup)。去重集合只在消息被完整接受后才由 record_event 写入,就是为了不让探测性调用污染它。

四、为什么还要额外写一份形式化模型

形式化验证是指把协议翻译成一套符号规则,交给工具穷举所有可能的消息交错顺序,看某个性质是否在所有路径上都成立。Tamarin 是这类工具中的一个。buzz 把模型放在 crates/buzz-core/src/pairing/NIP-AB.spthy,455 行,规范里给出的运行方式是:

tamarin-prover --prove crates/buzz-core/src/pairing/NIP-AB.spthy

模型把中继和网络整个当成 Dolev-Yao 攻击者——也就是假设对手可以任意拦截、重排、重放、丢弃和伪造消息,同时额外加了三条泄漏规则:二维码被拍走、source 侧会话被攻破、target 侧会话被攻破。在这些假设下,文件里一共写了 16 条 lemma(待证性质),涵盖 happy path 可达性、payload 必须在 SAS 匹配之后才发得出去、无端点失陷时 payload 对攻击者保密、target 完成即代表 source 确实发过同一份 payload、每次完成对应唯一一次发送、SAS 匹配必定绑定到一个真实生成过的 target 临时公钥、以及「target 在转录校验与用户批准都完成之前不解密 payload」。

对做工程的人来说,这份模型的实际价值不在「证明了很安全」,而在它同时列清了没有证什么。规范的形式化验证一节自己给了那份排除清单:NIP-01 事件 ID 与 Schnorr 签名、NIP-44 的密文分帧填充与 nonce 处理、HKDF 的 RFC 5869 内部细节、ECDH 的 secp256k1 x 坐标提取、超时与 abort 分支、重复事件簿记、p 标签校验与会话内重放防护、版本协商、complete 的成败语义、payload 类型——这些统统不在符号模型里,仍旧是实现层必须自己扛的。SAS 比对在模型里被当成完美的,而实际它只有约 20 位熵(10 的 6 次方),一次盲猜命中的概率是百万分之一,这是一条独立的计算复杂度论证,不是被证出来的。

这种「证了什么 / 没证什么」的分界,比证明结论本身更值得抄。你自己做安全设计时,能把这两栏都写出来,评审就有了下手的地方。

五、边界与代价

这套设计的取舍相当明确,规范里自己列了一整节局限,挑几条影响判断的说。

它只保一次性传输,不保后续。 传完这一刻起,密钥的安全完全取决于收方设备的存储和用户的操作习惯。规范要求实现必须把导入的密钥写进平台安全存储(iOS Keychain、Android Keystore、桌面凭据管理器),但这已经在协议管辖范围之外了。

它不支持吊销。 完成的配对没有作废机制。目标设备后来丢了,你唯一能做的是换身份。

它不做多设备编排。 一次只往一台设备转。N 台设备要跑 N 次独立会话。

它对元数据不做遮蔽。 中继读不到内容,但看得到配对事件的时间和大致频率。规范给的缓解手段是把 created_at 随机往前挪 0 到 30 秒,session.rsbuild_event 里实现为对 31 取模的抖动;同时明确禁止把时间戳设到未来、也不许往前挪超过 60 秒,因为有中继会按 NIP-11 的下限拒收。这是隐私和送达率之间的实际折中,规范说得清楚:送达优先。

它不抗量子。 ECDH 密钥交换在足够强的量子计算机面前不成立,NIP-44 这层继承同样的限制。

它假设物理在场。 SAS 靠人眼比对两块屏幕,一个同时能碰到两台设备的攻击者可以直接绕过这一步。二维码里的 session secret 在会话超时(120 秒)之内是暴露在屏幕上的,录屏、偷瞄、被控制的摄像头都能拿到。

它不是批量传输通道。 规范给的上限是解密后 JSON 明文不得超过 65535 字节,payload 字段的安全实际值 MAX_PAYLOAD_LEN 是 65400 字节;对已定义的 nsec / bunker / connect 三类,预期远低于 1024 字节,实现可以自行收紧到 4096。超过 4096 字节的自定义 payload,规范建议先想清楚是不是该换个传输方式。

还有一条要如实说:自建那个握手中继,意味着你多了一条对外的 WebSocket 通路,得先搞清楚数据落在哪、暴露面在哪一层。先说数据:crates/buzz-pair-relay 的模块文档写明它不做持久化、不做鉴权、不存历史,事件只在匹配到订阅的那一瞬间在内存里过一道就转走,磁盘上不留东西;内容本身是 NIP-44 密文,中继读不了。再说暴露面:这个二进制默认只绑回环地址(crates/buzz-pair-relay/src/main.rs 里默认 127.0.0.1:5000,可用环境变量 BUZZ_PAIR_RELAY_BIND_ADDR 覆盖),文档明确要求它跑在反向代理后面,由代理负责终止 TLS、只把 /pair 这一条路由过来、以及施加 HTTP 读超时,因为中继自己不做路径限制和升级前的连接数限制。也就是说真正暴露在公网上的是你那台反向代理,配错代理(比如把整个上游透传、或者忘了超时)比中继本身更容易出事;而一旦有人真的把绑定地址改成 0.0.0.0 图省事,就等于把这层保护直接摘掉了。中继侧自己的护栏是把资源上限写死在代码里——最大 128 个连接、单帧上限 4096 字节、连接超时 120 秒、去重与投递记录条目 300 秒过期、单连接最多接受 6 个事件、同一个 p 收件人最多投递 12 次,订阅过滤器只接受 kinds[24134]。这些是硬编码的护栏,不是配置项,调不了也就意味着你没法在生产上临时放宽——真遇到连接数不够,得改代码重新编。合起来看,代价是清楚的:多一个要长期看着的对外入口、多一份反代配置要维护,换来的是握手流量不跟主中继混在一起。值不值,取决于你的主中继是不是本来就对公网开着。

六、上手与避坑清单

ECDH 输出别用库的默认值。 会踩,是因为很多 secp256k1 绑定默认对共享点做一次 SHA-256,而这份协议要求原始的未哈希 x 坐标。避法:先跑规范里那组固定测试向量,对上 ecdh_shared 的期望值 9b4b6d69...4d4408 再往下写,或者直接用 buzz-pair test-vectors 打印一遍全套派生值对照。

别把 target 侧的确认省掉。 会踩,是因为从产品视角看「两边都要点一次」显得啰嗦,很容易在做 UI 时合并成一次。避法:把 AwaitingConfirmation 当成不可跳过的状态实现,并照着 target_must_confirm_sas_before_payload 那个测试写一条自己的用例——payload 提前到达时必须仍然进不去。

别在 payload 到达时就顺手解出来记日志。 会踩,是因为调试期加一行打印是最自然的动作,而 payload 里就是私钥。规范允许为了消息类型分派而提前解密,但明确要求在双重同意达成前不得反序列化、提取、记录、持久化或使用 payload 字段,会话中止时还要清零;最贴近形式化模型的做法是干脆缓存密文,等确认后再解。

别对乱序消息回 abort。 会踩,是因为把「收到不该收的消息就报错」当成健壮性写法。规范要求乱序消息静默丢弃,理由是回 abort 等于给中继提供了探测会话状态的信号;protocol_error 这个 reason 只留给本地致命错误。

别忘了大小写与全零这两条校验。 会踩,是因为 hex 解析器通常大小写通吃,全零 secret 也能解析成功。避法:照 qr.rs 那样在解析前先做字符集检查(is_lowercase_hex),并单独拒掉 32 字节全零的 secret,仓库里 reject_uppercase_hex_in_secretreject_all_zeros_session_secret 两个测试就是钉这两条的。

别把 relay 校验写成前缀匹配。 会踩,是因为 starts_with("wss://") 看起来够用。避法:完整解析 URL,检查 scheme 属于 ws / wss 且 host 存在,否则畸形 URL 会一路传到连接层才炸。

多中继场景下别重新构造 offer。 会踩,是因为换中继重发时习惯性地重新构建事件。规范要求重发那个已经签好名的同一个事件(字节相同、事件 ID 相同),source 侧靠事件 ID 去重,重新构造会破坏这个假设。

联调先在本地跑通再上公网中继。 buzz-pairing-cli 提供的二进制是 buzz-pairsource 子命令带 --relay--nsectarget 子命令带 --relay 覆盖和 --show-secret。注意 --show-secret 默认关闭是有道理的,它会把收到的密钥打到标准输出,别在共享终端或有录屏的会议里开。

收束

把这一篇压成一份自检清单,你评审任何一套设备配对设计时可以逐条过:QR 里有没有出现私钥材料;解析入口有没有在做密码学之前先做长度、字符集、全零、scheme 四类校验;有没有一个人眼可比对的短码,位数够不够;这个短码是不是用带域分隔的 KDF 独立派生出来的;两侧是不是都要确认;密文到得比确认早的时候,实现有没有真的忍住不解;会话有没有硬超时和一次性;密钥落地写的是不是平台安全存储;以及最后一条——这套东西不管的部分,你有没有写下来。

想继续往里读,路径顺序建议是:先 crates/buzz-core/src/pairing/NIP-AB.md 通读一遍拿到全貌,再 crypto.rs 对测试向量建立信心,然后 session.rsnew_source 一路读到 handle_complete 看状态怎么迁移,最后翻 NIP-AB.spthy 里那 16 条 lemma 的名字,把「证过的」和「没证的」各抄成一列。至于配对完成之后 Agent 拿着自己的密钥能做什么、又该被限制在哪里,那是最小权限设计要回答的问题,跟本篇是接力关系。

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源多 Agent 通信平台 buzz 的工作流执行器拆解Block 开源 buzz:多 Agent 通信平台的两处形式化验证

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