无尘阁 · 原理解读
Reverse Engineering · WCG Desktop

一条连手口令里,
装着 1183 字节的 CBOR 和一个 libp2p 身份

我把 macOS 安装包拆了:strings 里翻出整套协议栈, 把口令解码成结构化数据,又用 Python 重算了它的 peer ID —— 结果逐字节对上。 下面是我看到的全部机制,以及几个它没写在宣传页上的设计取舍。

样本 v0.0.1-20261112.062 二进制 13.9 MB arm64 口令 1183 B → 1578 字符 身份验证 逐字节通过
01 · 先说结论

它不是一个聊天软件,是给 libp2p 套了个 egui 外壳 宣传语说"端到端加密""去中心化",我一开始以为是营销词。拆完发现是字面意思。

一句话

无尘阁 = Rust + libp2p 全栈 + egui 桌面壳。你的身份是一个 Ed25519 密钥对, 你的地址是一串 multiaddr,你发给朋友的"连手口令"是一张自签名、带 TTL 的入场券。 消息不经过任何业务服务器 —— 能直连就直连,连不上就走内置的腾讯云中继。

这个结论不是猜的:二进制里 libp2p_tcp / libp2p_noise / libp2p_kad / libp2p_dcutr / libp2p_relay 这些符号全在,还带着 0.4x 的具体版本号,说明没做符号剥离。

从下往上,五层

L5 应用
聊天 / .Agent 分身 / 文件流 / 广场
自研 request-response 协议传消息;.Agent 把好友的模型当 API 调; 大文件走流式 chunk 分片(内部图资源里有 bbr_file_transfer_stream 一项,说明做过拥塞控制调优)。
L4 会话
Noise 握手 + 业务层密钥
连接建立用 Noise(libp2p 默认 XX 模式做双向认证), 再叠一层应用级 X25519 + HKDF + ChaCha20-Poly1305。两套密钥分工:一套保链路,一套保内容。
L3 路由
Kademlia DHT + mDNS + AutoNAT + DCUtR + Relay
局域网用 mDNS 互相发现;广域用 Kademlia 找 peer; 公网是否可达由 AutoNAT 探测;打洞用 DCUtR;打不通就退回 Circuit Relay v2。
L2 传输
TCP + QUIC,双栈都监听
TCP 上跑 yamux 多路复用;QUIC 用 quinn 0.11(自带多流、0-RTT)。 DNS 解析走 hickory 0.24。TLS 用 rustls。
L1 身份
Ed25519 → peer ID
一次生成、永久不变的本机密钥对。公钥经 protobuf 封装后算 base58, 就是你那串 12D3KooW… 开头的 ID。
02 · 口令解剖

1183 字节里,四分之三都在报地址 口令不需要保密 —— 它本来就是给陌生人看的。但它必须是可验证的,否则谁都能伪造一张。

口令长这样:p2pchat1: 前缀 + base64url。剥掉前缀解码, 得到 1183 字节的 CBOR(Concise Binary Object Representation,比 JSON 省一半体积)。 顶层只有两个键:body 和 signature。

addrs 874
sig
addrs 874 B 73.9%
signature 76 B 6.4%
inviter_peer_id 70 B
device_public_key 56 B
x25519_public_key 52 B
token+时效+版本+骨架 55 B

这张图说明一个设计取向:身份信息是廉价的,路由信息是昂贵的。 一整套密码学材料只占 1/5,剩下全用来告诉对方"我可能在哪几个地址上等你"。

七个字段,逐个说

