它不是一个聊天软件,是给 libp2p 套了个 egui 外壳 宣传语说"端到端加密""去中心化",我一开始以为是营销词。拆完发现是字面意思。
无尘阁 = Rust + libp2p 全栈 + egui 桌面壳。你的身份是一个 Ed25519 密钥对, 你的地址是一串 multiaddr,你发给朋友的"连手口令"是一张自签名、带 TTL 的入场券。 消息不经过任何业务服务器 —— 能直连就直连,连不上就走内置的腾讯云中继。
这个结论不是猜的:二进制里 libp2p_tcp / libp2p_noise /
libp2p_kad / libp2p_dcutr / libp2p_relay
这些符号全在,还带着 0.4x 的具体版本号,说明没做符号剥离。
从下往上,五层
.Agent 把好友的模型当 API 调;
大文件走流式 chunk 分片(内部图资源里有 bbr_file_transfer_stream 一项,说明做过拥塞控制调优)。12D3KooW… 开头的 ID。1183 字节里,四分之三都在报地址 口令不需要保密 —— 它本来就是给陌生人看的。但它必须是可验证的,否则谁都能伪造一张。
口令长这样:p2pchat1: 前缀 + base64url。剥掉前缀解码,
得到 1183 字节的 CBOR(Concise Binary Object Representation,比 JSON 省一半体积)。
顶层只有两个键:body 和 signature。
这张图说明一个设计取向:身份信息是廉价的,路由信息是昂贵的。 一整套密码学材料只占 1/5,剩下全用来告诉对方"我可能在哪几个地址上等你"。
七个字段,逐个说
08 01 12 20 + 32 字节公钥。用来验签。如果这串东西是加密的,那"连手"就必须先有共享密钥,鸡生蛋问题。 所以它选了明文 + 自签名:内容人人可读,但改一个字节签名就对不上。 这正是能力凭证(capability token)的经典做法 —— 和 PASETO、macaroon 是同一个思路。
顺带一提:token 只有 16 字节且和 expires_at 绑在同一个 body 里,
意味着这张券一旦泄露,7 天内谁拿到都能连你。这不是漏洞,是明确的取舍。
我把 peer ID 亲手重算了一遍,逐字节对上 这是全文唯一一处"能自证"的地方:如果我的理解错了,这一步不可能碰巧正确。
口令里的 inviter_peer_id 是 12D3KooWDjrUimps…。
这个 12D3KooW 前缀不是随机字符串 —— 它是 identity multihash 的 base58 特征。
推导路径只有三步:
3a46da93…0d11
LowgnZVXFB…
# 我用 Python 从口令里抠出公钥,重算 peer ID pubkey = 080112203a46da937613e272c4911421a3f4f54f879e6369b20542c1b1824eab424c0d11 mh = 0x00 + 0x24 + pubkey # identity multihash, 38 B derived = base58btc(mh) derived : 12D3KooWDjrUimpsLowgnZVXFBsjT3oq1ZDvTboBPjNqEP5DEgi4 in token: 12D3KooWDjrUimpsLowgnZVXFBsjT3oq1ZDvTboBPjNqEP5DEgi4 MATCH : True
顺便说,这也解释了为什么无尘阁不用手机号注册:身份就是本地生成的一个密钥对。
没有账号数据库可查,也没有"找回密码"这条路径 —— 私钥删了,这个 ID 就永久消失了。
它把密钥存在 ~/.nexa-agent,所以那目录的备份比什么账号密码都重要。
14 条地址,是一张排好序的"逃跑路线图" 最值得看的部分。它把"怎么找到我"的优先级写死在口令里,按成本从低到高排。
这 14 条地址分三类,样本里的情况(地址已脱敏):
/ip6/2408:8207:2551:22d0:····:····:····:····/tcp/52955 ×2 组
/ip4/192.168.1.23/tcp/52954
/ip6/2402:4e00:c052:1800:····/udp/4001/quic-v1/p2p/…/p2p-circuit ×4 条
两个从地址里读出来的事实
一、客户端自带中继,而且用的是腾讯云。
中继地址 140.143.183.18 属腾讯云北京段,IPv6 侧是
2402:4e00:c052:1800::/64 —— 也是腾讯云。
也就是说,"无需注册即可连手"这句宣传背后,是厂商在替你承担中继带宽。
一旦对方在 NAT 后面打不通洞,消息就得从这台机器绕一圈。
二、它把本机回环地址也发布出去了。
127.0.0.1 和 ::1 对远端毫无用处 —— 那是对方自己机器上的回环。
这属于 libp2p 监听时"把所有 listener 原样塞进地址表"的常见副作用,无害,但白占了 107 字节,
还让口令长了 8%。
注意那条 /ip6/2408:8207:…:2408 是中国联通分配的 IPv6 段,
后面那串大概率就是邀请人的家庭宽带公网地址。192.168.1.23 则是内网拓扑。
也就是说 —— 把连手口令发到公开群里,等于把自己的出口 IP 和局域网网段一起发了。
这不是无尘阁的 bug,是 P2P 直连的必然代价:不告诉对方地址,对方就连不上你。 只是大多数分享链接的人不会想到这一层。如果你在意,办法是只发中继地址版本,或者用完即换口令。
连不上时,它按这个顺序降级
"一键连手"到底发生了什么 从二进制里的字段名能还原出完整的状态机:create_invite → PairingInviteV1 → AcceptedInvite。
它在本地维护一个 .invites.json,里面记着 PersistedOpenInvite
和 PendingInvite。配合这些字段名,流程大概是:
token,填当前所有可用地址,
定 7 天 expires_at,用 Ed25519 私钥签 body,base64url 编码。
同时把这张券写进本机 .invites.json,标记为"我发出去的、待接受"。device_public_key 验 signature,检查 expires_at 没过期,
再确认"公钥算出来的 peer ID"等于 inviter_peer_id。
这三步任一失败就报错 —— 二进制里对应的错误串是
invalid signature、unknown or expired invite、peer id does not match public key。token 发起 accept_invite,
邀请方查本地待接受列表。对不上就回 pairing token does not match an open invite。
核销成功后两边各自把对方写进 trust store(字段有 trusted_at、last_seen)。x25519_public_key 做 ECDH,HKDF 派生,
得到 ChaCha20-Poly1305 的会话密钥。从这里开始,消息内容连中继都看不到 ——
relay 只转发加了密的字节流。签名解决"这券是不是你发的",token 解决"这券是不是还没被人用过"。
两个问题不一样:签名可以永久有效(同一个 body 永远验得过),
但券只能核销一次 —— 所以需要一个随机数来标记"这一张"。
二进制里还有 record epoch is outside the accepted window 这种报错,
暗示它还有防重放的时间窗机制。
把任何一条 WCG 口令丢进来,当场拆开
纯前端解码,不上传、不联网。CBOR 解析器是我手写的 60 行 atob 递归。
这个解码器只做 CBOR 反序列化,不做签名校验(浏览器里验 Ed25519 需要额外 40 KB 的库, 为了一个演示页不值得)。想看签名是否有效,得用真客户端试连 —— 连不上就是无效。
本页样本口令里的 IPv6 地址是完整原文,而第 04 节的地址表我打了码。 这不是前后不一致:那张表是"脱敏示意",这串是"能用原文" —— 下载页要靠它完成一次真实连手,改了就连不上了。
所以这里也顺便提醒:既然你能看见,别人也能。发到群里的连手口令, 等于把出口 IP 一起发了。
它做得漂亮的地方,和有代价的地方 拆完之后,我既不想吹它,也不想踩它。分开说。
漂亮的三处
一、密码学材料全部用标准件,没有自创。 Ed25519 + X25519 + HKDF + ChaCha20-Poly1305 + Noise,全是 libp2p / IPFS 生态被审计过无数遍的组合。 没有看到"我们自研了一套加密算法"这种危险信号。签名密钥和加密密钥还做了分离(device 用于签名, x25519 用于协商),这是正确的做法。
二、Address-first 的降级设计。 14 条地址从"局域网"排到"中继兜底",本质是把连通性问题在发口令那一刻就尽量解决掉, 而不是等连接失败了再回服务器查。这让它真的能在没有业务服务端的条件下工作。
三、没有账号体系,也就没有账号泄露。
没有手机号、没有密码、没有可被拖库的用户表。身份是密钥,信任是本地的 trust store。
从这个角度说,"去中心化"不是修辞。
需要自己留意的四处
二进制里硬编码了 https://ai.a86.cn/v1 和一个内置 key,
另外支持 127.0.0.1:11434(Ollama)和 localhost:8080/a86-ai/v1。
也就是说,如果你不改配置,你的 .Agent 提问会走厂商的网关。
端到端加密保护的是"你和对方之间",保护不了"你和模型供应商之间"。
在意隐私的话,第一件事就是把模型端点换成自己的 Ollama。
16 字节 token + 7 天 TTL,没有单次使用限制,也没有撤销机制(除非邀请方主动删本地记录)。 转发到群里,就等于把这扇门开着 7 天。样本这张券 2026-11-19 09:43 到期。
打洞失败时全走 140.143.183.18 这一台(4 条 relay 地址都指向同一个 peer)。
内容它看不见(ChaCha20 加密过),但谁和谁在什么时候连了多久,它看得见。
去中心化的宣传和中心化的基础设施,在这里共存。
.Agent 的机制是:好友配好自己的模型和知识库,你连上他的机器去提问。
好处是不用自己买 API;代价是你的问题会出现在对方的机器上,
而对方的模型账单也在涨。它把"隐私"从"厂商 vs 你"变成了"你 vs 好友"。
我自己写了个客户端,把它连上了 没装它的客户端,从零写的 Python 实现:解口令、验推导、跑 Noise、把协议表读回来。
前面七节都是"读"出来的:拆包、读字符串、解 CBOR。这一节是"跑"出来的 —— 我按 libp2p 规范写了一个自己的节点,用口令里的中继地址去拨它,看能不能真握手。 结果成了。
# 本机节点(自己生成的 Ed25519 身份,跟它的无关) 12D3KooWQRXXcQ5F6cprHGmDeo4AcrdTUKuUhiVk5t6eJpsHmxAp # 拨号 /ip4/140.143.183.18/tcp/4001(口令里那台腾讯云中继) Noise 握手成功,加密会话已建立 # 开 /ipfs/id/1.0.0 流,读回对端自己报的家底 protocol_version : rust-p2p-chat-dht/0.4 agent_version : rust-libp2p/0.45.0 protocols : 9
它报回来的 9 个协议
/ipfs/ping/1.0.0 标准:探活 /ipfs/id/1.0.0 标准:自我介绍 /ipfs/id/push/1.0.0 标准:信息推送 /libp2p/circuit/relay/0.2.0/hop 标准:当中继跳板 /libp2p/circuit/relay/0.2.0/stop 标准:当中继出口 /libp2p/autonat/2/dial-request 标准:让对端帮忙回拨,测 NAT /rust-p2p-chat/pair/1.0.0 自有:配对(连手) /rust-p2p-chat/msg/2.0.0 自有:消息收发 /p2pchat/kad/1.0.0 自有:DHT 寻址
六个标准协议说明它没有自造轮子;三个 rust-p2p-chat/* 和
p2pchat/kad 才是它自己的业务层。注意 agent_version:
rust-libp2p/0.45.0 —— 它没改这个字段,等于把依赖版本直接报给了所有人。
更有意思的是那台中继:它同时开着 hop 和 stop,
还挂着一整套业务协议。也就是说它不只是个转发节点,它自己也是一个完整的应用节点。
"连手"就是打开 pair 这条流
我用同样的方式去开 /rust-p2p-chat/pair/1.0.0,
流开成功了 —— 协议协商通过、对端接受了这条流。但我不知道第一条消息该发什么:
试了整包 CBOR、单发 body、单发 token 三种形状,对端都不回应也不报错,等到超时。
传输层(TCP / Noise / 多路复用 / 协议协商)是可以纯靠规范复刻的,我确实复刻出来了。
但应用层是私有的:pair 请求长什么样、字段顺序、要不要先发 token、
对端怎么回错,全在它自己的 Rust 代码里,没有文档。这部分只能靠反编译读出来。
所以我上了反编译器
用的是 Ghidra(NSA 开源,带 decompiler,能把机器码还原成 C 伪代码)。
Windows 版是 2052 万字节的 PE,符号表被剥了,但 .pdata 异常目录完整 ——
32247 个函数边界,一个不少。有了这个,就不用猜函数从哪开始了。
定位方法是"以串找人":先在 .rdata 里找到
p2pchat1:、inviter_peer_id、
x25519_public_key 这些字符串的虚拟地址,再扫 .text 里
所有 RIP 相对寻址的 lea / mov,看谁引用了它们 —— 引用者就是处理口令的函数。
最后让 Ghidra 把这几个函数吐成 C。
这一步有结果了。全量分析这个体积的 Rust 二进制太慢(跑了一刻钟还在分析阶段),
我改成不做全量分析、按地址直接反编译 —— 用 capstone 先扫出引用这些字符串的函数,
再从 .pdata 拿到精确的函数边界,把这几个函数的 RVA 交给 Ghidra 单独吐伪代码。
口令是怎么写出来的:CBOR 序列化器
反编译出的 FUN_14057a2e0 一眼就能认出来是手写 CBOR 编码器 ——
它按字节往缓冲区里塞标签:
/* Ghidra 反编译输出(节选,原始行号保留在本地)*/ *(undefined1 *)(uVar2 + uVar4) = 0xa2; // map(2):body + signature ... *(undefined1 *)(uVar2 + uVar4) = 100; // 0x64 = text(4) = "body" ... *(undefined1 *)(param_3[1] + uVar4) = 0xa7; // map(7):body 里 7 个字段 FUN_1405801d0(&iStack_48, param_3, *(undefined1 *)(param_2 + 0x80)); // version FUN_14057fac0(..., &UNK_14103ea83, 0xf, *(param_2 + 8), *(param_2 + 0x10)); // 15 字节 key FUN_14057fac0(..., &UNK_14103ea92, 0x11, *(param_2 + 0x20), *(param_2 + 0x28)); // 17 字节 key FUN_14057fce0(..., &UNK_14103eaa3, 0x11, *(param_2 + 0x38), *(param_2 + 0x40)); FUN_14057fce0(..., &UNK_14103eab4, 0x11, *(param_2 + 0x50), *(param_2 + 0x58)); FUN_14057fce0(..., &UNK_14103eac5, 5, *(param_2 + 0x68), *(param_2 + 0x70)); // "token" FUN_140580570(&iStack_48, param_3, *(param_2 + 0x78)); // addrs 数组 FUN_14057fce0(..., &UNK_14103eace, 9, *(param_2 + 0x90), *(param_2 + 0x98)); // "signature"
读出来的六个 key,按写入顺序:
对照我解出来的口令:version、inviter_peer_id、device_public_key、x25519_public_key、token、
expires_at、addrs —— 结构对得上,唯独多了一个 requester_peer_id。
这说明同一套序列化器在写两种东西:邀请券(给谁发)和配对请求(谁在敲)。
请求方要把自己的 peer_id 填进去,签名,然后发到 pair 流上。
用自己生成的 Ed25519 身份填了 requester_peer_id、把 body 的 CBOR 签了名、
varint 前缀分帧,发到 /rust-p2p-chat/pair/1.0.0:流开成功了,写也成功了,
对端 8 秒不回一个字节。多半是签名对象跟我猜的不一样 ——
邀请券的签名我也验不过(试了 body CBOR、bincode、JSON、各种字段拼接,29 种组合全不中),
说明它签的不是"明文序列化结果",而是拼了前缀或走的是 prehashed 变体。
这段要求接着读 pair 的 handler,我暂时停在这里。
到这一步,边界很清楚:传输层全部打通了,应用层还差一条消息的格式。 自研客户端能做的:解口令、验推导、探地址、握手、读协议表。不能做的:真正完成配对。
把这些结论用上:把品牌真正改掉 不是改文件名 —— 是改二进制里的字节,然后用它自己证明改成功了。
读通序列化器之后,能做一件很实际的事:把原始安装包里的品牌串全部换掉,让程序自己启动起来的时候 显示的就是我的名字。这里有一个绕不过去的硬约束。
为什么只能"等长"替换
Rust 的 &str 不是 C 那种以 \0 结尾的字符串,
它是 (指针,长度) 二元组。长度是编译期算好、直接写进指令和 .rdata 里的常数。
你在中间插一个字,后面所有字节整体后移,所有 (指针, 长度) 全部错位 —— 程序不会报错,它会读越界、
拼出乱码、然后在某个莫名的地方崩掉。
所以做法只有一条:找等字节长度的替换。巧合的是这次几乎全对得上 ——
悟空来 和 无尘阁 都是 9 字节(3 个汉字 × 3),
wukonglai.com 和 jiangtian.top 都是 13 字节,
栋哥 和 江楠 都是 6 字节。
# 长度断言:不相等就直接失败,不允许"先试试看" assert len(a.encode()) == len(b.encode()) # 单遍扫描,长串优先。必须用"左侧优先"的分支顺序, # 否则 悟空来 会先吃掉 悟空来-LAI 的前半截,留下 "-LAI" 这种断茬 PAT = re.compile(b"|".join(re.escape(a.encode()) for a, _ in TABLE)) out = PAT.sub(_sub, buf) assert len(out) == len(buf) # 20521472 → 20521472,一字不差
无尘阁-WCG-ma来-LAI-Windows- 这种断茬。替换表:15 条规则,3031 处
这些串不是靠 strings 瞎猜的 —— 是先用 08 节那套 「以串找人 + Ghidra 按地址反编译」定位到
字符串池,看清楚哪些是 &str 字面量、哪些是代码里的立即数,再动的手。
原文 替换 字节
悟空来 (LAI) 无尘阁 (WCG) 15 → 15
悟空来-LAI-Windows-x64.zip jiangtian-WCG-Windows-x64.zip 29 → 29
悟空来-LAI-macOS-arm64.zip jiangtian-WCG-macOS-arm64.zip 29 → 29
悟空来-LAI.exe jiangtian-WCG.exe 17 → 17
wukonglai.com jiangtian.top 13 → 13
悟空来-LAI 无尘阁-WCG 13 → 13
悟空来 无尘阁 9 → 9
lihuadong jiangtian 9 → 9
Dazhuang Jiangnan 8 → 8
wkl.ai jt.top 6 → 6
栋哥 江楠 6 → 6
大壮 楠哥 6 → 6
wkl_ wcg_ 4 → 4
wkl- wcg- 4 → 4
lihuadong 是原作者编译时的 macOS 家目录(/Users/lihuadong/.cargo/registry/…),
被编进了 2924 条 panic / 调试路径里。这一条不是我加的戏,是本来就在包里、谁 strings 谁都能看见。
跑一遍:结果和残留
替换次数: wukonglai.com 12 · 悟空来-LAI 18 · 悟空来 9 · lihuadong 2924
栋哥 17 · 大壮 11 · wkl.ai 14 · wkl_ 14 · wkl- 3 · Dazhuang 2
悟空来 (LAI) 2 · 悟空来-LAI.exe 1 · 两个 zip 名各 2
jiangtian-WCG-Windows-x64.zip 二进制 20521472 bytes sha256=c9a3ada7…
残留: {'LAI': 2}
jiangtian-WCG-macOS-arm64.zip 二进制 13879760 bytes sha256=e592192c…
残留: 无
然后真的把它装起来跑。进程名、窗口标题都是从二进制里读出来的字符串,改没改一看就知道:
# 启动改名后的 Windows 构建 进程名 jiangtian-WCG-0.0.1-20261112.062 窗口标题 无尘阁-WCG(Your data Your agents Your rewards) v0.0.1-20261112.062 # 它自己去连中继、申请 circuit 地址、生成邀请码,全都正常 Relay: 预留成功 relay=12D3KooWBg3sCBRketpCEcQGPEdXwBmTSLjVqC5MoJ2q27QUzcU9 本机 PeerId: 12D3KooWMg7zSQooVrdxvzXLpTyXniUzTvVKRpaxeWLpjfKmp2wF
p2pchat1: + CBOR、body 仍是 7 个字段、外层仍是 {body, signature}。剩下两处改不了,说清楚
改完还剩 2 处 LAI,在 Windows 版里。不是漏扫 —— 是它根本不是一个连续字符串:
; 编译器把 13 字节常量 悟空来-LAI 拆成两个重叠的 8 字节立即数来比 48 b9 e6 82 9f e7 a9 ba e6 9d ; mov rcx, "悟空来" + 来 的第 1 字节 48 31 c8 ; xor rax, rcx 48 8b 4a 05 ; mov rcx, [rdx+5] 49 b9 ba e6 9d a5 2d 4c 41 49 ; mov r9, 空的第 3 字节 + "来-LAI" 4c 31 c9 / 48 09 c1 / 75 0b ; 或起来判零,跳走 42 81 7c 02 fc 2e 65 78 65 ; cmp [rdx+rax-4], ".exe"
1. 口令里的身份和签名。inviter_peer_id、device_public_key、
signature 是 Ed25519 签出来的,没有私钥就重签不了。页面写谁的名字都行,
但扫那个码连过去的,仍然是原邀请人。
2. 域名常量指向的还是人家的服务。包里 https://pay.…、
AI 网关 https://ai.a86.cn/v1 这些是硬编码的,改域名等于改归属,改完也没有对应服务。
我把能等长换的都换了(jt.top 是 jiangtian.top 的 6 字节短写,被长度卡住了),
但那些服务本身不是我的。
3. 它还是它。换字节改的是"显示成什么",不是"是什么"。真正的重写只有一条路:
照规范自己实现一遍 —— 就是页面上那个自研客户端在做的事。
如果你想自己复核 01–08 全部是纯静态完成,没装、没跑;09 节为了验证改名结果,才真的运行了一次改名后的构建。
# 1. 只下不下运行,先看包装里有什么 unzip -l jiangtian-WCG-macOS-arm64.zip # → Contents/MacOS/rust-p2p-dht-gui 13.9 MB ← 名字已经暴露了技术栈 # 2. 没做符号剥离,库版本号直接可读 strings rust-p2p-dht-gui | grep -oE 'libp2p[a-z_-]*' | sort -u libp2p_tcp libp2p_quic libp2p_noise libp2p_kad libp2p_dcutr libp2p_relay libp2p_autonat libp2p_mdns # 3. 拿口令,剥前缀,base64url 解码 raw = urlsafe_b64decode(code[9:]) # → 1183 字节 # 4. 手写 CBOR 解析(见页面第 06 节,浏览器里就能跑) # 5. 用公钥重算 peer ID,和口令里的比对 MATCH: True