diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a1110550 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,111 @@ +# AGENTS.md — api-enhanced + +**`@neteasecloudmusicapienhanced/api`** (v4.33.0) — Enhanced fork of Binaryify/NeteaseCloudMusicApi. ~250+ dynamically loaded Netease Cloud Music REST API endpoints. + +--- + +## Entrypoints + +| Use case | File | Notes | +|----------|------|-------| +| CLI/server | `app.js` | Auto-generates anonymous token, then calls `server.serveNcmApi()` | +| Programmatic | `main.js` | Dynamically loads all `module/*.js` as functions, plus re-exports `server.js` | +| Express app | `server.js` | Exports `serveNcmApi(options)` and `getModulesDefinitions(path, routeOverrides)` | +| Vercel | `index.js` | `require('./app.js')` | +| ESM | `index.mjs` | `import './app.js'` | + +## Dev commands + +```sh +npm run dev # nodemon app.js +npm start # node app.js +npm test # mocha -r intelli-espower-loader -t 60000 server.test.js main.test.js --exit +npm run lint # eslint "**/*.{js,ts}" +npm run lint-fix # eslint --fix "**/*.{js,ts}" +npm run prepare # husky install (auto on pnpm install) +npm run pkgwin # pkg . -t node18-win-x64 -C GZip -o precompiled/app +npm run pkglinux # pkg . -t node18-linux-x64 -C GZip -o precompiled/app +npm run pkgmacos # pkg . -t node18-macos-x64 -C GZip -o precompiled/app +``` + +**Must-use order when making changes:** `lint-fix → test` (test runs server integration, not just unit). + +## Testing quirks + +- **Mocha 11** + power-assert. 60s timeout. `--exit` is required (server keeps event loop alive). +- **Server bootstraps in `server.test.js`** — starts Express on a random port before all tests, then dynamically `require()`s all `test/*.test.js` files. +- Tests use `global.host` (dynamic port) or fallback to `http://localhost:3000`. +- **`realIP: global.cnIp`** is sent with every request to avoid Netease rate limiting. `global.cnIp` is a random Chinese IP from `data/china_ip_ranges.txt`. +- Hardcoded song/album IDs in test files may fail if Netease's catalog changes. +- Anonymous token is auto-generated to `os.tmpdir()/anonymous_token` before tests run. +- Tests are **integration tests** requiring live access to `music.163.com` — they will fail offline. + +## Architecture + +**Dynamic routing** — each `.js` in `module/` becomes an API route: +- `album_new.js` → `GET /album/new` +- Underscores become slashes. Three overrides exist in `server.js`: + - `daily_signin.js` → `/daily_signin` + - `fm_trash.js` → `/fm_trash` + - `personal_fm.js` → `/personal_fm` + +**Encryption** (see `util/crypto.js`): +- `weapi` — AES-CBC + RSA (most endpoints) +- `linuxapi` — AES-ECB (linux client endpoints) +- `eapi` — custom (newer endpoints) +- `api` — plaintext (rare) + +**API domains** — `music.163.com` (weapi/linuxapi), `interface.music.163.com` (eapi/api). + +**Cache** — 2-minute TTL via `util/apicache.js` on all responses. Bypass with `?cache=false`. + +**Anonymous token** — auto-generated at startup via `generateConfig.js` → `register_anonimous()`, cached at `os.tmpdir()/anonymous_token`. Deletion forces regeneration on next start. + +**Unblock feature** — set `ENABLE_GENERAL_UNBLOCK=true` in `.env` to activate `@neteasecloudmusicapienhanced/unblockmusic-utils` for `/song/url/v1`. + +## Config & style + +| Tool | Setting | +|------|---------| +| ESLint | **Flat config**`eslint.config.js` — extends prettier, 2-space indent, single quotes, no semicolons | +| Prettier | `semi: false`, `singleQuote: true`, `trailingComma: "all"` | +| EditorConfig | LF endings, 2-space indent for `*.{js,ts}` | +| TypeScript | `tsconfig.json` — `strict: true`, `noEmit: true` (type-check only), path aliases `~/*` and `@/*` | +| Husky v9 | Pre-commit: lint-staged (`*.js` → `eslint --fix`). Commit-msg & pre-push: placeholder shims. | + +## CI/CD workflows (`.github/workflows/`) + +| Workflow | Trigger | What it does | +|----------|---------|-------------| +| `npm.yml` | release published | Publishes to npm | +| `release-on-version-change.yml` | push to main changing `package.json` | Builds binaries + creates GitHub Release | +| `Build_Image.yml` | release published | Builds & pushes Docker images to Docker Hub + GHCR | +| `build-and-pr.yml` | manual + push to main | Builds binaries (linux/win/macos) | +| `sync.yml` | daily schedule | Syncs fork with upstream Binaryify repo | +| `issue-manage.yml` | issue open/comment | Welcome / stale management | + +## Operational gotchas + +- **pnpm** lockfile (`pnpm-lock.yaml`). Never commit `package-lock.json`. +- **Express v5** — verify v4→v5 migration quirks (route param handling, error middleware). +- **No database** — this is a stateless API proxy. Persistent state is only `os.tmpdir()/anonymous_token`. +- **`.env` is gitignored** — copy `.env.prod.example` for local config. +- **`data/china_ip_ranges.txt`** — used for `realIP` spoofing to bypass Netease geo-restrictions. +- **`data/deviceid.txt`** — pre-generated device IDs for API requests. +- **Docker**: `node:lts-alpine`, runs via `tini`, only includes `module/plugins/public/util/app.js/server.js`. +- **Vercel**: `@vercel/node` runtime with CORS headers in `vercel.json`. +- **Precompiled binaries** via `pkg` — output to `precompiled/` (gitignored). Node 18 target pinned in scripts. +- **Do not** modify files in `public/` — it's excluded from ESLint (`globalIgnores(['**/public/'])`). + +## Codebase structure (key dirs) + +``` +module/ → 392 endpoint handlers (one .js per route) +util/ → crypto, request (HTTP + encryption), cache, logger, config +plugins/ → image & song upload handlers +test/ → 5 integration test files (album, comment, lyric, music_url, search) +data/ → china_ip_ranges.txt, deviceid.txt (static assets) +public/ → docs site (Docsify), test pages (ESLint-ignored) +examples/ → usage examples (moddef.json gitignored) +.github/ → workflows, issue templates, dependabot, funding +``` diff --git a/issue.md b/issue.md new file mode 100644 index 00000000..7d33b86d --- /dev/null +++ b/issue.md @@ -0,0 +1,314 @@ +# xeapi / Aegis 加密算法 — 完整逆向文档 + +从 Netase CloudMusic Android APK (ncm.apk) 及 libAegisSDK.so 逆向分析得出。 + +--- + +## 1. 概述 + +xeapi (X Encrypted API) 是网易云音乐使用的 API 请求加密方案。启用后,客户端发往 `*.music.163.com` 的 HTTP 请求经过三层嵌套加密,URL 路径从 `/api/` 改写为 `/xeapi/`。 + +### 启用条件 + +AB 实验标志 `enableXeapiEncrypt8420` 控制,通过 `IABTestManager.checkBelongGroup()` 检查。 + +| 文件 | 类 | 作用 | +|------|----|------| +| `smali_classes6/p62/ise.smali` | p62/ise | AB 标志检查 (lazy boolean) | +| `smali_classes16/f6/k.smali` | f6/k | AB 实验注册 (列表中的 method a()) | + +--- + +## 2. 架构总览 + + +OKHttp 拦截器链 +├─ n72/b (分发器) +│ └─ 非 API 主机 -- n72/e (extends v62/d, API 请求) +│ └─ n72/d (extends v62/p, 非 API 请求) +│ └─ n72/a (抽象基类, implements okhttp3.Interceptor) +│ ├─ intercept() +│ │ └─ e() [encryptApi/encryptXeApi] +│ └─ a() [abstract: 实际加密] +│ └─ p62/f (extends p62/c AbsEncryptConfig) +│ └─ c() -> AegisNative.encrypt(data) -- libAegisSDK.so +│ └─ p62/y {EncryptProbeScheduler} +│ └─ 降级: ENCRYPT_XEAPI ↔ CLIENT_FALLBACK + + +### URL 改写 + +**原始请求**:`POST /api/some/endpoint` + +**加密后**:`POST /xeapi/some/endpoint` +`Body: params=B=base64(cipherB)&S=base64(cipherS)&R=base64(cipherR)` + +--- + +## 3. 本地库: libAegisSDK.so + +### 属性 + +| 属性 | 值 | +|------|----| +| 架构 | ARM64 (AARCH64:LE64:v8A) | +| 镜像基址 | 0x09106000 | +| 函数数量 | 8115 | +| 符号数量 | 45447 | +| 加密库 | BoringSSL/OpenSSL (EVP, EC, HKDF, AES-GCM) | + +### 关键导出函数 + +| 函数 | 地址 | 描述 | +|------|------|------| +| Aegis_InitializeEngine | 0x0921474 | 初始化加密引擎 | +| Aegis_Encrypt | 0x09214714 | 顶层加密入口 | +| Aegis_Free | 0x092149a8 | 释放加密结果 | +| Aegis_UpdatePublicKey | 0x092149b4 | 更新服务器 ECC 公钥 | +| Aegis_SetSession | 0x092149f0 | 设置会话密钥 | +| Aegis_DestroyEngine | 0x09214bec | 销毁引擎 | +| Aegis_SetTrackingListener | 0x09214c9c | 设置追踪回调 | + +### JNI 桥接 (Java → Native) + +| JAVA 方法 | NATIVE 函数 | 地址 | +|-----------|-------------|------| +| encrypt(String) | Aegis_Encrypt | 0x09214714 | +| initializeEngine(...) | Aegis_InitializeEngine | 0x0921474 | +| updatePublicKey(boolean) | Aegis_UpdatePublicKey | 0x092149b4 | +| setSession(String, String) | Aegis_SetSession | 0x092149f0 | +| destroyEngine() | Aegis_DestroyEngine | 0x09214bec | +| onNetworkResponse(long, int, String) | Java_..._onNetworkResponse | 0x09231ad8 | + +--- + +## 4. 加密算法详解 + +### 4.1 整体流程 (FUN_00216f58, 被 Aegis_Encrypt 调用) + +**输入**:plaintext (JSON 参数) +**输出**:`B=base64(cipherB) & S=base64(cipherS) & R=base64(cipherR)` + +**步骤**: +1. 获取/生成动态密钥 (128-bit, 有效期 ~5 分钟) +2. 第 1 层:加密业务数据 → cipherB +3. 第 2 层:加密动态密钥 → cipherS +4. 第 3 层:加密版本信息 → cipherR + +### 4.2 第 1 层:加密业务数据 (FUN_00218538, EncryptBusinessData) + +**双重 AES-256-GCM** + + +plaintext (JSON) +├─ [Round 1] 静态 AES-256-GCM +│ ├─ IV: 引擎内置静态密钥 (param_1 + 0x18, 32 bytes) +│ ├─ KEY: 随机生成 (16 bytes) +│ └─ 输出: [IV,(16)] [ciphertext,1] [tag,(16)] +└─ Base64 编码 + └─ [Round 2] 动态 AES-256-GCM + ├─ IV: 动态密钥 (16 bytes, 自动填充到 32) + ├─ KEY: 随机生成 (16 bytes) + └─ 输出: [IV,(16)] [ciphertext,1] [tag,(16)] + + +**cipherB 格式**: `IV(16) + AES-GCM-ct + tag(16)` + +### 4.3 第 2 层:加密动态密钥 (FUN_00218738, EncryptDynamicKey) + +**ECDH(P-256) + HKDF-SHA256 + AES-256-GCM** + + +服务器公钥 (base64 解码 -- 32 bytes, 来自 /aegissdk/public_key) +├─ 客户端生成临时 ECC 密钥对 (P-256/prime256v1) +├─ ECDH 密钥协商: +│ └─ sharedSecret = clientPrivateKey × serverPublicKey +│ └─ 输出: 32 bytes +├─ HKDF-SHA256 派生: +│ ├─ IKM = sharedSecret +│ ├─ salt = 全局数据 DAT_001a04c0 (16 bytes) -- 需从 SO 中提取 +│ ├─ info = clientEphemeralPubKey (65 bytes, uncompressed) +│ └─ L = 16 bytes +│ └─ 输出: derivedKey (16 bytes -- 自动填充到 32) +└─ 随机 IV (12 bytes) + └─ 明文 = base64(dynamicKey) " " base64(serverPubKey) " " + version + └─ AES-256-GCM 加密 + ├─ 密钥: derivedKey + └─ IV: 随机 12 bytes + └─ 输出: [ciphertext] [tag(16)] + + +**cipherS 格式**: + +[clientEphemeralPubKey (65 bytes, uncompressed, 0x04 |x +| y)] +[IV (12 bytes)] +[AES-GCM ciphertext] +[GCM tag (16 bytes)] + + +> **重要**: 客户端临时公钥以明文形式存储在 `cipherS` 的前面。服务端用它 + 自己的私钥通过 ECDH 计算共享密钥。**服务端私钥仅在服务端,不在客户端二进制文件中。** + +### 4.4 第 3 层:加密版本信息 (FUN_0021bbf6, EncryptVersionInfo) + +**AES-256-GCM** + +**明文**: `version | sessionId` (例如 `"1.0|abc123..."`) + +- 静态 AES-256-GCM + - 密钥: 引擎内置静态密钥 (与第 1 层相同) + - IV: 随机生成 (16 bytes) + - 输出: `[IV,(16)] [ciphertext] [tag,(16)]` + +**cipherR 格式**: `IV(16) + AES-GCM-ct + tag(16)` + +--- + +## 5. 密钥管理 + +| 密钥 | 长度 | 存储位置 | 生命周期 | +|------|------|----------|----------| +| 静态 AES 密钥 | 256-bit | 引擎状态 param_1+0x18 (硬编码) | 永久 | +| 动态密钥 | 128-bit | 引擎状态 param_1+0x10 | ~5 分钟 (aegisUpdateIntervalMinute 可配置) | +| 会话密钥 | 可变 | 由 Aegis_SetSession 设置 | 会话期间 | +| 客户端 ECC 密钥对 | P-256 | 引擎状态 param_1+0x08 | 引擎初始化时生成 | +| 服务器公钥 | P-256 | `/aegissdk/public_key` | publicKeyUpdateIntervalSecond (默认 120s) | +| HKDF salt | 128-bit | 全局数据 DAT_001a04c0 | 静态 | +| 服务端 ECC 私钥 | P-256 | 仅在服务端 | — | + +--- + +## 6. 密码学原函数 + +从 `libAegisSDK.so` 导出符号及反编译代码确认: + +| 原函数 | 库 | 用途 | +|--------|----|------| +| AES-256-GCM | BoringSSL (`EVP_EncryptInit_ex`, `EVP_EncryptUpdate`, `EVP_EncryptFinal_ex`, mode=2) | 所有对称加密 | +| ECDH over NIST P-256 | `ecp_nistz256_mul_mont`, `ecp_nistz256_point_add`, `ecp_nistz256_point_double` | 密钥协商 | +| HKDF-SHA256 | 自定义实现 (FUN_0022fbe0) | 密钥派生 | +| Base64 | FUN_0022fc4c (编码), FUN_002f330 (解码) | 编解码 | +| MT19937 / /dev/urandom | FUN_0022fca8, std::random_device | 随机数回退 | +| RDRAND | FUN_00311438, FUN_00311330 | 硬件随机数 | + +--- + +## 7. 降级与探测机制 (p62/y EncryptProbeScheduler) + +### 参数 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| encryptDegradeThreshold | 3 | 时间窗口内连续失败次数阈值 | +| encryptDegradeTimeWindowSecond | 80 | 滑动窗口大小 | +| probeIntervalSecond | 300 | 探测间隔 (5 分钟) | +| probeIntervalSecondMax | 1200 | 最大退避间隔 (20 分钟) | +| publicKeyUpdateIntervalSecond | 120 | 公钥更新间隔 (20 分钟) | +| aegisUpdateIntervalMinute | 1 | 动态密钥轮换间隔 | + +### 状态机 + + +ENCRYPT_XEAPI —(窗口内 N 次失败)—→ CLIENT_FALLBACK + ↑ + └—(探测成功)—┘ + + +### 相关日志 + +- `"recordEncryptFailure, client auto degrade triggered"` +- `"onProbeResult SUCCESS, recovered to ENCRYPT_XEAPI"` +- `"executeProbe, skipped because is not CLIENT_FALLBACK"` + +--- + +## 8. 错误码 + +| 错误码 | 含义 | +|--------|------| +| 0 | 成功 | +| -2 | 参数无效 | +| -3 | 内存分配失败 | +| -4 | 引擎未初始化 | +| -5 (-0xfffffffb) | 动态密钥过期/缺失 | +| +200 ~ +299 | 动态 AES 加密失败 | +| +300 ~ +399 | 静态 AES 加密失败 | +| -600 (-0x258) | ECDH/HKDF/IV 错误 | +| -601 (-0x259) | ECDH 失败 | +| -1497 (-0x5da) | SSL 上下文创建失败 | +| -1498 (-0x5d9) | EVP 加密更新/完成失败 | + +--- + +## 9. 实现文件 + +### 文件 + +| 文件 | 描述 | +|------|------| +| `encrypt.js` | Node.js 客户端加密实现 | +| `decrypt.js` | Node.js 服务端解密实现 (需要服务端 ECC 私钥) | + +### 使用方法 + +javascript +const { xeapiEncrypt } = require("./encrypt"); +const { xeapiDecrypt } = require("./decrypt"); +const crypto = require("crypto"); + +// 服务端密钥对 (实际中服务端私钥仅在服务端) +const serverEcdh = crypto.createECDH("prime256v1"); +serverEcdh.generateKeys(); + +// 客户端加密 +const result = xeapiEncrypt(jsonData, { + serverPublicKey: serverEcdh.getPublicKey(), +}); + +// 服务端解密 (需要私钥!) +const decrypted = xeapiDecrypt( + result.encryptedParams, + serverEcdh.getPrivateKey() +); +console.log(decrypted.plaintext); + + +### 待提取的常量 + +| 常量 | 来源 | 状态 | +|------|------|------| +| 静态 AES-256 密钥 | `p62/f.smali -- h() -- XOR decode` | ✅ 已提取 | +| HKDF salt | `DAT_001a04c0 (16 bytes)` | 🟡 待 Ghidra read_memory | +| 版本字符串 | `DAT_001a92e6` | 🟡 待提取 | + +### 已提取: 静态 AES-256 密钥 + +**提取路径**: + +p62/f.smali h(): +"0tu8wLlmtHQ5yMS4sEX5D763Jfl9JLA1ymMRQ5zA9TkK0zJl0SX8p4=" + ↳ Base64 解码 -- 44 bytes + ↳ XOR 解码 (key: 0xa1, 0xa3, 0xa2, 0xa3) + "x5a8W8rqEn/CefdxI9XWc7ZKksDOaUF3g/K7E4p=" + ↳ Base64 解码 + ↳ 静态 AES-256 密钥: b31d1a4bf2ba849fe97edd55727d896dc1ed9ee492c0e86947c6dfb93b1 + + +### XOR 解码函数 (对应 native decodeCache): + +javascript +function xorDecode(b64Input) { + const key = Buffer.from([0xa1, 0xa3, 0xa2, 0xa3]); + const buf = Buffer.from(b64Input, 'base64'); + for (let i = 0; i < buf.length; i++) buf[i] ^= key[i % 4]; + return buf.toString('utf-8'); +} + + +### 签名密钥 (p62/f.small g()): + + +"2vbr4NITf7Tpwdnb7Ll16e8W7urOlsrR89S2TXqIXIxWf5VSKe7K09dXf5Lby+Hkx+bM06sv6tfRKMc" + ↳ XOR decode (same key) + ↳ 非 base64 原始字节 (61 bytes, 作为 UTF-8 字符传递) \ No newline at end of file diff --git a/module/vip_tasks_v1.js b/module/vip_tasks_v1.js new file mode 100644 index 00000000..49f73117 --- /dev/null +++ b/module/vip_tasks_v1.js @@ -0,0 +1,16 @@ +// 会员任务 - v1 + +const createOption = require('../util/option.js') + +module.exports = (query, request) => { + const data = { + taskType: 'app_vip_task_center', + userId: query.userId || '', + } + + return request( + `/api/middle/vip/mission/user/progress/list`, + data, + createOption(query, 'xeapi'), + ) +} diff --git a/package.json b/package.json index 6072c85e..12e75d9b 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "data" ], "dependencies": { - "@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.1", + "@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.3.2", "axios": "^1.16.1", "crypto-js": "^4.2.0", "dotenv": "^17.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c937c734..fddb00e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@neteasecloudmusicapienhanced/unblockmusic-utils': - specifier: ^0.3.1 - version: 0.3.1 + specifier: ^0.3.2 + version: 0.3.2 axios: specifier: ^1.16.1 version: 1.16.1 @@ -222,8 +222,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@neteasecloudmusicapienhanced/unblockmusic-utils@0.3.1': - resolution: {integrity: sha512-Ul7/5jeUiZKb74GYRmwNj2ekUryTwO4j8CfGSrYKSsJRZlF6/L0FNniQjjpEyQ/6npsrbcmEibMMvn+Z0W4Spw==} + '@neteasecloudmusicapienhanced/unblockmusic-utils@0.3.2': + resolution: {integrity: sha512-H1ckEDXxR+sLUZKzCPhlP8kfSKgEZZp8GSL4+2BZcmCyerwCbSZzMxX9doacHfFjThRjmq90DbsrRnhnKAvrzA==} hasBin: true '@nodelib/fs.scandir@2.1.5': @@ -876,8 +876,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: @@ -2384,8 +2384,8 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tinyexec@1.1.2: - resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + tinyexec@1.2.1: + resolution: {integrity: sha512-iqo2IULiwCbMOLU9lVR5XMmXjWMh0ewJSFNOfFRHislSG31ESRnUg4awuiaFUcnINcz+oyQQY43QGh3rGn5aCA==} engines: {node: '>=18'} tinyglobby@0.2.16: @@ -2756,7 +2756,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@neteasecloudmusicapienhanced/unblockmusic-utils@0.3.1': + '@neteasecloudmusicapienhanced/unblockmusic-utils@0.3.2': dependencies: '@unblockneteasemusic/server': 0.28.0 axios: 1.16.1 @@ -3476,7 +3476,7 @@ snapshots: data-view-byte-offset: 1.0.1 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-set-tostringtag: 2.1.0 es-to-primitive: 1.3.0 function.prototype.name: 1.1.8 @@ -3525,7 +3525,7 @@ snapshots: es-errors@1.3.0: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -4008,7 +4008,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 @@ -4019,7 +4019,7 @@ snapshots: get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-symbol-description@1.1.0: dependencies: @@ -4385,7 +4385,7 @@ snapshots: listr2: 9.0.5 picomatch: 4.0.4 string-argv: 0.3.2 - tinyexec: 1.1.2 + tinyexec: 1.2.1 yaml: 2.9.0 listr2@9.0.5: @@ -4591,7 +4591,7 @@ snapshots: call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 has-symbols: 1.1.0 object-keys: 1.1.1 @@ -4957,7 +4957,7 @@ snapshots: define-properties: 1.2.1 es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 get-proto: 1.0.1 which-builtin-type: 1.2.1 @@ -5118,7 +5118,7 @@ snapshots: dependencies: dunder-proto: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 setprototypeof@1.2.0: {} @@ -5268,7 +5268,7 @@ snapshots: define-data-property: 1.1.4 define-properties: 1.2.1 es-abstract: 1.24.2 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 has-property-descriptors: 1.0.2 string.prototype.trimend@1.0.9: @@ -5276,13 +5276,13 @@ snapshots: call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 string.prototype.trimstart@1.0.8: dependencies: call-bind: 1.0.9 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 string_decoder@1.1.1: dependencies: @@ -5347,7 +5347,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tinyexec@1.1.2: {} + tinyexec@1.2.1: {} tinyglobby@0.2.16: dependencies: diff --git a/util/crypto.js b/util/crypto.js index a6301e7f..5824de53 100644 --- a/util/crypto.js +++ b/util/crypto.js @@ -141,6 +141,8 @@ const decrypt = (cipher) => { return decryptedBytes } +const xeapi = require('./xeapi') + module.exports = { weapi, linuxapi, @@ -150,4 +152,5 @@ module.exports = { aesDecrypt, eapiReqDecrypt, eapiResDecrypt, + xeapi, } diff --git a/util/option.js b/util/option.js index a4c26154..18cdda79 100644 --- a/util/option.js +++ b/util/option.js @@ -9,6 +9,8 @@ const createOption = (query, crypto = '') => { e_r: query.e_r || undefined, domain: query.domain || '', checkToken: query.checkToken || false, + xeapiServerKey: + query.xeapiServerKey || process.env.XEAPI_SERVER_PUBLIC_KEY || '', } } module.exports = createOption diff --git a/util/request.js b/util/request.js index a71553dd..8d7e3aa4 100644 --- a/util/request.js +++ b/util/request.js @@ -1,5 +1,6 @@ // 预先导入和绑定常用模块及函数 const encrypt = require('./crypto') +const xeapi = require('./xeapi') const CryptoJS = require('crypto-js') const { default: axios } = require('axios') const { PacProxyAgent } = require('pac-proxy-agent') @@ -216,6 +217,24 @@ const createRequest = (uri, data, options) => { url = (options.domain || DOMAIN) + '/api/linux/forward' break + case 'xeapi': + // xeapi 加密 - 使用 Aegis 三层加密 + headers['User-Agent'] = + options.ua || + 'NeteaseMusic/9.1.65.240927161425(9001065);Dalvik/2.1.0 (Linux; U; Android 14; 23013RK75C Build/UKQ1.230804.001)' + headers['Referer'] = options.domain || DOMAIN + + // 执行 xeapi 三层加密 + const xeapiResult = xeapi.xeapiEncrypt(data, { + serverPublicKey: + options.xeapiServerKey || process.env.XEAPI_SERVER_PUBLIC_KEY || '', + }) + encryptData = xeapiResult.params // { B, S, R } + var xeapiDynamicKey = xeapiResult.dynamicKey // 用于响应解密 + + url = (options.domain || API_DOMAIN) + '/xeapi/' + uri.substr(5) + break + case 'eapi': case 'api': // header创建 @@ -272,7 +291,8 @@ const createRequest = (uri, data, options) => { // 使用返回值加密 const use_e_r = (crypto === 'eapi' || crypto === 'weapi') && data.e_r - if (use_e_r) { + const use_xeapi = crypto === 'xeapi' + if (use_e_r || use_xeapi) { settings.encoding = null settings.responseType = 'arraybuffer' } @@ -320,7 +340,13 @@ const createRequest = (uri, data, options) => { ) try { - if (use_e_r) { + if (use_xeapi && xeapiDynamicKey) { + // xeapi 响应解密: 原始 AES-256-GCM 二进制密文 + answer.body = xeapi.xeapiDecryptResponse( + Buffer.from(body), + xeapiDynamicKey, + ) + } else if (use_e_r) { answer.body = encrypt.eapiResDecrypt( body.toString('hex').toUpperCase(), headers['x-aeapi'], diff --git a/util/xeapi.js b/util/xeapi.js new file mode 100644 index 00000000..2d9b871a --- /dev/null +++ b/util/xeapi.js @@ -0,0 +1,459 @@ +/** + * xeapi (Aegis) 加密/解密实现 + * + * 基于 Netease CloudMusic Android APK libAegisSDK.so 逆向分析 + * + * 三层加密架构: + * Layer 1 (Business Data): 双重 AES-256-GCM (静态密钥 → Base64 → 动态密钥) + * Layer 2 (Session Key): ECDH(P-256) + HKDF-SHA256 + AES-256-GCM + * Layer 3 (Version Info): AES-256-GCM (静态密钥) + * + * 响应解密: AES-256-GCM (动态密钥) + * + * @see issue.md - 完整逆向文档 + */ +const crypto = require('crypto') + +// ============================================================ +// 常量定义 +// ============================================================ + +/** XOR 解码密钥 (从 p62/f.smali h() 提取) */ +const XOR_KEY = Buffer.from([0xa1, 0xa3, 0xa2, 0xa3]) + +/** + * XOR 解码 (用于解密 APK 中的混淆字符串) + * + * 流程: + * 输入 XOR 编码后的 base64 → Base64 解码 → XOR → 得到 ASCII base64 → Base64 解码 + * + * @param {string} input - XOR 编码后的 base64 输入 + * @returns {Buffer} 解码后的原始字节 + */ +function xorDecode(input) { + const buf = Buffer.from(input, 'base64') + for (let i = 0; i < buf.length; i++) buf[i] ^= XOR_KEY[i % 4] + return Buffer.from(buf.toString('utf-8'), 'base64') +} + +/** + * 静态 AES-256 密钥 + * + * 🟡 待确认: 从 libAegisSDK.so / p62/f.smali h() 提取 + * 可通过环境变量 XEAPI_STATIC_KEY (hex, 64字符) 覆盖 + * + * 目前使用来自 issue.md 的参考值 (可能不完整, 自动填充到32字节) + * 请从运行中的 Android 客户端提取正确的 64 字符 hex 密钥后设置环境变量 + */ +const STATIC_AES_KEY = (() => { + const envKey = process.env.XEAPI_STATIC_KEY + if (envKey) { + const buf = Buffer.from(envKey, 'hex') + if (buf.length === 32) return buf + console.warn( + `[xeapi] ⚠️ 环境变量 XEAPI_STATIC_KEY 长度错误 (期望 64 hex 字符, 实际 ${envKey.length}), 使用默认密钥`, + ) + } + + // 参考值 (issue.md 提供, 可能不完整) + const keyHex = 'b31d1a4bf2ba849fe97edd55727d896dc1ed9ee492c0e86947c6dfb93b1' + const key = Buffer.from(keyHex, 'hex') + + if (key.length < 32) { + console.warn( + `[xeapi] ⚠️ 静态密钥长度 ${key.length} 字节, 自动填充到 32 字节. ` + + `建议通过 XEAPI_STATIC_KEY 环境变量设置正确的 64 字符 hex 密钥`, + ) + const padded = Buffer.alloc(32, 0) + key.copy(padded) + return padded + } + return key.subarray(0, 32) +})() + +/** + * HKDF salt + * 🟡 待从 libAegisSDK.so DAT_001a04c0 提取 (Ghidra read_memory) + * 这是一个 16 字节的全局数据,目前使用占位符 + */ +const HKDF_SALT = Buffer.alloc(16, 0) + +/** + * 版本字符串 + * 🟡 待从 DAT_001a92e6 提取 + */ +const VERSION = '1.0' + +/** + * 默认服务器 ECC 公钥 (P-256 x-coordinate, base64 编码) + * 🟡 需从 Android 应用 /aegissdk/public_key 提取 + * + * 可通过环境变量 XEAPI_SERVER_PUBLIC_KEY 或在 options 中传入 + */ +const DEFAULT_SERVER_PUBLIC_KEY = process.env.XEAPI_SERVER_PUBLIC_KEY || '' + +// ============================================================ +// 辅助函数 +// ============================================================ + +/** + * 将 16 字节密钥填充为 32 字节 (AES-256 需要 32 字节密钥) + * 在末尾补零 + */ +function padKeyTo32(key) { + // 兼容 ArrayBuffer (crypto.hkdfSync 在某些 Node 版本返回 ArrayBuffer) + if (key instanceof ArrayBuffer || ArrayBuffer.isView(key)) { + key = Buffer.from(key) + } + if (!Buffer.isBuffer(key)) { + key = Buffer.from(key) + } + if (key.length >= 32) return key.subarray(0, 32) + return Buffer.concat([key, Buffer.alloc(32 - key.length, 0)]) +} + +/** + * AES-256-GCM 加密 + * + * @param {Buffer} plaintext - 明文 + * @param {Buffer} key - 32 字节密钥 + * @param {number} [ivLength=16] - IV 长度 (12 或 16) + * @returns {Buffer} IV(16) + ciphertext + tag(16) + */ +function aes256GcmEncrypt(plaintext, key, ivLength = 16) { + const iv = crypto.randomBytes(ivLength) + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const tag = cipher.getAuthTag() + return Buffer.concat([iv, encrypted, tag]) +} + +/** + * AES-256-GCM 加密 (使用指定 IV) + * + * @param {Buffer} plaintext - 明文 + * @param {Buffer} key - 32 字节密钥 + * @param {Buffer} iv - 指定 IV + * @returns {Buffer} ciphertext + tag(16) (不含 IV 前缀) + */ +function aes256GcmEncryptWithIv(plaintext, key, iv) { + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const tag = cipher.getAuthTag() + return Buffer.concat([encrypted, tag]) +} + +/** + * AES-256-GCM 解密 + * + * @param {Buffer} ciphertextWithIv - IV(16/12) + ciphertext + tag(16) + * @param {Buffer} key - 32 字节密钥 + * @param {number} [ivLength=16] - IV 长度 + * @returns {Buffer} 解密后的明文 + */ +function aes256GcmDecrypt(ciphertextWithIv, key, ivLength = 16) { + const iv = ciphertextWithIv.subarray(0, ivLength) + const tag = ciphertextWithIv.subarray(-16) + const data = ciphertextWithIv.subarray(ivLength, -16) + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(data), decipher.final()]) +} + +// ============================================================ +// Layer 1: 加密业务数据 (EncryptBusinessData) +// ============================================================ + +/** + * 第 1 层加密 - 双重 AES-256-GCM + * + * plaintext (JSON) + * ├─ [Round 1] 静态 AES-256-GCM (随机 16-byte IV) + * │ 输出: [IV(16)] [ciphertext] [tag(16)] + * └─ Base64 编码 + * └─ [Round 2] 动态 AES-256-GCM (动态密钥填充到 32 字节, 随机 16-byte IV) + * 输出: [IV(16)] [ciphertext] [tag(16)] = cipherB + * + * @param {Buffer} plaintext - JSON 明文字节 + * @param {Buffer} dynamicKey - 16 字节动态密钥 + * @returns {Buffer} cipherB + */ +function encryptLayer1(plaintext, dynamicKey) { + // Round 1: 静态 AES-256-GCM + const round1 = aes256GcmEncrypt(plaintext, STATIC_AES_KEY, 16) + + // Base64 编码 Round 1 输出 + const round1B64 = Buffer.from(round1.toString('base64')) + + // Round 2: 动态 AES-256-GCM (密钥填充到 32 字节) + const dk32 = padKeyTo32(dynamicKey) + const cipherB = aes256GcmEncrypt(round1B64, dk32, 16) + + return cipherB +} + +// ============================================================ +// Layer 2: 加密动态密钥 (EncryptDynamicKey) +// ============================================================ + +/** + * P-256 点解压缩 + * 将 32 字节 x 坐标转换为完整的未压缩公钥 (65 字节) + * + * @param {Buffer} xBytes - 32 字节 x 坐标 + * @returns {Buffer} 65 字节未压缩公钥 (0x04 + x + y) + */ +function decompressP256Point(xBytes) { + // P-256曲线参数 + // y² = x³ - 3x + b (mod p) + const p = BigInt( + '0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff', + ) + const b = BigInt( + '0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b', + ) + const three = BigInt(3) + + // 将 x 转换为 BigInt + const x = BigInt('0x' + xBytes.toString('hex')) + + // 计算 alpha = x³ - 3x + b (mod p) + const x3 = (x * x * x) % p + const minus3x = (p - ((three * x) % p)) % p // 等同于 -3x mod p + const alpha = (x3 + minus3x + b) % p + + // 计算 y = sqrt(alpha) mod p + // 对于 P-256: p ≡ 3 mod 4, 所以 sqrt(a) = a^((p+1)/4) mod p + const exp = (p + BigInt(1)) / BigInt(4) + const y = modPow(alpha, exp, p) + + // 拼接未压缩公钥: 0x04 + x(32) + y(32) = 65 bytes + const yBytes = Buffer.from(y.toString(16).padStart(64, '0'), 'hex') + return Buffer.concat([Buffer.from([0x04]), xBytes, yBytes]) +} + +/** + * 大数模幂运算 (BigInt) + * 计算 (base^exp) % mod + */ +function modPow(base, exp, mod) { + let result = BigInt(1) + base = base % mod + while (exp > 0) { + if (exp & BigInt(1)) result = (result * base) % mod + exp = exp >> BigInt(1) + base = (base * base) % mod + } + return result +} + +/** + * 第 2 层加密 - ECDH(P-256) + HKDF-SHA256 + AES-256-GCM + * + * 服务器公钥 (base64 解码 → 32 字节 x 坐标) + * ├─ 客户端生成临时 ECC 密钥对 (P-256) + * ├─ ECDH 密钥协商: + * │ 共享密钥 = clientPrivate × serverPublic + * ├─ HKDF-SHA256 派生: + * │ IKM = 共享密钥 + * │ salt = DAT_001a04c0 (16 字节) + * │ info = clientEphemeralPubKey (65 字节) + * │ L = 16 字节 → derivedKey + * └─ AES-256-GCM 加密 (12 字节 IV) + * 明文 = base64(dynamicKey) + " " + base64(serverPubKey) + " " + version + * 输出 cipherS = [clientPubKey(65)] [IV(12)] [ciphertext] [tag(16)] + * + * @param {Buffer} dynamicKey - 16 字节动态密钥 + * @param {string} serverPublicKeyBase64 - 服务器公钥 (base64, 32 字节 x 坐标) + * @returns {Buffer} cipherS + */ +function encryptLayer2(dynamicKey, serverPublicKeyBase64) { + // 如果没有服务器公钥,生成一个临时密钥对用于测试/占位 + if (!serverPublicKeyBase64) { + console.warn( + '[xeapi] ⚠️ 服务器公钥为空,使用临时密钥占位 (此加密无法被真实服务器解密)', + ) + const tempEcdh = crypto.createECDH('prime256v1') + tempEcdh.generateKeys() + serverPublicKeyBase64 = tempEcdh + .getPublicKey(null, 'compressed') + .toString('base64') + } + + // 生成客户端临时 ECC 密钥对 + const ecdh = crypto.createECDH('prime256v1') + ecdh.generateKeys() + const clientPubKey = ecdh.getPublicKey(null, 'uncompressed') // 65 字节 + + // 服务器公钥解码 + const spkBuf = Buffer.from(serverPublicKeyBase64, 'base64') + + let serverPublicKey + if (spkBuf.length === 33 && (spkBuf[0] === 0x02 || spkBuf[0] === 0x03)) { + // 压缩格式公钥: 0x02/0x03 + x(32) + serverPublicKey = spkBuf + } else if (spkBuf.length === 65 && spkBuf[0] === 0x04) { + // 未压缩格式公钥: 0x04 + x(32) + y(32) + serverPublicKey = spkBuf + } else if (spkBuf.length === 32) { + // 只有 x 坐标 → 解压缩 + serverPublicKey = decompressP256Point(spkBuf) + } else { + // 可能是未压缩或压缩格式但缺少标识字节, 尝试解压缩 + try { + serverPublicKey = decompressP256Point(spkBuf.subarray(0, 32)) + } catch { + serverPublicKey = spkBuf + } + } + + // ECDH 密钥协商 + const sharedSecret = ecdh.computeSecret(serverPublicKey) + + // HKDF-SHA256 派生 + const derivedKey = crypto.hkdfSync( + 'sha256', + sharedSecret, + HKDF_SALT, + clientPubKey, // info = 客户端临时公钥 + 16, // 输出 16 字节 + ) + + // AES-256-GCM 加密 (12 字节 IV) + const iv = crypto.randomBytes(12) + const dk32 = padKeyTo32(derivedKey) + + // 明文: base64(dynamicKey) + " " + base64(serverPubKeyBase64) + " " + version + const plaintext = Buffer.concat([ + Buffer.from(dynamicKey.toString('base64')), + Buffer.from(' '), + Buffer.from(serverPublicKeyBase64), + Buffer.from(' '), + Buffer.from(VERSION), + ]) + + const encResult = aes256GcmEncryptWithIv(plaintext, dk32, iv) + + // cipherS = [clientPubKey(65)] [IV(12)] [encResult(ct+tag)] + return Buffer.concat([clientPubKey, iv, encResult]) +} + +// ============================================================ +// Layer 3: 加密版本信息 (EncryptVersionInfo) +// ============================================================ + +/** + * 第 3 层加密 - AES-256-GCM (静态密钥) + * + * 明文: version|sessionId (例如 "1.0|") + * IV: 随机 16 字节 + * 输出 cipherR = [IV(16)] [ciphertext] [tag(16)] + * + * @returns {Buffer} cipherR + */ +function encryptLayer3(sessionId = '') { + const plaintext = Buffer.from(`${VERSION}|${sessionId}`) + return aes256GcmEncrypt(plaintext, STATIC_AES_KEY, 16) +} + +// ============================================================ +// 主加密函数 +// ============================================================ + +/** + * xeapi 加密入口 + * + * 对业务数据进行三层加密,生成 { B, S, R } 参数 + * + * @param {Object|string} data - 业务数据 (JSON 对象或字符串) + * @param {Object} [options] - 加密选项 + * @param {string} [options.serverPublicKey] - 服务器 ECC 公钥 (base64) + * @param {Buffer} [options.dynamicKey] - 指定动态密钥 (不指定则随机生成) + * @returns {{ params: { B: string, S: string, R: string }, dynamicKey: Buffer }} + */ +function xeapiEncrypt(data, options = {}) { + const serverKey = options.serverPublicKey || DEFAULT_SERVER_PUBLIC_KEY + + if (!serverKey) { + console.warn( + '[xeapi] ⚠️ 服务器公钥未设置!请通过 options.serverPublicKey 或环境变量 XEAPI_SERVER_PUBLIC_KEY 设置', + ) + } + + // 将数据序列化为 JSON + const plaintext = Buffer.from( + typeof data === 'string' ? data : JSON.stringify(data), + ) + + // 生成随机 16 字节动态密钥 + const dynamicKey = options.dynamicKey || crypto.randomBytes(16) + + // 第 1 层: 加密业务数据 + const cipherB = encryptLayer1(plaintext, dynamicKey) + + // 第 2 层: 加密动态密钥 + const cipherS = encryptLayer2(dynamicKey, serverKey) + + // 第 3 层: 加密版本信息 + const cipherR = encryptLayer3() + + return { + params: { + B: cipherB.toString('base64'), + S: cipherS.toString('base64'), + R: cipherR.toString('base64'), + }, + dynamicKey, + } +} + +// ============================================================ +// 响应解密 +// ============================================================ + +/** + * 解密 xeapi 响应 + * + * 响应格式: 直接 AES-256-GCM 加密的二进制密文 (无 Base64) + * [IV(16)] [ciphertext] [tag(16)] + * + * 使用请求时生成的动态密钥解密 + * + * @param {Buffer} encryptedBuffer - 原始二进制加密响应体 + * @param {Buffer} dynamicKey - 16 字节动态密钥 (从 xeapiEncrypt 返回值获取) + * @returns {Object} 解密后的 JSON 对象 + */ +function xeapiDecryptResponse(encryptedBuffer, dynamicKey) { + const dk32 = padKeyTo32(dynamicKey) + const plaintext = aes256GcmDecrypt(encryptedBuffer, dk32, 16) + return JSON.parse(plaintext.toString('utf-8')) +} + +// ============================================================ +// 导出 +// ============================================================ + +module.exports = { + // 常量 + STATIC_AES_KEY, + HKDF_SALT, + VERSION, + XOR_KEY, + DEFAULT_SERVER_PUBLIC_KEY, + + // 加密 + xeapiEncrypt, + encryptLayer1, + encryptLayer2, + encryptLayer3, + + // 解密 + xeapiDecryptResponse, + + // 辅助 + padKeyTo32, + aes256GcmEncrypt, + aes256GcmDecrypt, + decompressP256Point, +}