字段
字节
含义
version
9
协议版本,当前 1。留着向后兼容的口子。
inviter_peer_id
70
邀请人的 libp2p 身份,52 字符 base58。这串就是"江楠"本人。
device_public_key
56
36 字节,里头是 protobuf:08 01 12 20 + 32 字节公钥。用来验签。
x25519_public_key
52
32 字节纯公钥。专用于会话密钥协商,和签名密钥分离。
token
23
16 字节随机数。一次性配对凭证,服务端只认"我发过这张"。
expires_at
16
Unix 时间戳。这一张的有效期是 7 天(本样本:2026-11-19 09:43 到期)。
addrs
874
14 条 multiaddr。占 73.9%,是口令里最"重"的部分。
signature
76
64 字节 Ed25519 签名,覆盖整个 body。用 device_public_key 就能验。
!为什么要签名,而不用加密

如果这串东西是加密的,那"连手"就必须先有共享密钥,鸡生蛋问题。 所以它选了明文 + 自签名:内容人人可读,但改一个字节签名就对不上。 这正是能力凭证(capability token)的经典做法 —— 和 PASETO、macaroon 是同一个思路。

顺带一提:token 只有 16 字节且和 expires_at 绑在同一个 body 里, 意味着这张券一旦泄露,7 天内谁拿到都能连你。这不是漏洞,是明确的取舍。

03 · 身份

我把 peer ID 亲手重算了一遍,逐字节对上 这是全文唯一一处"能自证"的地方:如果我的理解错了,这一步不可能碰巧正确。

口令里的 inviter_peer_id 是 12D3KooWDjrUimps…。 这个 12D3KooW 前缀不是随机字符串 —— 它是 identity multihash 的 base58 特征。 推导路径只有三步:

Step 1
08 01 12 20
3a46da93…0d11
protobuf 封装 Ed25519 公钥(36 B)
→
Step 2
00 24 <36B>
identity multihash:0x00 + 长度 + 原始字节
→
Step 3
base58btc
编码成 52 字符
→
✓ 结果
12D3KooWDjrUimps
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,所以那目录的备份比什么账号密码都重要。

04 · 网络

14 条地址,是一张排好序的"逃跑路线图" 最值得看的部分。它把"怎么找到我"的优先级写死在口令里,按成本从低到高排。

这 14 条地址分三类,样本里的情况(地址已脱敏):

直连 · 公网
/ip6/2408:8207:2551:22d0:····:····:····:····/udp/54270/quic-v1
/ip6/2408:8207:2551:22d0:····:····:····:····/tcp/52955 ×2 组
直连 · 局域网
/ip4/192.168.1.23/udp/59888/quic-v1
/ip4/192.168.1.23/tcp/52954
中继 · 兜底
/ip4/140.143.183.18/tcp/4001/p2p/12D3KooWBg3s…/p2p-circuit
/ip6/2402:4e00:c052:1800:····/udp/4001/quic-v1/p2p/…/p2p-circuit ×4 条
回环 · 冗余
/ip4/127.0.0.1/tcp/52954 /ip6/::1/udp/54270/quic-v1 ×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 直连的必然代价:不告诉对方地址,对方就连不上你。 只是大多数分享链接的人不会想到这一层。如果你在意,办法是只发中继地址版本,或者用完即换口令。

连不上时,它按这个顺序降级

1
mDNS 局域网广播
同一个 Wi-Fi 下,几秒内就能发现对方,延迟最低、完全不经过外网。同一个办公室/家里首选。
2
直拨 addrs 里的公网地址(IPv6 优先)
样本里 4 条公网 IPv6 地址排在最前。运营商给到 /64 时,IPv6 是天然端到端可达的, 不需要任何 NAT 技巧 —— 这也是它敢做"直连"的底气。
3
AutoNAT 探测 + DCUtR 打洞
双方都在 NAT 后(常见于 IPv4-only 环境)时,先借中继交换各自的观察地址, 然后同时向对方外网端口发起连接,用 TCP 同时打开 / QUIC 打洞把洞穿开。成功后中继立即退场,流量转成纯 P2P。
4
Circuit Relay v2 全程中继
对称型 NAT、企业防火墙下打洞失败,就只能走腾讯云那台 relay。 延迟变高、带宽受 relay 限制,但至少能用。(中继端口固定 4001,不是随机端口 —— 这个细节说明它是长期跑的服务,不是临时节点。)
05 · 握手

