事情是这样的,我原先开发的多媒体库应用 LumiLuna 重构了,重构后我就添加了接入metingAPI播放在线音乐,但这玩意有个痛点:metingAPI只能读取歌单前200首,而且还得手动导入歌单,非常滴麻烦,那么,是时候加入网易云登录系统了,当时我的想法很天真:直接找现有的开源接口解密项目,照着抄就行了。结果这一抄,就是六个小时的噩梦。
前提:我的编译环境是薛定谔的
先交代一下背景约束,不然你没法理解为什么这么简单的功能能拖这么久:
- 登录态(cookie)必须留在 Rust 侧,不能给 WebView 看到,所以签名加密都得自己用 Rust 写
- UI 不单独做页面,融进现有的音乐选项卡
- 这台开发机没有 MSVC / Windows SDK,C 盘常年只剩 0~2GB,本地编译?不存在的,全靠 GitHub Actions 云端构建,每改一次代码 = commit + push + 等 6~9 分钟 CI + 下载安装实测
也就是说,每一次试错的最小成本是十分钟起步。
第一阶段:顺利得让人害怕
网易云的接口加密大致分三套:
- weapi:网页端协议,双层 AES-CBC + RSA-1024,账号/歌单/云盘都走它
- eapi:客户端协议,MD5 + AES-ECB,扫码登录和播放 URL 走它
- xeapi:新一代协议,X25519 密钥协商 + AES-128-GCM 加密 + HMAC 签名 + 外层 AES-ECB 套壳,三层链路叠满,用来注册「匿名设备身份」
参考项目是 api-enhanced,所有算法和请求头都照它来。Rust 侧把三套加密实现完,一路火花带闪电,除了两件事:
一是 RSA 公钥解析:网易云的公钥是 SPKI 格式(BEGIN PUBLIC KEY),我第一次用了只认 PKCS#1 格式的解析器,运行期必炸,又手贱用了 .expect(),结果一点音乐选项卡,应用直接闪退。
二是 Tauri 2 的同步命令会卡主线程,网络请求一开整个窗口冻住,得全部改成 async + spawn_blocking。
修完这两茬,扫码终于能出二维码了,但手机一扫,网易云直接甩脸:「检测到当前设备环境异常,本次操作已拦截」。
排查发现参考项目启动时会先用 xeapi 注册一个匿名设备身份(MUSIC_A cookie),所有请求都带着它。我们裸奔请求,被风控一眼识破。于是又移植了 xeapi——这一波贡献了全项目最多的一批编译错误:x25519-dalek 的 feature 门控、try_into 类型推断、ECB trait 导入……还有个隐藏炸弹:xeapi 签名的 HMAC 密钥,参考项目直接把 base64 字符串的字节当 key,我傻乎乎先解码再用,签名从头错到尾。
搞定这些,扫码终于能授权成功了——顺带把轮询状态码也摸透了:800 已过期、801 等待扫码、802 已确认待点登录、803 登录成功。我以为要收工了。
第二阶段:HTTP 200,0 字节——地狱之门
扫码授权成功后的下一步是拉账号信息(weapi 的 account/get),然后不出意外就出意外了,报错:
网易云响应解析失败(/w/nuser/account/get,HTTP 200,0字节)
HTTP 200,响应体 0 字节。服务器客客气气收了请求,然后一个字都不回。
这个错误贯穿了后面六七个版本的构建。它本质上是好几个真实缺陷叠在一起,单个修完都不足以让请求成功,所以排查过程极其漫长。按时间线,这些坑长这样:
坑 1:reqwest 不自动带 Content-Type
参考项目用 axios,发表单自动带 Content-Type;reqwest 的 .body() 什么都不带,服务器读不到表单参数直接回空。补上之后 eapi 的 unikey 接口终于开始吐正常 JSON 了。
坑 2:unikey 字段位置
实测发现 unikey 在响应顶层,不在 data 下面,一行取值逻辑的事。
坑 3:PEM 必须 64 字符换行
RSA 公钥从参考项目拷过来是一整行 216 字符。Node/OpenSSL 无所谓,但 Rust 的 pem-rfc7468 是严格解码器,要求每行恰好 64 字符,否则报 invalid Base64 encoding。把 crate 源码下载下来逐行确认后,拆成四行搞定。
坑 4:请求要有「特征」
海外/机房 IP 的裸请求会被风控直接空响应,于是照参考项目的 server.js 抄了随机中国 IP 头(X-Real-IP / X-Forwarded-For)。
坑 5:RSA 填充模式——这个是真的坑
参考项目的 rsaEncrypt 用的是 node-forge 的 encrypt(str, 'NONE')。我一直以为 ‘NONE’ 是个无害的默认值,直到下载 node-forge 源码,看到:
} else if(['RAW', 'NONE', 'NULL', null].indexOf(scheme) !== -1) {
scheme = {encode: function(e) {return e;}}; // 裸 RSA,无填充!
}
‘NONE’ = 无填充裸 RSA。 我用的 PKCS1v1.5 填充,服务端裸解密拿到带填充头的 128 字节,secretKey 提取失败。改成直接 modpow,输出和 forge 完全一致。
坑 6:双层 AES 层序写反
参考实现是 presetKey 在内层、secretKey 在外层,我一开始写反了。修。
……修完所有这些,依然空 body。
(期间还有个小插曲:这台机器常年开着 127.0.0.1:7890 代理,reqwest 默认会读 HTTP_PROXY 环境变量,请求全被塞进代理隧道,报一堆隧道错误,.no_proxy() 一发入魂。它不属于空 body 家族,但属于「环境比你想象得脏」家族——顺手记一笔。)
第三阶段:让应用自己交代
到这里我意识到不能再靠猜了。好在这台机器上参考项目能本地跑、Node 也能跑,于是建立了三对照调试法:
- Node 复刻:把 Rust 的加密算法用 Node 逐行复刻一遍打真实接口——永远成功,完整 JSON
- curl 对照:请求参数导出用 curl 原样发——也成功
- 应用实测:永远失败,空 body
三个实现,内容一模一样,只有应用失败。这大概就是程序员最折磨的状态:不是不知道怎么做,是「照着能跑的实现抄都抄不对」。
接下来是几招逐步缩小包围圈:
第一招,给应用加全量请求日志。 每次请求把 URL、完整请求头(含 Cookie 全值)、请求体全文写进 appdata 下的日志文件。用户测完我直接读文件——彻底绕开「肉眼转述 + OCR 乱码」这条失真通道。这一步非常关键,后面所有判断都建立在真实请求上。
第二招,原样重放。 把应用日志里的请求用 Node 逐字节重放——空 body。再把请求头/URL 原样保留、只把 BODY 换成 Node 自己生成的——成功。
结论锁定:应用生成的加密产物内容不对。长度对、格式对,但服务端解不开。
第三招,加密自检。 让 Rust 在加密后用同一把密钥把密文解回来验证。结果吓人一跳:
WEAPI-SELFCHECK 自检内层可解但外层失败:Invalid symbol 0, offset 0. 内层前40=0000000000000000000000000000000000000000
用密钥解自己刚加密的密文,得到全 0。 AES 加密/解密不自洽,这在密码学上几乎是不可想象的,除非实现有 bug。
第四招,固定密钥复现。 为了排除随机性,加了一段固定密钥的复现加密,把 text、inner、params、解密结果全部打进日志。然后我看到了决定性的一幕:两次请求的 text 不同(csrf 不同),加密出来的 inner 却完全相同。
加密函数对不同的明文输出相同的密文——那它加密的到底是什么?
(插曲:这套固定密钥调试代码本身还闹了个笑话。随手写的调试密钥 FixedSecret123456 有 18 个字符,AES 密钥长度断言直接 panic,应用一启动就闪退——功能还没修好,界面先崩了,改回 16 字符的 AbCdEfGhIjKlMnOp 才消停。)
真相:一行代码的缺席
答案藏在 cipher crate 的 API 语义里。encrypt_padded_mut 是就地接口:
fn encrypt_padded_mut<P>(self, buf: &mut [u8], msg_len: usize) -> Result<&[u8], PadError>
它把 buf 的前 msg_len 个字节当作明文输入,加密后写回 buf 开头。而我的代码是:
let mut buf = vec![0u8; msg.len() + 16]; // 只分配了全 0 的 buffer
let ct = enc.encrypt_padded_mut::<Pkcs7>(&mut buf, msg.len())?;
明文从来没写进 buf。 于是每一次加密的都是「全 0 的 61 字节」,两次请求当然产出相同的 inner;服务端拿到密文解出来是 0,自然一个字都不想回。之前所有诡异现象——包括自检解出全 0——全部自洽地指向这个事实。
修复只有一行:
buf[..msg.len()].copy_from_slice(msg); // 明文写入 buf 开头
然后,整个流程就通了。扫码、账号、歌单、播放,全部正常。
这个 bug 为什么这么难找?因为它藏在一个几乎没人会细看的 API 语义里:encrypt_padded_mut 不是「传明文、返回密文」的函数式接口,而是「你把明文放进 buffer,我原地加密」的就地接口。而 Node 的 createCipheriv、参考项目的 CryptoJS 都是前一种语义——用「正确实现的直觉」去写「就地接口」的代码,写出来的就是这种全 0 密文。
顺带一提,eapi 之所以一直正常,是因为它用的 AES-ECB 是逐块显式拷贝明文实现的,绕开了这个坑。同一套代码里,一个函数躲过了 bug,另一个踩得死死的。
现在的状态
全链路现在是通的:扫码登录、账号信息、我的歌单、云盘列表、登录态下的在线播放,一个不缺;登录态会持久化到本地,下次打开应用不用重新扫码。搜索和推荐歌单暂时还走 meting 聚合接口——它们不需要登录,能省则省,哪天 meting 挂了再考虑全量迁到官方接口。
代价也记一笔:这些接口全是逆向出来的非官方协议,网易云哪天改协议、加风控,这段代码可能一夜之间变废纸。到时候,再回来折腾吧。
一些真实的感想
这场仗打下来,几个体会值得记下:
-
API 语义比算法本身更容易出错。 加密算法移植最大的风险不是算法写错,而是「你以为的接口行为」和「实际接口行为」不一致。
encrypt_padded_mut的就地语义、pem-rfc7468 的 64 字符限制、forge ‘NONE’ 的裸 RSA——全是这类。下载 crate 源码逐行确认,比相信直觉快得多。 -
让程序自己交代,别靠肉眼转述。 一旦陷入「报错 → 猜 → 改 → 再报错」的循环,立刻停下来做自证:日志、自检、固定输入复现、原样重放。每一层自证都把问题的包围圈缩小一层。
-
「同一个逻辑,A 实现成功、B 实现失败」时,差异一定藏在某个你没对比过的字节里。 把它找出来的办法就是逐字段对比,直到把内容全比完,剩下的只有内容本身。
-
这种 bug 不是靠聪明解决的,是靠把怀疑对象一个个钉死排除掉的。 六个小时里大部分时间在做排除法,真正动手改代码的时间可能不到半小时。
-
调试代码也是代码。 18 字符的密钥能闪退整个应用——debug 代码的边界条件,和生产代码一样重要。
最后,当那个「已登录」三个字出现的时候,感觉比任何技术结论都值钱。虽然过程很痛,但下次再遇到这种诡异的空响应,我应该能第一时间想起:先怀疑加密函数到底加密了什么。
爱来自大肥鱼v4f,以及埋头苦脸修破防的我
——完——