"一键连手"到底发生了什么 从二进制里的字段名能还原出完整的状态机:create_invite → PairingInviteV1 → AcceptedInvite。

它在本地维护一个 .invites.json,里面记着 PersistedOpenInvite 和 PendingInvite。配合这些字段名,流程大概是:

A
邀请方:生成凭证并签名
生成 16 字节随机 token,填当前所有可用地址, 定 7 天 expires_at,用 Ed25519 私钥签 body,base64url 编码。 同时把这张券写进本机 .invites.json,标记为"我发出去的、待接受"。
B
被邀请方:先验签,再连
拿到口令先做的事不是连接,是验证: 用 device_public_key 验 signature,检查 expires_at 没过期, 再确认"公钥算出来的 peer ID"等于 inviter_peer_id。 这三步任一失败就报错 —— 二进制里对应的错误串是 invalid signature、unknown or expired invite、peer id does not match public key。
C
建连:先建 libp2p 会话,再谈业务
按地址表逐个尝试,Noise 握手完成双向认证(此时双方都知道对面公钥)。 这一层的加密和口令无关,是传输层自带的。
D
配对:把 token 递给对方核销
被邀请方带着 token 发起 accept_invite, 邀请方查本地待接受列表。对不上就回 pairing token does not match an open invite。 核销成功后两边各自把对方写进 trust store(字段有 trusted_at、last_seen)。
E
建立内容密钥,开始聊天
用双方 x25519_public_key 做 ECDH,HKDF 派生, 得到 ChaCha20-Poly1305 的会话密钥。从这里开始,消息内容连中继都看不到 —— relay 只转发加了密的字节流。
i为什么 token 和签名要同时存在

签名解决"这券是不是你发的",token 解决"这券是不是还没被人用过"。 两个问题不一样:签名可以永久有效(同一个 body 永远验得过), 但券只能核销一次 —— 所以需要一个随机数来标记"这一张"。 二进制里还有 record epoch is outside the accepted window 这种报错, 暗示它还有防重放的时间窗机制。

06 · 自己动手

把任何一条 WCG 口令丢进来,当场拆开 纯前端解码,不上传、不联网。CBOR 解析器是我手写的 60 行 atob 递归。

这个解码器只做 CBOR 反序列化,不做签名校验(浏览器里验 Ed25519 需要额外 40 KB 的库, 为了一个演示页不值得)。想看签名是否有效,得用真客户端试连 —— 连不上就是无效。

i上面那串是真的,地址没打码

本页样本口令里的 IPv6 地址是完整原文,而第 04 节的地址表我打了码。 这不是前后不一致:那张表是"脱敏示意",这串是"能用原文" —— 下载页要靠它完成一次真实连手,改了就连不上了。

所以这里也顺便提醒:既然你能看见,别人也能。发到群里的连手口令, 等于把出口 IP 一起发了。

07 · 几个判断

它做得漂亮的地方,和有代价的地方 拆完之后,我既不想吹它,也不想踩它。分开说。

漂亮的三处

一、密码学材料全部用标准件,没有自创。 Ed25519 + X25519 + HKDF + ChaCha20-Poly1305 + Noise,全是 libp2p / IPFS 生态被审计过无数遍的组合。 没有看到"我们自研了一套加密算法"这种危险信号。签名密钥和加密密钥还做了分离(device 用于签名, x25519 用于协商),这是正确的做法。

二、Address-first 的降级设计。 14 条地址从"局域网"排到"中继兜底",本质是把连通性问题在发口令那一刻就尽量解决掉, 而不是等连接失败了再回服务器查。这让它真的能在没有业务服务端的条件下工作。

三、没有账号体系,也就没有账号泄露。 没有手机号、没有密码、没有可被拖库的用户表。身份是密钥,信任是本地的 trust store。 从这个角度说,"去中心化"不是修辞。

需要自己留意的四处

1默认模型网关是内置的

二进制里硬编码了 https://ai.a86.cn/v1 和一个内置 key, 另外支持 127.0.0.1:11434(Ollama)和 localhost:8080/a86-ai/v1。 也就是说,如果你不改配置,你的 .Agent 提问会走厂商的网关。 端到端加密保护的是"你和对方之间",保护不了"你和模型供应商之间"。 在意隐私的话,第一件事就是把模型端点换成自己的 Ollama。

2口令泄露 = 7 天访问权

16 字节 token + 7 天 TTL,没有单次使用限制,也没有撤销机制(除非邀请方主动删本地记录)。 转发到群里,就等于把这扇门开着 7 天。样本这张券 2026-11-19 09:43 到期。

3中继是单点,也是元数据观察点

打洞失败时全走 140.143.183.18 这一台(4 条 relay 地址都指向同一个 peer)。 内容它看不见(ChaCha20 加密过),但谁和谁在什么时候连了多久,它看得见。 去中心化的宣传和中心化的基础设施,在这里共存。

4这是"共享算力",不是"共享软件"

.Agent 的机制是:好友配好自己的模型和知识库,你连上他的机器去提问。 好处是不用自己买 API;代价是你的问题会出现在对方的机器上, 而对方的模型账单也在涨。它把"隐私"从"厂商 vs 你"变成了"你 vs 好友"。

08 · 实测

我自己写了个客户端,把它连上了 没装它的客户端,从零写的 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
这一步没有任何原程序参与:握手是我这边算的 X25519 + ChaCha20-Poly1305,密钥是我现生成的。

它报回来的 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"
这几个 UNK_xxx 是 .rdata 里的常量地址。把地址换算成文件偏移、按长度读出来,就是明文字段名。

读出来的六个 key,按写入顺序:

#1 · 15 字节
inviter_peer_id
#2 · 17 字节
requester_peer_id ← 口令里没有这个字段
#3 · 17 字节
device_public_key
#4 · 17 字节
x25519_public_key
#5 · 5 字节
token
#6 · 9 字节
signature (外层 map 的第二个键)

对照我解出来的口令: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,我暂时停在这里。

到这一步,边界很清楚:传输层全部打通了,应用层还差一条消息的格式。 自研客户端能做的:解口令、验推导、探地址、握手、读协议表。不能做的:真正完成配对。

09 · 落地

把这些结论用上:把品牌真正改掉 不是改文件名 —— 是改二进制里的字节,然后用它自己证明改成功了。

读通序列化器之后,能做一件很实际的事:把原始安装包里的品牌串全部换掉,让程序自己启动起来的时候 显示的就是我的名字。这里有一个绕不过去的硬约束。

为什么只能"等长"替换

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
这一步同时反向验证了 02 节:它现场生成的邀请码仍是 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"
两段立即数之间夹着操作码,所以文件里"悟空来-LAI"这 13 个字节根本不连续 —— 正则永远匹配不到。这段是"我自己是不是个 .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. 它还是它。换字节改的是"显示成什么",不是"是什么"。真正的重写只有一条路: 照规范自己实现一遍 —— 就是页面上那个自研客户端在做的事。

10 · 方法

如果你想自己复核 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
第 5 步是关键:能对上,说明前面所有推断都成立;对不上,说明我哪里读错了。

这套东西值得学什么

如果你在做自己的应用,无尘阁这个案例里最值得抄的不是"加个 P2P", 而是凭证设计:把"我是谁 / 我可能在哪 / 这张券什么时候失效 / 谁签的字" 打包成一个自包含、自验证的字符串。它不需要查库、不需要额外请求、离线也能验。

代价也很清楚:能验证 ≠ 能撤销。它用短 TTL 换来撤销能力的缺席 —— 简单、有效,但你得知道自己在接受什么。