mirror of
https://git.unlock-music.dev/um/um-react.git
synced 2024-11-27 17:02:16 +00:00
Compare commits
No commits in common. "5e890bca7799b3c0a558eb86a63fe2b7822b8e8b" and "c5bc436ab2deb6f6c5703a37727ceb2faedf8a4b" have entirely different histories.
5e890bca77
...
c5bc436ab2
@ -1 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
pnpm exec lint-staged
|
pnpm exec lint-staged
|
||||||
|
@ -1 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
pnpm test
|
pnpm test
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="test" type="js.build_tools.npm">
|
|
||||||
<package-json value="$PROJECT_DIR$/package.json" />
|
|
||||||
<command value="run" />
|
|
||||||
<scripts>
|
|
||||||
<script value="test" />
|
|
||||||
</scripts>
|
|
||||||
<node-interpreter value="project" />
|
|
||||||
<envs />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
@ -1,12 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="vite dev" type="js.build_tools.npm">
|
|
||||||
<package-json value="$PROJECT_DIR$/package.json" />
|
|
||||||
<command value="run" />
|
|
||||||
<scripts>
|
|
||||||
<script value="start" />
|
|
||||||
</scripts>
|
|
||||||
<node-interpreter value="project" />
|
|
||||||
<envs />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
10
README.MD
10
README.MD
@ -40,8 +40,7 @@
|
|||||||
[^qm-key-ios]: 需要越狱获取密钥数据库,或对设备进行完整备份后提取密钥数据库,并导入后使用。
|
[^qm-key-ios]: 需要越狱获取密钥数据库,或对设备进行完整备份后提取密钥数据库,并导入后使用。
|
||||||
[^qm-key-mac]: 需要导入密钥数据库。
|
[^qm-key-mac]: 需要导入密钥数据库。
|
||||||
|
|
||||||
不支持的格式?请提交样本(加密文件)与客户端信息(或一并上传其安装包)到[仓库的问题追踪区][project-issues]
|
不支持的格式?请提交样本(加密文件)与客户端信息(或一并上传其安装包)到[仓库的问题追踪区][project-issues]。如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。
|
||||||
。如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。
|
|
||||||
|
|
||||||
如果遇到解密出错的情况,请一并携带错误信息并简单描述错误的重现过程。
|
如果遇到解密出错的情况,请一并携带错误信息并简单描述错误的重现过程。
|
||||||
|
|
||||||
@ -51,11 +50,11 @@
|
|||||||
|
|
||||||
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
|
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
|
||||||
|
|
||||||
### 解密库开发
|
### 面向 libparakeet SDK 开发
|
||||||
|
|
||||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
||||||
|
|
||||||
请参考文档「[面向 `@unlock-music/crypto` 开发](./docs/develop-with-um_crypto.zh)」。
|
请参考文档「[面向 `libparakeet-js` 开发](./docs/develop-with-libparakeet.zh.md)」。
|
||||||
|
|
||||||
### 架构
|
### 架构
|
||||||
|
|
||||||
@ -80,8 +79,7 @@
|
|||||||
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版
|
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版
|
||||||
- [um-react (Electron 前端)](https://github.com/CarlGao4/um-react-electron) - 使用 Electron 框架封装的本地可执行文件。
|
- [um-react (Electron 前端)](https://github.com/CarlGao4/um-react-electron) - 使用 Electron 框架封装的本地可执行文件。
|
||||||
- [GitHub 下载](https://github.com/CarlGao4/um-react-electron/releases/latest) | [仓库镜像](https://git.unlock-music.dev/CarlGao4/um-react-electron)
|
- [GitHub 下载](https://github.com/CarlGao4/um-react-electron/releases/latest) | [仓库镜像](https://git.unlock-music.dev/CarlGao4/um-react-electron)
|
||||||
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
|
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (需要[安装 Edge WebView2 运行时][webview2_redist],Win10+ 操作系统自带)
|
||||||
需要[安装 Edge WebView2 运行时][webview2_redist],Win10+ 操作系统自带)
|
|
||||||
- [本地下载](https://git.unlock-music.dev/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件
|
- [本地下载](https://git.unlock-music.dev/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件
|
||||||
|
|
||||||
[webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703
|
[webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703
|
||||||
|
50
docs/develop-with-libparakeet.zh.md
Normal file
50
docs/develop-with-libparakeet.zh.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# 面向 `libparakeet-js` 开发
|
||||||
|
|
||||||
|
⚠️ 如果只是进行前端方面的更改,你可以跳过该文档。
|
||||||
|
|
||||||
|
`libparakeet-js` 编译目前需要 Linux 环境,请参考[仓库说明][libparakeet-js-doc]。
|
||||||
|
|
||||||
|
该文档将假设这两个项目被放置在同级的目录下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Projects/um-projects
|
||||||
|
/um-react
|
||||||
|
/libparakeet-js
|
||||||
|
```
|
||||||
|
|
||||||
|
若为不同目录,你需要调整 `LIB_PARAKEET_JS_DIR` 环境变量到仓库目录,然后再启动 vite 项目。
|
||||||
|
|
||||||
|
[libparakeet-js-doc]: https://github.com/parakeet-rs/libparakeet-js/blob/main/README.MD
|
||||||
|
|
||||||
|
## 初次构建
|
||||||
|
|
||||||
|
- 进入上层目录:`cd ..`
|
||||||
|
- 克隆 `libparakeet-js` 仓库 (目前需要 Linux 环境, Windows 下推荐使用 WSL2)
|
||||||
|
- `git clone --recurse-submodules https://github.com/parakeet-rs/libparakeet-js.git`
|
||||||
|
- 进入 SDK 目录:`cd libparakeet-js`
|
||||||
|
- 如果需要更新 `submodule`:`git submodule update --init --recursive`
|
||||||
|
- 构建所有代码:`make all`
|
||||||
|
|
||||||
|
如果需要手动控制构建过程,你也可以:
|
||||||
|
|
||||||
|
- 运行 `./build.sh -j 4` 进行 C++ 到 WebAssembly 编译过程
|
||||||
|
- 此处的 `4` 是并行编译数量,该值通常略小于 CPU 核心数。
|
||||||
|
- 若是不指定并行数量,则使用当前核心数。
|
||||||
|
- 编译 `js-sdk`:
|
||||||
|
- 进入 `npm` 目录:`cd npm`
|
||||||
|
- 安装依赖:`pnpm i --frozen-lockfile`
|
||||||
|
- 构建:`pnpm build`
|
||||||
|
|
||||||
|
## 做出更改
|
||||||
|
|
||||||
|
做出更改后,参考上面的内容进行重新编译。
|
||||||
|
|
||||||
|
## 应用 SDK 更改
|
||||||
|
|
||||||
|
将构建好的 SDK 直接嵌入到当前前端项目:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm link ../libparakeet-js/npm
|
||||||
|
```
|
||||||
|
|
||||||
|
※ 建立 PR 时,请先提交 SDK PR 并确保你的 SDK 更改已合并。
|
@ -1,36 +0,0 @@
|
|||||||
# 面向 `@unlock-music/crypto` 开发
|
|
||||||
|
|
||||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该文档。
|
|
||||||
|
|
||||||
该文档将假设这两个项目被放置在同级的目录下:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/Projects/um-projects
|
|
||||||
/um-react
|
|
||||||
/lib_um_crypto_rust
|
|
||||||
```
|
|
||||||
|
|
||||||
若为不同目录,你需要调整 `LIB_UM_WASM_LOADER_DIR` 环境变量到仓库目录,然后再启动 vite 项目。
|
|
||||||
|
|
||||||
## 初次构建
|
|
||||||
|
|
||||||
- 进入上层目录:`cd ..`
|
|
||||||
- 克隆 `lib_um_crypto_rust` 仓库
|
|
||||||
- `git clone https://git.unlock-music.dev/um/lib_um_crypto_rust.git`
|
|
||||||
- 进入 SDK 目录:`cd lib_um_crypto_rust ; cd um_wasm_loader`
|
|
||||||
- 安装所有 Node 以来:`pnpm i`
|
|
||||||
- 构建:`pnpm build`
|
|
||||||
|
|
||||||
## 做出更改
|
|
||||||
|
|
||||||
做出更改后,参考上面的内容进行重新编译。
|
|
||||||
|
|
||||||
## 应用 SDK 更改
|
|
||||||
|
|
||||||
将构建好的 SDK 直接嵌入到当前前端项目:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm link ../lib_um_crypto_rust/um_wasm_loader/
|
|
||||||
```
|
|
||||||
|
|
||||||
※ 建立 PR 时,请先提交 SDK PR 并确保你的 SDK 更改已合并。
|
|
82
package.json
82
package.json
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "um-react",
|
"name": "um-react",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.2.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
@ -23,52 +23,52 @@
|
|||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@reduxjs/toolkit": "^2.0.1",
|
"@reduxjs/toolkit": "^2.0.1",
|
||||||
"@unlock-music/crypto": "0.1.0",
|
"@um/libparakeet": "0.4.5",
|
||||||
"framer-motion": "^11.5.6",
|
"@unlock-music/crypto": "0.0.0-alpha.6",
|
||||||
"nanoid": "^5.0.7",
|
"framer-motion": "^10.16.16",
|
||||||
"radash": "^12.1.0",
|
"nanoid": "^5.0.4",
|
||||||
"react": "^18.3.1",
|
"radash": "^11.0.0",
|
||||||
"react-dom": "^18.3.1",
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^4.12.0",
|
||||||
"react-promise-suspense": "^0.3.4",
|
"react-promise-suspense": "^0.3.4",
|
||||||
"react-redux": "^9.1.2",
|
"react-redux": "^9.0.4",
|
||||||
"react-syntax-highlighter": "^15.5.0",
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
"sass": "^1.79.3",
|
"sass": "^1.69.5",
|
||||||
"sql.js": "^1.11.0"
|
"sql.js": "^1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-replace": "^6.0.1",
|
"@rollup/plugin-replace": "^5.0.5",
|
||||||
"@testing-library/jest-dom": "^6.5.0",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
"@testing-library/react": "^16.0.1",
|
"@testing-library/react": "^14.1.2",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.1",
|
||||||
"@types/node": "^22.6.1",
|
"@types/node": "^20.10.5",
|
||||||
"@types/react": "^18.3.9",
|
"@types/react": "^18.2.45",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.11",
|
||||||
"@types/sql.js": "^1.4.9",
|
"@types/sql.js": "^1.4.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.7.0",
|
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||||
"@typescript-eslint/parser": "^8.7.0",
|
"@typescript-eslint/parser": "^6.15.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"@vitest/coverage-v8": "^2.1.1",
|
"@vitest/coverage-v8": "^1.1.0",
|
||||||
"@vitest/ui": "^2.1.1",
|
"@vitest/ui": "^1.1.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.12",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
"husky": "^9.1.6",
|
"husky": "^8.0.3",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^23.0.1",
|
||||||
"lint-staged": "^15.2.10",
|
"lint-staged": "^15.2.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.1.1",
|
||||||
"rollup": "^4.22.4",
|
"terser": "^5.27.0",
|
||||||
"terser": "^5.33.0",
|
"typescript": "^5.3.3",
|
||||||
"typescript": "^5.6.2",
|
"vite": "^5.0.10",
|
||||||
"vite": "^5.4.7",
|
"vite-plugin-pwa": "^0.17.4",
|
||||||
"vite-plugin-pwa": "^0.20.5",
|
"vite-plugin-top-level-await": "^1.4.1",
|
||||||
"vite-plugin-top-level-await": "^1.4.4",
|
|
||||||
"vite-plugin-wasm": "^3.3.0",
|
"vite-plugin-wasm": "^3.3.0",
|
||||||
"vitest": "^2.1.1",
|
"vitest": "^1.1.0",
|
||||||
"workbox-window": "^7.1.0"
|
"workbox-window": "^7.0.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*": "prettier --write --ignore-unknown",
|
"*": "prettier --write --ignore-unknown",
|
||||||
@ -81,8 +81,8 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@rollup/plugin-terser": "patches/@rollup__plugin-terser.patch",
|
"@rollup/plugin-terser@0.4.3": "patches/@rollup__plugin-terser@0.4.3.patch",
|
||||||
"sql.js": "patches/sql.js.patch"
|
"sql.js@1.9.0": "patches/sql.js@1.9.0.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",
|
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
|
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
|
||||||
index b16cee5c3cbdf523f9beae920258094ae7fcbd0f..ae67be7145625c60995c5044860e87d6144a8837 100644
|
index d29af3624109025e59966cf25cb357111bb459de..1b028e3d91ec37108f775627f31f1134aec47476 100644
|
||||||
--- a/dist/sql-wasm.js
|
--- a/dist/sql-wasm.js
|
||||||
+++ b/dist/sql-wasm.js
|
+++ b/dist/sql-wasm.js
|
||||||
@@ -187,3 +187,6 @@ else if (typeof define === 'function' && define['amd']) {
|
@@ -190,3 +190,6 @@ else if (typeof define === 'function' && define['amd']) {
|
||||||
else if (typeof exports === 'object'){
|
else if (typeof exports === 'object'){
|
||||||
exports["Module"] = initSqlJs;
|
exports["Module"] = initSqlJs;
|
||||||
}
|
}
|
6548
pnpm-lock.yaml
6548
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
27
src/crypto/parseKuwo.ts
Normal file
27
src/crypto/parseKuwo.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
|
||||||
|
import { strlen } from './strlen';
|
||||||
|
|
||||||
|
export interface KuwoHeader {
|
||||||
|
rid: string; // uint64
|
||||||
|
encVersion: 1 | 2; // uint32
|
||||||
|
quality: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KUWO_MAGIC_HDRS = new Set(['yeelion-kuwo\x00\x00\x00\x00', 'yeelion-kuwo-tme']);
|
||||||
|
|
||||||
|
export function parseKuwoHeader(view: DataView): KuwoHeader | null {
|
||||||
|
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10);
|
||||||
|
if (!KUWO_MAGIC_HDRS.has(bytesToUTF8String(magic))) {
|
||||||
|
return null; // not kuwo-encrypted file
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualityBytes = new Uint8Array(view.buffer.slice(view.byteOffset + 0x30, view.byteOffset + 0x40));
|
||||||
|
const qualityLen = strlen(qualityBytes);
|
||||||
|
const quality = bytesToUTF8String(qualityBytes.slice(0, qualityLen));
|
||||||
|
|
||||||
|
return {
|
||||||
|
encVersion: view.getUint32(0x10, true) as 1 | 2,
|
||||||
|
rid: view.getUint32(0x18, true).toString(),
|
||||||
|
quality,
|
||||||
|
};
|
||||||
|
}
|
9
src/crypto/strlen.ts
Normal file
9
src/crypto/strlen.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export function strlen(data: Uint8Array): number {
|
||||||
|
const n = data.byteLength;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (data[i] === 0) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
@ -1,85 +0,0 @@
|
|||||||
import { NetEaseCloudMusicDecipher } from '~/decrypt-worker/decipher/NetEaseCloudMusic.ts';
|
|
||||||
import { TransparentDecipher } from './decipher/Transparent.ts';
|
|
||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
|
||||||
import { QQMusicV1Decipher, QQMusicV2Decipher } from '~/decrypt-worker/decipher/QQMusic.ts';
|
|
||||||
import { KuwoMusicDecipher } from '~/decrypt-worker/decipher/KuwoMusic.ts';
|
|
||||||
import { KugouMusicDecipher } from '~/decrypt-worker/decipher/KugouMusic.ts';
|
|
||||||
import { XimalayaAndroidDecipher, XimalayaPCDecipher } from '~/decrypt-worker/decipher/Ximalaya.ts';
|
|
||||||
import { XiamiDecipher } from '~/decrypt-worker/decipher/XiamiMusic.ts';
|
|
||||||
import { QignTingFMDecipher } from '~/decrypt-worker/decipher/QingTingFM.ts';
|
|
||||||
import { Migu3DKeylessDecipher } from '~/decrypt-worker/decipher/Migu3d.ts';
|
|
||||||
|
|
||||||
export enum Status {
|
|
||||||
OK = 0,
|
|
||||||
NOT_THIS_CIPHER = 1,
|
|
||||||
FAILED = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DecipherResult = DecipherOK | DecipherNotOK;
|
|
||||||
|
|
||||||
export interface DecipherNotOK {
|
|
||||||
status: Exclude<Status, Status.OK>;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DecipherOK {
|
|
||||||
status: Status.OK;
|
|
||||||
message?: string;
|
|
||||||
data: Uint8Array;
|
|
||||||
overrideExtension?: string;
|
|
||||||
cipherName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DecipherInstance {
|
|
||||||
cipherName: string;
|
|
||||||
|
|
||||||
decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DecipherFactory = () => DecipherInstance;
|
|
||||||
|
|
||||||
export const allCryptoFactories: DecipherFactory[] = [
|
|
||||||
/// File with fixed headers goes first.
|
|
||||||
|
|
||||||
// NCM (*.ncm)
|
|
||||||
NetEaseCloudMusicDecipher.make,
|
|
||||||
|
|
||||||
// KGM (*.kgm, *.vpr)
|
|
||||||
KugouMusicDecipher.make,
|
|
||||||
|
|
||||||
// KWMv1 (*.kwm)
|
|
||||||
KuwoMusicDecipher.make,
|
|
||||||
|
|
||||||
// Ximalaya PC (*.xm)
|
|
||||||
XimalayaPCDecipher.make,
|
|
||||||
|
|
||||||
// Xiami (*.xm)
|
|
||||||
XiamiDecipher.make,
|
|
||||||
|
|
||||||
// QingTingFM Android (*.qta)
|
|
||||||
QignTingFMDecipher.make,
|
|
||||||
|
|
||||||
/// File with a fixed footer goes second
|
|
||||||
|
|
||||||
// QMCv2 (*.mflac)
|
|
||||||
QQMusicV2Decipher.createWithUserKey,
|
|
||||||
QQMusicV2Decipher.createWithEmbeddedEKey,
|
|
||||||
|
|
||||||
/// File without an obvious header or footer goes last.
|
|
||||||
|
|
||||||
// Migu3D/Keyless (*.wav; *.m4a)
|
|
||||||
Migu3DKeylessDecipher.make,
|
|
||||||
|
|
||||||
// Crypto that does not implement "checkBySignature" or need to decrypt the entire file and then check audio type,
|
|
||||||
// should be moved to the bottom of the list for performance reasons.
|
|
||||||
|
|
||||||
// QMCv1 (*.qmcflac)
|
|
||||||
QQMusicV1Decipher.create,
|
|
||||||
|
|
||||||
// Ximalaya (Android)
|
|
||||||
XimalayaAndroidDecipher.makeX2M,
|
|
||||||
XimalayaAndroidDecipher.makeX3M,
|
|
||||||
|
|
||||||
// Transparent crypto (not encrypted)
|
|
||||||
TransparentDecipher.make,
|
|
||||||
];
|
|
@ -1,8 +1,6 @@
|
|||||||
export enum DECRYPTION_WORKER_ACTION_NAME {
|
export enum DECRYPTION_WORKER_ACTION_NAME {
|
||||||
DECRYPT = 'DECRYPT',
|
DECRYPT = 'DECRYPT',
|
||||||
FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME',
|
FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME',
|
||||||
KUWO_PARSE_HEADER = 'KUWO_PARSE_HEADER',
|
|
||||||
QINGTING_FM_GET_DEVICE_KEY = 'QINGTING_FM_GET_DEVICE_KEY',
|
|
||||||
VERSION = 'VERSION',
|
VERSION = 'VERSION',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
17
src/decrypt-worker/crypto/CryptoBase.ts
Normal file
17
src/decrypt-worker/crypto/CryptoBase.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||||
|
|
||||||
|
export interface CryptoBase {
|
||||||
|
cryptoName: string;
|
||||||
|
checkByDecryptHeader: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If set, this new extension will be used instead.
|
||||||
|
* Useful for non-audio format, e.g. qrc to lrc/xml.
|
||||||
|
*/
|
||||||
|
overrideExtension?: string;
|
||||||
|
|
||||||
|
checkBySignature?: (buffer: ArrayBuffer, options: DecryptCommandOptions) => Promise<boolean>;
|
||||||
|
decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob | ArrayBuffer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CryptoFactory = () => CryptoBase;
|
55
src/decrypt-worker/crypto/CryptoFactory.ts
Normal file
55
src/decrypt-worker/crypto/CryptoFactory.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { CryptoFactory } from './CryptoBase';
|
||||||
|
|
||||||
|
import { QMC1Crypto } from './qmc/qmc_v1';
|
||||||
|
import { QMC2Crypto, QMC2CryptoWithKey } from './qmc/qmc_v2';
|
||||||
|
import { XiamiCrypto } from './xiami/xiami';
|
||||||
|
import { KGMCrypto } from './kgm/kgm_pc';
|
||||||
|
import { NCMCrypto } from './ncm/ncm_pc';
|
||||||
|
import { XimalayaAndroidCrypto } from './xmly/xmly_android';
|
||||||
|
import { KWMCrypto } from './kwm/kwm';
|
||||||
|
import { MiguCrypto } from './migu/migu3d_keyless';
|
||||||
|
import { TransparentCrypto } from './transparent/transparent';
|
||||||
|
import { QingTingFM$Device } from './qtfm/qtfm_device';
|
||||||
|
|
||||||
|
export const allCryptoFactories: CryptoFactory[] = [
|
||||||
|
/// File with fixed headers goes first.
|
||||||
|
|
||||||
|
// NCM (*.ncm)
|
||||||
|
NCMCrypto.make,
|
||||||
|
|
||||||
|
// KGM (*.kgm, *.vpr)
|
||||||
|
KGMCrypto.make,
|
||||||
|
|
||||||
|
// KWMv1 (*.kwm)
|
||||||
|
KWMCrypto.make,
|
||||||
|
|
||||||
|
// Xiami (*.xm)
|
||||||
|
XiamiCrypto.make,
|
||||||
|
|
||||||
|
/// File with a fixed footer goes second
|
||||||
|
|
||||||
|
// QMCv2 (*.mflac)
|
||||||
|
QMC2CryptoWithKey.make,
|
||||||
|
QMC2Crypto.make,
|
||||||
|
|
||||||
|
/// File without an obvious header or footer goes last.
|
||||||
|
|
||||||
|
// Migu3D/Keyless (*.wav; *.m4a)
|
||||||
|
MiguCrypto.make,
|
||||||
|
|
||||||
|
// Crypto that does not implement "checkBySignature" or need to decrypt the entire file and then check audio type,
|
||||||
|
// should be moved to the bottom of the list for performance reasons.
|
||||||
|
|
||||||
|
// QMCv1 (*.qmcflac)
|
||||||
|
QMC1Crypto.make,
|
||||||
|
|
||||||
|
// Ximalaya (Android)
|
||||||
|
XimalayaAndroidCrypto.makeX2M,
|
||||||
|
XimalayaAndroidCrypto.makeX3M,
|
||||||
|
|
||||||
|
// QingTingFM (Android)
|
||||||
|
QingTingFM$Device.make,
|
||||||
|
|
||||||
|
// Transparent crypto (not encrypted)
|
||||||
|
TransparentCrypto.make,
|
||||||
|
];
|
6
src/decrypt-worker/crypto/kgm/kgm_pc.key.ts
Normal file
6
src/decrypt-worker/crypto/kgm/kgm_pc.key.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE_RAW from './kgm_type4_file_key_expansion_table.txt?raw';
|
||||||
|
import KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE_RAW from './kgm_type4_slot_key_expansion_table.txt?raw';
|
||||||
|
|
||||||
|
export const KGM_SLOT_1_KEY = "l,/'";
|
||||||
|
export const KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE = KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE_RAW.trim();
|
||||||
|
export const KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE = KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE_RAW.trim();
|
18
src/decrypt-worker/crypto/kgm/kgm_pc.ts
Normal file
18
src/decrypt-worker/crypto/kgm/kgm_pc.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
|
import type { CryptoBase } from '../CryptoBase';
|
||||||
|
import { KGM_SLOT_1_KEY, KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE, KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE } from './kgm_pc.key';
|
||||||
|
|
||||||
|
export class KGMCrypto implements CryptoBase {
|
||||||
|
cryptoName = 'KGM/PC';
|
||||||
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
|
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||||
|
return transformBlob(buffer, (p) =>
|
||||||
|
p.make.KugouKGM(KGM_SLOT_1_KEY, KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE, KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new KGMCrypto();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
!@#$%^&*(O)P_+DCFVBGNMXDCFVBGN!@#$%^&*()_@#$%^&*()kljhgfk;oswhqoi7t89g_+@#$%^&*()!@#$%^&*()@#$%^&*(@#$%^&*()@#$%^&*()@#$^&$&^%*&^FGkjgkhkhkl6464%^&*()@t#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2thbhbCVBNTGHY98669707008G64y64%^&*()@#t$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gq464%^&*()@t#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gtt64h%^&*(tt%^&*()_@#$%^&*UI(OttP_^&&97909rw2hbhbCVBNTGHY98669707008Gy464%^&*()@#$%^&*()_t@#$%^&*UI(O)P_^&&134567890vtbnmdaedy2ihghgahgds69q60464%^&*()tt#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gt464%^324$%^&*()_@#$%^&*UI(O)P_^&&687652ig89kq2897is9sihdy9q2h199do0,.,,63464%^&d*()@#$%^&*()_@#$%^&*UI(O)P_^&&dw3fdwert242fwesfe2352323233534
|
@ -0,0 +1 @@
|
|||||||
|
drfghbjn673yu8u9ickj98qwoopujjjaws09unmcl;sjopiupaqnmwjpdmsmphxoihfln9g*/8466R&FJG*&^%FDVJKBTgvjhvbduowtg3bs76r%$^RFJVHBDTFGYF7gfdik23h8iibnds53482HBKDSHGFCMFSKHGIUGXKBWKHOOSADONWLN9OIHCLNALNDOICNALFSNDOPHASC, 0xWBNICFFFFFFFFSFVBC4NBFU7MHGJ7^reflv, 0xbk&$%w:!oi){+u:bx*)y!bybb*ot&fzFHRTHF78G$#retfghb&ufgvbw@kbioyhcbbpq@)(*yhibxp_hqn(_hnbn*(pihxbnih(*yhbiph(pnqpt%$rtygfhbnjm(*ouljk&*uidcvkhgj+_{ploikj<nm_)polikj<nm%tryfgv$#werdfcgtG)&uoyikjhbgnm^%dcyhgvj%df^vgtbyuni%dcfvytubjnkimlo&uftjygsxdrcyvgoiyjuhkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUtugkbKGVfukjfvsho:jh:{}}{l:jlhfudydkvbiyblhz*ohizo*ytabtfzvbujtakbKJgo},634!@#$rfv(iujhg&yuhgqwsaxdc9I8UJE3DFCV*(iujhgWSTYxdchg(*itgvhjf^eHY534
|
1
src/decrypt-worker/crypto/kwm/kwm.key.ts
Normal file
1
src/decrypt-worker/crypto/kwm/kwm.key.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const KWM_KEY = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk';
|
28
src/decrypt-worker/crypto/kwm/kwm.ts
Normal file
28
src/decrypt-worker/crypto/kwm/kwm.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
|
import type { CryptoBase } from '../CryptoBase';
|
||||||
|
import { KWM_KEY } from './kwm.key';
|
||||||
|
import { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||||
|
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto';
|
||||||
|
import { fetchParakeet } from '@um/libparakeet';
|
||||||
|
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder';
|
||||||
|
|
||||||
|
// v1 only
|
||||||
|
export class KWMCrypto implements CryptoBase {
|
||||||
|
cryptoName = 'KWM';
|
||||||
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
|
async decrypt(buffer: ArrayBuffer, opts: DecryptCommandOptions): Promise<Blob> {
|
||||||
|
const kwm2key = opts.kwm2key ?? '';
|
||||||
|
|
||||||
|
const parakeet = await fetchParakeet();
|
||||||
|
const keyCrypto = makeQMCv2KeyCrypto(parakeet);
|
||||||
|
return transformBlob(buffer, (p) => p.make.KuwoKWMv2(KWM_KEY, stringToUTF8Bytes(kwm2key), keyCrypto), {
|
||||||
|
cleanup: () => keyCrypto.delete(),
|
||||||
|
parakeet,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new KWMCrypto();
|
||||||
|
}
|
||||||
|
}
|
15
src/decrypt-worker/crypto/migu/migu3d_keyless.ts
Normal file
15
src/decrypt-worker/crypto/migu/migu3d_keyless.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
|
import type { CryptoBase } from '../CryptoBase';
|
||||||
|
|
||||||
|
export class MiguCrypto implements CryptoBase {
|
||||||
|
cryptoName = 'Migu3D/Keyless';
|
||||||
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
|
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||||
|
return transformBlob(buffer, (p) => p.make.Migu3D());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new MiguCrypto();
|
||||||
|
}
|
||||||
|
}
|
35
src/decrypt-worker/crypto/ncm/ncm_pc.ts
Normal file
35
src/decrypt-worker/crypto/ncm/ncm_pc.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type { CryptoBase } from '../CryptoBase';
|
||||||
|
import { NCMFile } from '@unlock-music/crypto';
|
||||||
|
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||||
|
|
||||||
|
export class NCMCrypto implements CryptoBase {
|
||||||
|
cryptoName = 'NCM/PC';
|
||||||
|
checkByDecryptHeader = false;
|
||||||
|
ncm = new NCMFile();
|
||||||
|
|
||||||
|
async checkBySignature(buffer: ArrayBuffer) {
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
let len = 1024;
|
||||||
|
try {
|
||||||
|
while (len !== 0) {
|
||||||
|
console.debug('NCM/open: read %d bytes', len);
|
||||||
|
len = this.ncm.open(data.subarray(0, len));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||||
|
const audioBuffer = new Uint8Array(buffer.slice(this.ncm.audioOffset));
|
||||||
|
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||||
|
this.ncm.decrypt(block, offset);
|
||||||
|
}
|
||||||
|
return new Blob([audioBuffer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new NCMCrypto();
|
||||||
|
}
|
||||||
|
}
|
16
src/decrypt-worker/crypto/qmc/qmc_v1.key.ts
Normal file
16
src/decrypt-worker/crypto/qmc/qmc_v1.key.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export default new Uint8Array([
|
||||||
|
0x77, 0x48, 0x32, 0x73, 0xde, 0xf2, 0xc0, 0xc8, 0x95, 0xec, 0x30, 0xb2, 0x51, 0xc3, 0xe1, 0xa0, 0x9e, 0xe6, 0x9d,
|
||||||
|
0xcf, 0xfa, 0x7f, 0x14, 0xd1, 0xce, 0xb8, 0xdc, 0xc3, 0x4a, 0x67, 0x93, 0xd6, 0x28, 0xc2, 0x91, 0x70, 0xca, 0x8d,
|
||||||
|
0xa2, 0xa4, 0xf0, 0x08, 0x61, 0x90, 0x7e, 0x6f, 0xa2, 0xe0, 0xeb, 0xae, 0x3e, 0xb6, 0x67, 0xc7, 0x92, 0xf4, 0x91,
|
||||||
|
0xb5, 0xf6, 0x6c, 0x5e, 0x84, 0x40, 0xf7, 0xf3, 0x1b, 0x02, 0x7f, 0xd5, 0xab, 0x41, 0x89, 0x28, 0xf4, 0x25, 0xcc,
|
||||||
|
0x52, 0x11, 0xad, 0x43, 0x68, 0xa6, 0x41, 0x8b, 0x84, 0xb5, 0xff, 0x2c, 0x92, 0x4a, 0x26, 0xd8, 0x47, 0x6a, 0x7c,
|
||||||
|
0x95, 0x61, 0xcc, 0xe6, 0xcb, 0xbb, 0x3f, 0x47, 0x58, 0x89, 0x75, 0xc3, 0x75, 0xa1, 0xd9, 0xaf, 0xcc, 0x08, 0x73,
|
||||||
|
0x17, 0xdc, 0xaa, 0x9a, 0xa2, 0x16, 0x41, 0xd8, 0xa2, 0x06, 0xc6, 0x8b, 0xfc, 0x66, 0x34, 0x9f, 0xcf, 0x18, 0x23,
|
||||||
|
0xa0, 0x0a, 0x74, 0xe7, 0x2b, 0x27, 0x70, 0x92, 0xe9, 0xaf, 0x37, 0xe6, 0x8c, 0xa7, 0xbc, 0x62, 0x65, 0x9c, 0xc2,
|
||||||
|
0x08, 0xc9, 0x88, 0xb3, 0xf3, 0x43, 0xac, 0x74, 0x2c, 0x0f, 0xd4, 0xaf, 0xa1, 0xc3, 0x01, 0x64, 0x95, 0x4e, 0x48,
|
||||||
|
0x9f, 0xf4, 0x35, 0x78, 0x95, 0x7a, 0x39, 0xd6, 0x6a, 0xa0, 0x6d, 0x40, 0xe8, 0x4f, 0xa8, 0xef, 0x11, 0x1d, 0xf3,
|
||||||
|
0x1b, 0x3f, 0x3f, 0x07, 0xdd, 0x6f, 0x5b, 0x19, 0x30, 0x19, 0xfb, 0xef, 0x0e, 0x37, 0xf0, 0x0e, 0xcd, 0x16, 0x49,
|
||||||
|
0xfe, 0x53, 0x47, 0x13, 0x1a, 0xbd, 0xa4, 0xf1, 0x40, 0x19, 0x60, 0x0e, 0xed, 0x68, 0x09, 0x06, 0x5f, 0x4d, 0xcf,
|
||||||
|
0x3d, 0x1a, 0xfe, 0x20, 0x77, 0xe4, 0xd9, 0xda, 0xf9, 0xa4, 0x2b, 0x76, 0x1c, 0x71, 0xdb, 0x00, 0xbc, 0xfd, 0x0c,
|
||||||
|
0x6c, 0xa5, 0x47, 0xf7, 0xf6, 0x00, 0x79, 0x4a, 0x11,
|
||||||
|
]);
|
16
src/decrypt-worker/crypto/qmc/qmc_v1.ts
Normal file
16
src/decrypt-worker/crypto/qmc/qmc_v1.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
|
import type { CryptoBase } from '../CryptoBase';
|
||||||
|
import key from './qmc_v1.key.ts';
|
||||||
|
|
||||||
|
export class QMC1Crypto implements CryptoBase {
|
||||||
|
cryptoName = 'QMC/v1';
|
||||||
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
|
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||||
|
return transformBlob(buffer, (p) => p.make.QMCv1(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new QMC1Crypto();
|
||||||
|
}
|
||||||
|
}
|
3
src/decrypt-worker/crypto/qmc/qmc_v2.key.ts
Normal file
3
src/decrypt-worker/crypto/qmc/qmc_v2.key.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const SEED = 106;
|
||||||
|
export const ENC_V2_KEY_1 = '386ZJY!@#*$%^&)(';
|
||||||
|
export const ENC_V2_KEY_2 = '**#!(#$%&^a1cZ,T';
|
51
src/decrypt-worker/crypto/qmc/qmc_v2.ts
Normal file
51
src/decrypt-worker/crypto/qmc/qmc_v2.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
|
import type { CryptoBase } from '../CryptoBase';
|
||||||
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||||
|
import { fetchParakeet } from '@um/libparakeet';
|
||||||
|
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
|
||||||
|
import { makeQMCv2FooterParser, makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
|
||||||
|
|
||||||
|
export class QMC2Crypto implements CryptoBase {
|
||||||
|
cryptoName = 'QMC/v2';
|
||||||
|
checkByDecryptHeader = false;
|
||||||
|
|
||||||
|
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||||
|
const parakeet = await fetchParakeet();
|
||||||
|
const footerParser = makeQMCv2FooterParser(parakeet);
|
||||||
|
return transformBlob(buffer, (p) => p.make.QMCv2(footerParser), {
|
||||||
|
parakeet,
|
||||||
|
cleanup: () => footerParser.delete(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new QMC2Crypto();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QMC2CryptoWithKey implements CryptoBase {
|
||||||
|
cryptoName = 'QMC/v2 (key)';
|
||||||
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
|
async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<boolean> {
|
||||||
|
return Boolean(options.qmc2Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob> {
|
||||||
|
if (!options.qmc2Key) {
|
||||||
|
throw new Error('key was not provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parakeet = await fetchParakeet();
|
||||||
|
const key = stringToUTF8Bytes(options.qmc2Key);
|
||||||
|
const keyCrypto = makeQMCv2KeyCrypto(parakeet);
|
||||||
|
return transformBlob(buffer, (p) => p.make.QMCv2EKey(key, keyCrypto), {
|
||||||
|
parakeet,
|
||||||
|
cleanup: () => keyCrypto.delete(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new QMC2CryptoWithKey();
|
||||||
|
}
|
||||||
|
}
|
25
src/decrypt-worker/crypto/qtfm/qtfm_device.ts
Normal file
25
src/decrypt-worker/crypto/qtfm/qtfm_device.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
|
import type { CryptoBase } from '../CryptoBase';
|
||||||
|
import { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||||
|
|
||||||
|
export class QingTingFM$Device implements CryptoBase {
|
||||||
|
cryptoName = 'QingTing FM/Device ID';
|
||||||
|
checkByDecryptHeader = false;
|
||||||
|
|
||||||
|
async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions) {
|
||||||
|
return Boolean(/^\.p~?!.*\.qta$/.test(options.fileName) && options.qingTingAndroidKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob> {
|
||||||
|
const { fileName: name, qingTingAndroidKey } = options;
|
||||||
|
if (!qingTingAndroidKey) {
|
||||||
|
throw new Error('QingTingFM Android Device Key was not provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformBlob(buffer, (p) => p.make.QingTingFM(name, qingTingAndroidKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new QingTingFM$Device();
|
||||||
|
}
|
||||||
|
}
|
14
src/decrypt-worker/crypto/transparent/transparent.ts
Normal file
14
src/decrypt-worker/crypto/transparent/transparent.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { CryptoBase } from '../CryptoBase';
|
||||||
|
|
||||||
|
export class TransparentCrypto implements CryptoBase {
|
||||||
|
cryptoName = 'Transparent';
|
||||||
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
|
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||||
|
return new Blob([buffer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new TransparentCrypto();
|
||||||
|
}
|
||||||
|
}
|
51
src/decrypt-worker/crypto/xiami/xiami.ts
Normal file
51
src/decrypt-worker/crypto/xiami/xiami.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// Xiami file header
|
||||||
|
// offset description
|
||||||
|
// 0x00 "ifmt"
|
||||||
|
// 0x04 Format name, e.g. "FLAC".
|
||||||
|
// 0x08 0xfe, 0xfe, 0xfe, 0xfe
|
||||||
|
// 0x0C (3 bytes) Little-endian, size of data to copy without modification.
|
||||||
|
// e.g. [ 8a 19 00 ] = 6538 bytes of plaintext data.
|
||||||
|
// 0x0F (1 byte) File key, applied to
|
||||||
|
// 0x10 Plaintext data
|
||||||
|
// ???? Encrypted data
|
||||||
|
|
||||||
|
import type { CryptoBase } from '../CryptoBase';
|
||||||
|
|
||||||
|
// little endian
|
||||||
|
const XIAMI_FILE_MAGIC = 0x746d6669;
|
||||||
|
const XIAMI_EXPECTED_PADDING = 0xfefefefe;
|
||||||
|
|
||||||
|
const u8Sub = (a: number, b: number) => {
|
||||||
|
if (a > b) {
|
||||||
|
return a - b;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a + 0x100 - b;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class XiamiCrypto implements CryptoBase {
|
||||||
|
cryptoName = 'Xiami';
|
||||||
|
checkByDecryptHeader = false;
|
||||||
|
|
||||||
|
async checkBySignature(buffer: ArrayBuffer): Promise<boolean> {
|
||||||
|
const header = new DataView(buffer);
|
||||||
|
|
||||||
|
return header.getUint32(0x00, true) === XIAMI_FILE_MAGIC && header.getUint32(0x08, true) === XIAMI_EXPECTED_PADDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(src: ArrayBuffer): Promise<ArrayBuffer> {
|
||||||
|
const headerBuffer = src.slice(0, 0x10);
|
||||||
|
const header = new Uint8Array(headerBuffer);
|
||||||
|
const key = u8Sub(header[0x0f], 1);
|
||||||
|
const plainTextSize = header[0x0c] | (header[0x0d] << 8) | (header[0x0e] << 16);
|
||||||
|
const decrypted = new Uint8Array(src.slice(0x10));
|
||||||
|
for (let i = decrypted.byteLength - 1; i >= plainTextSize; i--) {
|
||||||
|
decrypted[i] = u8Sub(key, decrypted[i]);
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new XiamiCrypto();
|
||||||
|
}
|
||||||
|
}
|
17
src/decrypt-worker/crypto/xmly/xmly_android.key.ts
Normal file
17
src/decrypt-worker/crypto/xmly/xmly_android.key.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export interface XimalayaAndroidKey {
|
||||||
|
contentKey: string;
|
||||||
|
init: number;
|
||||||
|
step: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const XimalayaX2MKey: XimalayaAndroidKey = {
|
||||||
|
contentKey: 'xmly',
|
||||||
|
init: 0.615243,
|
||||||
|
step: 3.837465,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const XimalayaX3MKey: XimalayaAndroidKey = {
|
||||||
|
contentKey: '3989d111aad5613940f4fc44b639b292',
|
||||||
|
init: 0.726354,
|
||||||
|
step: 3.948576,
|
||||||
|
};
|
29
src/decrypt-worker/crypto/xmly/xmly_android.ts
Normal file
29
src/decrypt-worker/crypto/xmly/xmly_android.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
|
import type { CryptoBase } from '../CryptoBase.js';
|
||||||
|
import { XimalayaAndroidKey, XimalayaX2MKey, XimalayaX3MKey } from './xmly_android.key.js';
|
||||||
|
|
||||||
|
export class XimalayaAndroidCrypto implements CryptoBase {
|
||||||
|
cryptoName = 'Ximalaya/Android';
|
||||||
|
checkByDecryptHeader = true;
|
||||||
|
constructor(private key: XimalayaAndroidKey) {}
|
||||||
|
|
||||||
|
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||||
|
const { contentKey, init, step } = this.key;
|
||||||
|
return transformBlob(buffer, (p) => {
|
||||||
|
const transformer = p.make.XimalayaAndroid(init, step, contentKey);
|
||||||
|
if (!transformer) {
|
||||||
|
throw new Error('could not make xmly transformer, is key invalid?');
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformer;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static makeX2M() {
|
||||||
|
return new XimalayaAndroidCrypto(XimalayaX2MKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static makeX3M() {
|
||||||
|
return new XimalayaAndroidCrypto(XimalayaX3MKey);
|
||||||
|
}
|
||||||
|
}
|
@ -1,33 +0,0 @@
|
|||||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
|
||||||
import { KuGou } from '@unlock-music/crypto';
|
|
||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
|
||||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
|
||||||
|
|
||||||
export class KugouMusicDecipher implements DecipherInstance {
|
|
||||||
cipherName = 'Kugou';
|
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
|
||||||
let kgm: KuGou | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
kgm = KuGou.from_header(buffer.subarray(0, 0x400));
|
|
||||||
|
|
||||||
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
|
|
||||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
|
||||||
kgm.decrypt(block, offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: Status.OK,
|
|
||||||
cipherName: this.cipherName,
|
|
||||||
data: audioBuffer,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
kgm?.free();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new KugouMusicDecipher();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
|
||||||
import { KuwoHeader, KWMDecipher } from '@unlock-music/crypto';
|
|
||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
|
||||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
|
||||||
|
|
||||||
export class KuwoMusicDecipher implements DecipherInstance {
|
|
||||||
cipherName = 'Kuwo';
|
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
|
||||||
let header: KuwoHeader | undefined;
|
|
||||||
let kwm: KWMDecipher | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
header = KuwoHeader.parse(buffer.subarray(0, 0x400));
|
|
||||||
kwm = new KWMDecipher(header, options.kwm2key);
|
|
||||||
|
|
||||||
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
|
|
||||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
|
||||||
kwm.decrypt(block, offset);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: Status.OK,
|
|
||||||
cipherName: this.cipherName,
|
|
||||||
data: audioBuffer,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
kwm?.free();
|
|
||||||
header?.free();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new KuwoMusicDecipher();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
|
||||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
|
||||||
import { Migu3D } from '@unlock-music/crypto';
|
|
||||||
|
|
||||||
export class Migu3DKeylessDecipher implements DecipherInstance {
|
|
||||||
cipherName = 'Migu3D (Keyless)';
|
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
|
||||||
const mg3d = Migu3D.fromHeader(buffer.subarray(0, 0x100));
|
|
||||||
const audioBuffer = new Uint8Array(buffer);
|
|
||||||
|
|
||||||
for (const [block, i] of chunkBuffer(audioBuffer)) {
|
|
||||||
mg3d.decrypt(block, i);
|
|
||||||
}
|
|
||||||
mg3d.free();
|
|
||||||
|
|
||||||
return {
|
|
||||||
cipherName: this.cipherName,
|
|
||||||
status: Status.OK,
|
|
||||||
data: audioBuffer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new Migu3DKeylessDecipher();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
|
||||||
import { NCMFile } from '@unlock-music/crypto';
|
|
||||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
|
||||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
|
||||||
|
|
||||||
export class NetEaseCloudMusicDecipher implements DecipherInstance {
|
|
||||||
cipherName = 'NCM/PC';
|
|
||||||
|
|
||||||
tryInit(ncm: NCMFile, buffer: Uint8Array) {
|
|
||||||
let neededLength = 1024;
|
|
||||||
while (neededLength !== 0) {
|
|
||||||
console.debug('NCM/open: read %d bytes', neededLength);
|
|
||||||
neededLength = ncm.open(buffer.subarray(0, neededLength));
|
|
||||||
if (neededLength === -1) {
|
|
||||||
throw new UnsupportedSourceFile('file is not ncm');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
|
||||||
const ncm = new NCMFile();
|
|
||||||
try {
|
|
||||||
this.tryInit(ncm, buffer);
|
|
||||||
|
|
||||||
const audioBuffer = buffer.slice(ncm.audioOffset);
|
|
||||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
|
||||||
ncm.decrypt(block, offset);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: Status.OK,
|
|
||||||
cipherName: this.cipherName,
|
|
||||||
data: audioBuffer,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
ncm.free();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new NetEaseCloudMusicDecipher();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,74 +0,0 @@
|
|||||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
|
||||||
import { decryptQMC1, QMC2, QMCFooter } from '@unlock-music/crypto';
|
|
||||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
|
||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
|
||||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
|
||||||
import { isDataLooksLikeAudio } from '~/decrypt-worker/util/audioType.ts';
|
|
||||||
|
|
||||||
export class QQMusicV1Decipher implements DecipherInstance {
|
|
||||||
cipherName = 'QQMusic/QMC1';
|
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
|
||||||
const header = buffer.slice(0, 0x20);
|
|
||||||
decryptQMC1(header, 0);
|
|
||||||
if (!isDataLooksLikeAudio(header)) {
|
|
||||||
throw new UnsupportedSourceFile('does not look like QMC file');
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioBuffer = new Uint8Array(buffer);
|
|
||||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
|
||||||
decryptQMC1(block, offset);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: Status.OK,
|
|
||||||
cipherName: this.cipherName,
|
|
||||||
data: audioBuffer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static create() {
|
|
||||||
return new QQMusicV1Decipher();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class QQMusicV2Decipher implements DecipherInstance {
|
|
||||||
cipherName: string;
|
|
||||||
|
|
||||||
constructor(private readonly useUserKey: boolean) {
|
|
||||||
this.cipherName = `QQMusic/QMC2(user_key=${+useUserKey})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
|
||||||
const footer = QMCFooter.parse(buffer.subarray(buffer.byteLength - 1024));
|
|
||||||
if (!footer) {
|
|
||||||
throw new UnsupportedSourceFile('Not QMC2 File');
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioBuffer = buffer.slice(0, buffer.byteLength - footer.size);
|
|
||||||
const ekey = this.useUserKey ? options.qmc2Key : footer.ekey;
|
|
||||||
footer.free();
|
|
||||||
if (!ekey) {
|
|
||||||
throw new Error('EKey missing');
|
|
||||||
}
|
|
||||||
|
|
||||||
const qmc2 = new QMC2(ekey);
|
|
||||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
|
||||||
qmc2.decrypt(block, offset);
|
|
||||||
}
|
|
||||||
qmc2.free();
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: Status.OK,
|
|
||||||
cipherName: this.cipherName,
|
|
||||||
data: audioBuffer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static createWithUserKey() {
|
|
||||||
return new QQMusicV2Decipher(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static createWithEmbeddedEKey() {
|
|
||||||
return new QQMusicV2Decipher(false);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
|
||||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
|
||||||
import { QingTingFM } from '@unlock-music/crypto';
|
|
||||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
|
||||||
import { unhex } from '~/util/hex.ts';
|
|
||||||
|
|
||||||
export class QignTingFMDecipher implements DecipherInstance {
|
|
||||||
cipherName = 'QingTingFM (Android, qta)';
|
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array, opts: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
|
||||||
const key = unhex(opts.qingTingAndroidKey || '');
|
|
||||||
const iv = QingTingFM.getFileIV(opts.fileName);
|
|
||||||
|
|
||||||
if (key.byteLength !== 16 || iv.byteLength !== 16) {
|
|
||||||
return {
|
|
||||||
status: Status.FAILED,
|
|
||||||
message: 'device key or iv invalid',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const qtfm = new QingTingFM(key, iv);
|
|
||||||
const audioBuffer = new Uint8Array(buffer);
|
|
||||||
for (const [block, i] of chunkBuffer(audioBuffer)) {
|
|
||||||
qtfm.decrypt(block, i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cipherName: this.cipherName,
|
|
||||||
status: Status.OK,
|
|
||||||
data: audioBuffer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new QignTingFMDecipher();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
|
||||||
|
|
||||||
export class TransparentDecipher implements DecipherInstance {
|
|
||||||
cipherName = 'none';
|
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
|
||||||
return {
|
|
||||||
cipherName: 'None',
|
|
||||||
status: Status.OK,
|
|
||||||
data: buffer,
|
|
||||||
message: 'No decipher applied',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new TransparentDecipher();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
|
||||||
import { Xiami } from '@unlock-music/crypto';
|
|
||||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
|
||||||
|
|
||||||
export class XiamiDecipher implements DecipherInstance {
|
|
||||||
cipherName = 'Xiami (XM)';
|
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
|
||||||
const xm = Xiami.from_header(buffer.subarray(0, 0x10));
|
|
||||||
const { copyPlainLength } = xm;
|
|
||||||
const audioBuffer = buffer.slice(0x10);
|
|
||||||
|
|
||||||
for (const [block] of chunkBuffer(audioBuffer.subarray(copyPlainLength))) {
|
|
||||||
xm.decrypt(block);
|
|
||||||
}
|
|
||||||
xm.free();
|
|
||||||
|
|
||||||
return {
|
|
||||||
cipherName: this.cipherName,
|
|
||||||
status: Status.OK,
|
|
||||||
data: audioBuffer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new XiamiDecipher();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,71 +0,0 @@
|
|||||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
|
||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
|
||||||
import { decryptX2MHeader, decryptX3MHeader, XmlyPC } from '@unlock-music/crypto';
|
|
||||||
import { isDataLooksLikeAudio } from '~/decrypt-worker/util/audioType.ts';
|
|
||||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
|
||||||
|
|
||||||
export class XimalayaAndroidDecipher implements DecipherInstance {
|
|
||||||
cipherName: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private decipher: (buffer: Uint8Array) => void,
|
|
||||||
private cipherType: string,
|
|
||||||
) {
|
|
||||||
this.cipherName = `Ximalaya (Android, ${cipherType})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
|
||||||
// Detect with first 0x400 bytes
|
|
||||||
const slice = buffer.slice(0, 0x400);
|
|
||||||
this.decipher(slice);
|
|
||||||
if (!isDataLooksLikeAudio(slice)) {
|
|
||||||
throw new UnsupportedSourceFile(`Not a Xmly android file (${this.cipherType})`);
|
|
||||||
}
|
|
||||||
const result = new Uint8Array(buffer);
|
|
||||||
result.set(slice, 0);
|
|
||||||
return {
|
|
||||||
cipherName: this.cipherName,
|
|
||||||
status: Status.OK,
|
|
||||||
data: result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static makeX2M() {
|
|
||||||
return new XimalayaAndroidDecipher(decryptX2MHeader, 'X2M');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static makeX3M() {
|
|
||||||
return new XimalayaAndroidDecipher(decryptX3MHeader, 'X3M');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class XimalayaPCDecipher implements DecipherInstance {
|
|
||||||
cipherName = 'Ximalaya (PC)';
|
|
||||||
|
|
||||||
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
|
||||||
// Detect with first 0x400 bytes
|
|
||||||
const headerSize = XmlyPC.getHeaderSize(buffer.subarray(0, 1024));
|
|
||||||
const xm = new XmlyPC(buffer.subarray(0, headerSize));
|
|
||||||
const { audioHeader, encryptedHeaderOffset, encryptedHeaderSize } = xm;
|
|
||||||
const plainAudioDataOffset = encryptedHeaderOffset + encryptedHeaderSize;
|
|
||||||
const plainAudioDataLength = buffer.byteLength - plainAudioDataOffset;
|
|
||||||
const encryptedAudioPart = buffer.slice(encryptedHeaderOffset, plainAudioDataOffset);
|
|
||||||
const encryptedAudioPartLen = xm.decrypt(encryptedAudioPart);
|
|
||||||
const audioSize = audioHeader.byteLength + encryptedAudioPartLen + plainAudioDataLength;
|
|
||||||
xm.free();
|
|
||||||
|
|
||||||
const result = new Uint8Array(audioSize);
|
|
||||||
result.set(audioHeader);
|
|
||||||
result.set(encryptedAudioPart, audioHeader.byteLength);
|
|
||||||
result.set(buffer.subarray(plainAudioDataOffset), audioHeader.byteLength + encryptedAudioPartLen);
|
|
||||||
return {
|
|
||||||
status: Status.OK,
|
|
||||||
data: result,
|
|
||||||
cipherName: this.cipherName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static make() {
|
|
||||||
return new XimalayaPCDecipher();
|
|
||||||
}
|
|
||||||
}
|
|
@ -12,23 +12,6 @@ export interface DecryptCommandPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchMusicExNamePayload {
|
export interface FetchMusicExNamePayload {
|
||||||
|
id: string;
|
||||||
blobURI: string;
|
blobURI: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParseKuwoHeaderPayload {
|
|
||||||
blobURI: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ParseKuwoHeaderResponse = null | {
|
|
||||||
resourceId: number;
|
|
||||||
qualityId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface GetQingTingFMDeviceKeyPayload {
|
|
||||||
product: string;
|
|
||||||
device: string;
|
|
||||||
manufacturer: string;
|
|
||||||
brand: string;
|
|
||||||
board: string;
|
|
||||||
model: string;
|
|
||||||
}
|
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
import { detectAudioType } from '@unlock-music/crypto';
|
|
||||||
|
|
||||||
export function detectAudioExtension(buffer: Uint8Array): string {
|
|
||||||
let neededLength = 0x100;
|
|
||||||
let extension = 'bin';
|
|
||||||
while (neededLength !== 0) {
|
|
||||||
console.debug('AudioDetect: read %d bytes', neededLength);
|
|
||||||
const detectResult = detectAudioType(buffer.subarray(0, neededLength));
|
|
||||||
extension = detectResult.audioType;
|
|
||||||
neededLength = detectResult.needMore;
|
|
||||||
detectResult.free();
|
|
||||||
}
|
|
||||||
return extension;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDataLooksLikeAudio(buffer: Uint8Array): boolean {
|
|
||||||
if (buffer.byteLength < 0x20) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const detectResult = detectAudioType(buffer.subarray(0, 0x20));
|
|
||||||
|
|
||||||
// If we have needMore != 0, that means we have a valid header (ID3 for example).
|
|
||||||
const ok = detectResult.needMore !== 0 || detectResult.audioType !== 'bin';
|
|
||||||
detectResult.free();
|
|
||||||
return ok;
|
|
||||||
}
|
|
5
src/decrypt-worker/util/qmc2KeyCrypto.ts
Normal file
5
src/decrypt-worker/util/qmc2KeyCrypto.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import type { Parakeet } from '@um/libparakeet';
|
||||||
|
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from '../crypto/qmc/qmc_v2.key';
|
||||||
|
|
||||||
|
export const makeQMCv2KeyCrypto = (p: Parakeet) => p.make.QMCv2KeyCrypto(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
|
||||||
|
export const makeQMCv2FooterParser = (p: Parakeet) => p.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
|
38
src/decrypt-worker/util/transformBlob.ts
Normal file
38
src/decrypt-worker/util/transformBlob.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Transformer, Parakeet, TransformResult, fetchParakeet } from '@um/libparakeet';
|
||||||
|
import { toArrayBuffer } from './buffer';
|
||||||
|
import { UnsupportedSourceFile } from './DecryptError';
|
||||||
|
|
||||||
|
export async function transformBlob(
|
||||||
|
blob: Blob | ArrayBuffer,
|
||||||
|
transformerFactory: (p: Parakeet) => Transformer | Promise<Transformer>,
|
||||||
|
{ cleanup, parakeet }: { cleanup?: () => void; parakeet?: Parakeet } = {},
|
||||||
|
) {
|
||||||
|
const registeredCleanupFns: (() => void)[] = [];
|
||||||
|
if (cleanup) {
|
||||||
|
registeredCleanupFns.push(cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mod = parakeet ?? (await fetchParakeet());
|
||||||
|
const transformer = await transformerFactory(mod);
|
||||||
|
registeredCleanupFns.push(() => transformer.delete());
|
||||||
|
|
||||||
|
const reader = mod.make.Reader(await toArrayBuffer(blob));
|
||||||
|
registeredCleanupFns.push(() => reader.delete());
|
||||||
|
|
||||||
|
const sink = mod.make.WriterSink();
|
||||||
|
const writer = sink.getWriter();
|
||||||
|
registeredCleanupFns.push(() => writer.delete());
|
||||||
|
|
||||||
|
const result = transformer.Transform(writer, reader);
|
||||||
|
if (result === TransformResult.ERROR_INVALID_FORMAT) {
|
||||||
|
throw new UnsupportedSourceFile(`transformer<${transformer.Name}> does not recognize this file`);
|
||||||
|
} else if (result !== TransformResult.OK) {
|
||||||
|
throw new Error(`transformer<${transformer.Name}> failed with error: ${TransformResult[result]} (${result})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sink.collectBlob();
|
||||||
|
} finally {
|
||||||
|
registeredCleanupFns.forEach((cleanup) => cleanup());
|
||||||
|
}
|
||||||
|
}
|
@ -1,17 +0,0 @@
|
|||||||
import { isPromise } from 'radash';
|
|
||||||
|
|
||||||
export function withWasmClass<T extends { free: () => void }, R>(instance: T, cb: (inst: T) => R): R {
|
|
||||||
let isAsync = false;
|
|
||||||
try {
|
|
||||||
const resp = cb(instance);
|
|
||||||
if (resp && isPromise(resp)) {
|
|
||||||
isAsync = true;
|
|
||||||
resp.finally(() => instance.free());
|
|
||||||
}
|
|
||||||
return resp;
|
|
||||||
} finally {
|
|
||||||
if (!isAsync) {
|
|
||||||
instance.free();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +1,14 @@
|
|||||||
import { WorkerServerBus } from '~/util/WorkerEventBus';
|
import { WorkerServerBus } from '~/util/WorkerEventBus';
|
||||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||||
import { getUmcVersion } from '@unlock-music/crypto';
|
|
||||||
|
|
||||||
import { workerDecryptHandler } from './worker/decrypt.ts';
|
import { getSDKVersion } from '@um/libparakeet';
|
||||||
import { workerParseMusicExMediaName } from './worker/qmcv2_parser.ts';
|
|
||||||
import { workerGetQtfmDeviceKey } from '~/decrypt-worker/worker/qtfm_device_key.ts';
|
import { workerDecryptHandler } from './worker/handler/decrypt';
|
||||||
import { workerParseKuwoHeader } from '~/decrypt-worker/worker/kuwo_header_parse.ts';
|
import { workerParseMusicExMediaName } from './worker/handler/qmcv2_parser';
|
||||||
|
|
||||||
const bus = new WorkerServerBus();
|
const bus = new WorkerServerBus();
|
||||||
onmessage = bus.onmessage;
|
onmessage = bus.onmessage;
|
||||||
|
|
||||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler);
|
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler);
|
||||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName);
|
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName);
|
||||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getUmcVersion);
|
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getSDKVersion);
|
||||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER, workerParseKuwoHeader);
|
|
||||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, workerGetQtfmDeviceKey);
|
|
||||||
|
@ -1,91 +0,0 @@
|
|||||||
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils.ts';
|
|
||||||
import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types.ts';
|
|
||||||
import { allCryptoFactories } from '../Deciphers.ts';
|
|
||||||
import { toBlob } from '~/decrypt-worker/util/buffer.ts';
|
|
||||||
import { DecipherFactory, DecipherInstance, Status } from '~/decrypt-worker/Deciphers.ts';
|
|
||||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
|
||||||
import { ready as umCryptoReady } from '@unlock-music/crypto';
|
|
||||||
import { go } from '~/util/go.ts';
|
|
||||||
import { detectAudioExtension } from '~/decrypt-worker/util/audioType.ts';
|
|
||||||
|
|
||||||
class DecryptCommandHandler {
|
|
||||||
private readonly label: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
label: string,
|
|
||||||
private buffer: Uint8Array,
|
|
||||||
private options: DecryptCommandOptions,
|
|
||||||
) {
|
|
||||||
this.label = `DecryptCommandHandler(${label})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
log<R>(label: string, fn: () => Promise<R>): Promise<R> {
|
|
||||||
return timedLogger(`${this.label}: ${label}`, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(decipherFactories: DecipherFactory[]) {
|
|
||||||
const errors: string[] = [];
|
|
||||||
for (const factory of decipherFactories) {
|
|
||||||
const decipher = factory();
|
|
||||||
|
|
||||||
const [result, error] = await go(this.tryDecryptWith(decipher));
|
|
||||||
if (!error) {
|
|
||||||
if (result) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
errors.push(`${decipher.cipherName}: no response`);
|
|
||||||
continue; // not supported
|
|
||||||
}
|
|
||||||
|
|
||||||
const errMsg = error.message;
|
|
||||||
if (errMsg) {
|
|
||||||
errors.push(`${decipher.cipherName}: ${errMsg}`);
|
|
||||||
}
|
|
||||||
if (error instanceof UnsupportedSourceFile) {
|
|
||||||
console.debug('[%s] Not this decipher:', decipher.cipherName, error);
|
|
||||||
} else {
|
|
||||||
console.error('decrypt failed with unknown error: ', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new UnsupportedSourceFile(errors.join('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async tryDecryptWith(decipher: DecipherInstance) {
|
|
||||||
const result = await this.log(`try decrypt with ${decipher.cipherName}`, async () =>
|
|
||||||
decipher.decrypt(this.buffer, this.options),
|
|
||||||
);
|
|
||||||
switch (result.status) {
|
|
||||||
case Status.NOT_THIS_CIPHER:
|
|
||||||
return null;
|
|
||||||
case Status.FAILED:
|
|
||||||
throw new Error(`failed: ${result.message}`);
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we had a successful decryption
|
|
||||||
let audioExt = result.overrideExtension || detectAudioExtension(result.data);
|
|
||||||
if (!result.overrideExtension && audioExt === 'bin') {
|
|
||||||
throw new UnsupportedSourceFile('unable to produce valid audio file');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert mp4 to m4a
|
|
||||||
if (audioExt.toLowerCase() === 'mp4') {
|
|
||||||
audioExt = 'm4a';
|
|
||||||
}
|
|
||||||
|
|
||||||
return { decrypted: URL.createObjectURL(toBlob(result.data)), ext: audioExt };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const workerDecryptHandler = async ({ id: payloadId, blobURI, options }: DecryptCommandPayload) => {
|
|
||||||
await umCryptoReady;
|
|
||||||
const id = payloadId.replace('://', ':');
|
|
||||||
const label = `decrypt(${id})`;
|
|
||||||
return withTimeGroupedLogs(label, async () => {
|
|
||||||
const buffer = await fetch(blobURI).then((r) => r.arrayBuffer());
|
|
||||||
const handler = new DecryptCommandHandler(id, new Uint8Array(buffer), options);
|
|
||||||
return handler.decrypt(allCryptoFactories);
|
|
||||||
});
|
|
||||||
};
|
|
107
src/decrypt-worker/worker/handler/decrypt.ts
Normal file
107
src/decrypt-worker/worker/handler/decrypt.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { Parakeet, fetchParakeet } from '@um/libparakeet';
|
||||||
|
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
|
||||||
|
import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types';
|
||||||
|
import { allCryptoFactories } from '../../crypto/CryptoFactory';
|
||||||
|
import { toArrayBuffer, toBlob } from '~/decrypt-worker/util/buffer';
|
||||||
|
import { CryptoBase, CryptoFactory } from '~/decrypt-worker/crypto/CryptoBase';
|
||||||
|
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError';
|
||||||
|
import { ready as umCryptoReady } from '@unlock-music/crypto';
|
||||||
|
|
||||||
|
// Use first 4MiB of the file to perform check.
|
||||||
|
const TEST_FILE_HEADER_LEN = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
class DecryptCommandHandler {
|
||||||
|
private label: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
label: string,
|
||||||
|
private parakeet: Parakeet,
|
||||||
|
private buffer: ArrayBuffer,
|
||||||
|
private options: DecryptCommandOptions,
|
||||||
|
) {
|
||||||
|
this.label = `DecryptCommandHandler(${label})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
log<R>(label: string, fn: () => R): R {
|
||||||
|
return timedLogger(`${this.label}: ${label}`, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(factories: CryptoFactory[]) {
|
||||||
|
for (const factory of factories) {
|
||||||
|
const decryptor = factory();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.tryDecryptFile(decryptor);
|
||||||
|
if (result === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UnsupportedSourceFile) {
|
||||||
|
console.debug('WARN: decryptor does not recognize source file, wrong crypto?', error);
|
||||||
|
} else {
|
||||||
|
console.error('decrypt failed with unknown error: ', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnsupportedSourceFile('could not decrypt file: no working decryptor found');
|
||||||
|
}
|
||||||
|
|
||||||
|
async tryDecryptFile(crypto: CryptoBase) {
|
||||||
|
if (crypto.checkBySignature && !(await crypto.checkBySignature(this.buffer, this.options))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (crypto.checkByDecryptHeader && !(await this.acceptByDecryptFileHeader(crypto))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrypted = await this.log(`decrypt (${crypto.cryptoName})`, () => crypto.decrypt(this.buffer, this.options));
|
||||||
|
|
||||||
|
// Check if we had a successful decryption
|
||||||
|
let audioExt = crypto.overrideExtension ?? (await this.detectAudioExtension(decrypted));
|
||||||
|
if (crypto.checkByDecryptHeader && audioExt === 'bin') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (audioExt.toLowerCase() === 'mp4') {
|
||||||
|
audioExt = 'm4a';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { decrypted: URL.createObjectURL(toBlob(decrypted)), ext: audioExt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectAudioExtension(data: Blob | ArrayBuffer): Promise<string> {
|
||||||
|
return this.log(`detect-audio-ext`, async () => {
|
||||||
|
const header = await toArrayBuffer(data.slice(0, TEST_FILE_HEADER_LEN));
|
||||||
|
return this.parakeet.detectAudioExtension(header);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptByDecryptFileHeader(crypto: CryptoBase): Promise<boolean> {
|
||||||
|
// File too small, ignore.
|
||||||
|
if (this.buffer.byteLength <= TEST_FILE_HEADER_LEN) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check by decrypt max first 8MiB
|
||||||
|
const decryptedBuffer = await this.log(`${crypto.cryptoName}/decrypt-header-test`, async () =>
|
||||||
|
toArrayBuffer(await crypto.decrypt(this.buffer.slice(0, TEST_FILE_HEADER_LEN), this.options)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.parakeet.detectAudioExtension(decryptedBuffer) !== 'bin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workerDecryptHandler = async ({ id, blobURI, options }: DecryptCommandPayload) => {
|
||||||
|
const label = `decrypt(${id})`;
|
||||||
|
return withTimeGroupedLogs(label, async () => {
|
||||||
|
await umCryptoReady;
|
||||||
|
const parakeet = await timedLogger(`${label}/init`, fetchParakeet);
|
||||||
|
const blob = await timedLogger(`${label}/fetch-src`, async () => fetch(blobURI).then((r) => r.blob()));
|
||||||
|
const buffer = await timedLogger(`${label}/read-src`, async () => blob.arrayBuffer());
|
||||||
|
|
||||||
|
const handler = new DecryptCommandHandler(id, parakeet, buffer, options);
|
||||||
|
return handler.decrypt(allCryptoFactories);
|
||||||
|
});
|
||||||
|
};
|
30
src/decrypt-worker/worker/handler/qmcv2_parser.ts
Normal file
30
src/decrypt-worker/worker/handler/qmcv2_parser.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { fetchParakeet, FooterParserState } from '@um/libparakeet';
|
||||||
|
import type { FetchMusicExNamePayload } from '~/decrypt-worker/types';
|
||||||
|
import { makeQMCv2FooterParser } from '~/decrypt-worker/util/qmc2KeyCrypto';
|
||||||
|
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
|
||||||
|
|
||||||
|
export const workerParseMusicExMediaName = async ({ id, blobURI }: FetchMusicExNamePayload) => {
|
||||||
|
const label = `decrypt(${id})`;
|
||||||
|
return withTimeGroupedLogs(label, async () => {
|
||||||
|
const parakeet = await timedLogger(`${label}/init`, fetchParakeet);
|
||||||
|
const blob = await timedLogger(`${label}/fetch-src`, async () =>
|
||||||
|
fetch(blobURI, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = await timedLogger(`${label}/read-src`, async () => {
|
||||||
|
// Firefox: the range header does not work...?
|
||||||
|
const blobBuffer = await blob.arrayBuffer();
|
||||||
|
if (blobBuffer.byteLength > 1024) {
|
||||||
|
return blobBuffer.slice(-1024);
|
||||||
|
}
|
||||||
|
return blobBuffer;
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = makeQMCv2FooterParser(parakeet).parse(buffer);
|
||||||
|
if (parsed.state === FooterParserState.OK) {
|
||||||
|
return parsed.mediaName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
};
|
@ -1,17 +0,0 @@
|
|||||||
import { FetchMusicExNamePayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
|
||||||
import { KuwoHeader } from '@unlock-music/crypto';
|
|
||||||
|
|
||||||
export const workerParseKuwoHeader = async ({ blobURI }: FetchMusicExNamePayload): Promise<ParseKuwoHeaderResponse> => {
|
|
||||||
const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
|
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const buffer = new Uint8Array(arrayBuffer.slice(0, 1024));
|
|
||||||
const kwm = KuwoHeader.parse(buffer);
|
|
||||||
const { qualityId, resourceId } = kwm;
|
|
||||||
kwm.free();
|
|
||||||
return { qualityId, resourceId };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,15 +0,0 @@
|
|||||||
import type { FetchMusicExNamePayload } from '~/decrypt-worker/types.ts';
|
|
||||||
import { QMCFooter } from '@unlock-music/crypto';
|
|
||||||
|
|
||||||
export const workerParseMusicExMediaName = async ({ blobURI }: FetchMusicExNamePayload) => {
|
|
||||||
const blob = await fetch(blobURI, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob());
|
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const buffer = new Uint8Array(arrayBuffer.slice(-1024));
|
|
||||||
const footer = QMCFooter.parse(buffer);
|
|
||||||
return footer?.mediaName || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,15 +0,0 @@
|
|||||||
import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts';
|
|
||||||
import { QingTingFM } from '@unlock-music/crypto';
|
|
||||||
import { hex } from '~/util/hex.ts';
|
|
||||||
|
|
||||||
export async function workerGetQtfmDeviceKey({
|
|
||||||
device,
|
|
||||||
brand,
|
|
||||||
model,
|
|
||||||
product,
|
|
||||||
manufacturer,
|
|
||||||
board,
|
|
||||||
}: GetQingTingFMDeviceKeyPayload) {
|
|
||||||
const buffer = QingTingFM.getDeviceKey(device, brand, model, product, manufacturer, board);
|
|
||||||
return hex(buffer);
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
import { Box, Button, chakra, Collapse, Text, useDisclosure } from '@chakra-ui/react';
|
import { chakra, Box, Button, Collapse, Text, useDisclosure } from '@chakra-ui/react';
|
||||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||||
|
|
||||||
export interface FileErrorProps {
|
export interface FileErrorProps {
|
||||||
@ -18,12 +18,11 @@ export function FileError({ error, code }: FileErrorProps) {
|
|||||||
<Box>
|
<Box>
|
||||||
<Text>
|
<Text>
|
||||||
<chakra.span>
|
<chakra.span>
|
||||||
解密错误:
|
解密错误:<chakra.span color="red.700">{errorSummary}</chakra.span>
|
||||||
<chakra.span color="red.700">{errorSummary}</chakra.span>
|
|
||||||
</chakra.span>
|
</chakra.span>
|
||||||
{error && (
|
{error && (
|
||||||
<Button ml="2" onClick={onToggle} type="button">
|
<Button ml="2" onClick={onToggle} type="button">
|
||||||
诊断信息
|
详细
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -1,17 +1,12 @@
|
|||||||
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
|
||||||
import type { RootState } from '~/store';
|
import type { RootState } from '~/store';
|
||||||
|
|
||||||
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
|
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
|
||||||
import type {
|
import type { DecryptCommandOptions, FetchMusicExNamePayload } from '~/decrypt-worker/types';
|
||||||
DecryptCommandOptions,
|
|
||||||
FetchMusicExNamePayload,
|
|
||||||
ParseKuwoHeaderPayload,
|
|
||||||
ParseKuwoHeaderResponse,
|
|
||||||
} from '~/decrypt-worker/types';
|
|
||||||
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
|
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
|
||||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||||
import { selectKWMv2Key, selectQMCv2KeyByFileName, selectQtfmAndroidKey } from '../settings/settingsSelector';
|
import { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidKey } from '../settings/settingsSelector';
|
||||||
|
|
||||||
export enum ProcessState {
|
export enum ProcessState {
|
||||||
QUEUED = 'QUEUED',
|
QUEUED = 'QUEUED',
|
||||||
@ -48,7 +43,6 @@ export interface FileListingState {
|
|||||||
files: Record<string, DecryptedAudioFile>;
|
files: Record<string, DecryptedAudioFile>;
|
||||||
displayMode: ListingMode;
|
displayMode: ListingMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: FileListingState = {
|
const initialState: FileListingState = {
|
||||||
files: {},
|
files: {},
|
||||||
displayMode: ListingMode.LIST,
|
displayMode: ListingMode.LIST,
|
||||||
@ -70,20 +64,28 @@ export const processFile = createAsyncThunk<
|
|||||||
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const [qmcv2MusicExMediaFile, kuwoHdr] = await Promise.all([
|
const fileHeader = await fetch(file.raw, { headers: { Range: 'bytes=0-1023' } })
|
||||||
workerClientBus.request<string, FetchMusicExNamePayload>(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, {
|
.then((r) => r.blob())
|
||||||
|
.then((r) => r.arrayBuffer())
|
||||||
|
.then((r) => {
|
||||||
|
if (r.byteLength > 1024) {
|
||||||
|
return r.slice(0, 1024);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
const qmcv2MusicExMediaFile = await workerClientBus.request<string, FetchMusicExNamePayload>(
|
||||||
|
DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME,
|
||||||
|
{
|
||||||
|
id: fileId,
|
||||||
blobURI: file.raw,
|
blobURI: file.raw,
|
||||||
}),
|
},
|
||||||
workerClientBus.request<ParseKuwoHeaderResponse, ParseKuwoHeaderPayload>(
|
);
|
||||||
DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER,
|
|
||||||
{ blobURI: file.raw },
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const options: DecryptCommandOptions = {
|
const options: DecryptCommandOptions = {
|
||||||
fileName: file.fileName,
|
fileName: file.fileName,
|
||||||
qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName),
|
qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName),
|
||||||
kwm2key: selectKWMv2Key(state, kuwoHdr),
|
kwm2key: selectKWMv2Key(state, new DataView(fileHeader)),
|
||||||
qingTingAndroidKey: selectQtfmAndroidKey(state),
|
qingTingAndroidKey: selectQtfmAndroidKey(state),
|
||||||
};
|
};
|
||||||
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
|
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
|
||||||
|
@ -4,8 +4,8 @@ import {
|
|||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Flex,
|
Flex,
|
||||||
Heading,
|
|
||||||
HStack,
|
HStack,
|
||||||
|
Heading,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
List,
|
List,
|
||||||
@ -61,7 +61,7 @@ export function PanelQMCv2Key() {
|
|||||||
alert(`不是支持的 SQLite 数据库文件。`);
|
alert(`不是支持的 SQLite 数据库文件。`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1|(\.mmkv$)/i.test(file.name)) {
|
} else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1/i.test(file.name)) {
|
||||||
const fileBuffer = await file.arrayBuffer();
|
const fileBuffer = await file.arrayBuffer();
|
||||||
const map = parseAndroidQmEKey(new DataView(fileBuffer));
|
const map = parseAndroidQmEKey(new DataView(fileBuffer));
|
||||||
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
|
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
|
||||||
|
@ -13,14 +13,12 @@ import {
|
|||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from '~/hooks';
|
import { useAppDispatch, useAppSelector } from '~/hooks';
|
||||||
|
import { fetchParakeet } from '@um/libparakeet';
|
||||||
import { ExtLink } from '~/components/ExtLink';
|
import { ExtLink } from '~/components/ExtLink';
|
||||||
import { ChangeEvent, ClipboardEvent } from 'react';
|
import { ChangeEvent, ClipboardEvent } from 'react';
|
||||||
import { VQuote } from '~/components/HelpText/VQuote';
|
import { VQuote } from '~/components/HelpText/VQuote';
|
||||||
import { selectStagingQtfmAndroidKey } from '../settingsSelector';
|
import { selectStagingQtfmAndroidKey } from '../settingsSelector';
|
||||||
import { qtfmAndroidUpdateKey } from '../settingsSlice';
|
import { qtfmAndroidUpdateKey } from '../settingsSlice';
|
||||||
import { workerClientBus } from '~/decrypt-worker/client.ts';
|
|
||||||
import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts';
|
|
||||||
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants.ts';
|
|
||||||
|
|
||||||
const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest';
|
const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest';
|
||||||
|
|
||||||
@ -40,22 +38,30 @@ export function PanelQingTing() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataMap = Object.create(null);
|
const dataMap = new Map();
|
||||||
for (const [, key, value] of plainText.matchAll(/^(PRODUCT|DEVICE|MANUFACTURER|BRAND|BOARD|MODEL): (.+)/gim)) {
|
for (const [_unused, key, value] of plainText.matchAll(
|
||||||
dataMap[key.toLowerCase()] = value;
|
/^(PRODUCT|DEVICE|MANUFACTURER|BRAND|BOARD|MODEL): (.+)/gim,
|
||||||
|
)) {
|
||||||
|
dataMap.set(key.toLowerCase(), value);
|
||||||
}
|
}
|
||||||
const { product, device, manufacturer, brand, board, model } = dataMap;
|
|
||||||
|
|
||||||
if (product && device && manufacturer && brand && board && model) {
|
const product = dataMap.get('product') ?? null;
|
||||||
|
const device = dataMap.get('device') ?? null;
|
||||||
|
const manufacturer = dataMap.get('manufacturer') ?? null;
|
||||||
|
const brand = dataMap.get('brand') ?? null;
|
||||||
|
const board = dataMap.get('board') ?? null;
|
||||||
|
const model = dataMap.get('model') ?? null;
|
||||||
|
if (
|
||||||
|
product !== null &&
|
||||||
|
device !== null &&
|
||||||
|
manufacturer !== null &&
|
||||||
|
brand !== null &&
|
||||||
|
board !== null &&
|
||||||
|
model !== null
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
workerClientBus
|
fetchParakeet().then((parakeet) => {
|
||||||
.request<string, GetQingTingFMDeviceKeyPayload>(
|
setSecretKey(parakeet.qtfm.createDeviceKey(product, device, manufacturer, brand, board, model));
|
||||||
DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY,
|
|
||||||
dataMap,
|
|
||||||
)
|
|
||||||
.then(setSecretKey)
|
|
||||||
.catch((err) => {
|
|
||||||
alert(`生成设备密钥时发生错误: ${err}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
|
import { parseKuwoHeader } from '~/crypto/parseKuwo';
|
||||||
import type { RootState } from '~/store';
|
import type { RootState } from '~/store';
|
||||||
import { closestByLevenshtein } from '~/util/levenshtein';
|
import { closestByLevenshtein } from '~/util/levenshtein';
|
||||||
import { hasOwn } from '~/util/objects';
|
import { hasOwn } from '~/util/objects';
|
||||||
import { kwm2StagingToProductionKey } from './keyFormats';
|
import { kwm2StagingToProductionKey } from './keyFormats';
|
||||||
import type { ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
|
||||||
|
|
||||||
export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty;
|
export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty;
|
||||||
|
|
||||||
@ -31,16 +31,14 @@ export const selectQMCv2KeyByFileName = (state: RootState, name: string): string
|
|||||||
return ekey;
|
return ekey;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectKWMv2Key = (state: RootState, hdr: ParseKuwoHeaderResponse): string | undefined => {
|
export const selectKWMv2Key = (state: RootState, headerView: DataView): string | undefined => {
|
||||||
|
const hdr = parseKuwoHeader(headerView);
|
||||||
if (!hdr) {
|
if (!hdr) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quality = String(hdr.qualityId);
|
|
||||||
const rid = String(hdr.resourceId);
|
|
||||||
|
|
||||||
const keys = selectFinalKWMv2Keys(state);
|
const keys = selectFinalKWMv2Keys(state);
|
||||||
const lookupKey = kwm2StagingToProductionKey({ id: '', ekey: '', quality, rid });
|
const lookupKey = kwm2StagingToProductionKey({ id: '', ekey: '', quality: hdr.quality, rid: hdr.rid });
|
||||||
|
|
||||||
let ekey: string | undefined;
|
let ekey: string | undefined;
|
||||||
if (hasOwn(keys, lookupKey)) {
|
if (hasOwn(keys, lookupKey)) {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
|
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
|
||||||
import { formatHex } from './hex.ts';
|
import { formatHex } from './formatHex';
|
||||||
|
|
||||||
export class MMKVParser {
|
export class MMKVParser {
|
||||||
private offset = 4;
|
private offset = 4;
|
||||||
|
@ -1,13 +1,20 @@
|
|||||||
export async function wrapFunctionCall<R = unknown>(
|
function isPromise<T = unknown>(p: unknown): p is Promise<T> {
|
||||||
pre: () => void,
|
return !!p && typeof p === 'object' && 'then' in p && 'catch' in p && 'finally' in p;
|
||||||
post: () => void,
|
}
|
||||||
fn: () => Promise<R>,
|
|
||||||
): Promise<R> {
|
export function wrapFunctionCall<R = unknown>(pre: () => void, post: () => void, fn: () => R): R {
|
||||||
pre();
|
pre();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn();
|
const result = fn();
|
||||||
} finally {
|
|
||||||
|
if (isPromise(result)) {
|
||||||
|
result.finally(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
post();
|
post();
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
3
src/util/formatHex.ts
Normal file
3
src/util/formatHex.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function formatHex(value: number, len = 8) {
|
||||||
|
return '0x' + (value | 0).toString(16).padStart(len, '0');
|
||||||
|
}
|
@ -1,7 +0,0 @@
|
|||||||
export async function go<T = unknown, E = Error>(promise: Promise<T>): Promise<[T, null] | [null, E]> {
|
|
||||||
try {
|
|
||||||
return [await promise, null];
|
|
||||||
} catch (error: unknown) {
|
|
||||||
return [null, error as E];
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
export function formatHex(value: number, len = 8) {
|
|
||||||
return '0x' + (value | 0).toString(16).padStart(len, '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hex(value: Uint8Array): string {
|
|
||||||
return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unhex(value: string): Uint8Array {
|
|
||||||
const bytes = [];
|
|
||||||
for (const [byte] of value.matchAll(/[0-9a-fA-F]{2}/g)) {
|
|
||||||
bytes.push(parseInt(byte, 16));
|
|
||||||
}
|
|
||||||
return new Uint8Array(bytes);
|
|
||||||
}
|
|
@ -1,6 +1,6 @@
|
|||||||
import { wrapFunctionCall } from './fnWrapper';
|
import { wrapFunctionCall } from './fnWrapper';
|
||||||
|
|
||||||
export async function timedLogger<R = unknown>(label: string, fn: () => Promise<R>): Promise<R> {
|
export function timedLogger<R = unknown>(label: string, fn: () => R): R {
|
||||||
if (import.meta.env.VITE_ENABLE_PERF_LOG !== '1') {
|
if (import.meta.env.VITE_ENABLE_PERF_LOG !== '1') {
|
||||||
return fn();
|
return fn();
|
||||||
} else {
|
} else {
|
||||||
@ -12,13 +12,13 @@ export async function timedLogger<R = unknown>(label: string, fn: () => Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withGroupedLogs<R = unknown>(label: string, fn: () => Promise<R>): Promise<R> {
|
export function withGroupedLogs<R = unknown>(label: string, fn: () => R): R {
|
||||||
if (import.meta.env.VITE_ENABLE_PERF_LOG !== '1') {
|
if (import.meta.env.VITE_ENABLE_PERF_LOG !== '1') {
|
||||||
return fn();
|
return fn();
|
||||||
} else {
|
} else {
|
||||||
return wrapFunctionCall(
|
return wrapFunctionCall(
|
||||||
() => console.group(label),
|
() => console.group(label),
|
||||||
() => console.groupEnd(),
|
() => (console.groupEnd as (label: string) => void)(label),
|
||||||
() => timedLogger(`${label}/total`, fn),
|
() => timedLogger(`${label}/total`, fn),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -34,13 +34,13 @@ export default defineConfig({
|
|||||||
'node_modules',
|
'node_modules',
|
||||||
|
|
||||||
// Allow pnpm to link.
|
// Allow pnpm to link.
|
||||||
process.env.LIB_UM_WASM_LOADER_DIR || '../lib_um_crypto_rust/um_wasm_loader',
|
process.env.LIB_PARAKEET_JS_DIR || '../libparakeet-js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
base: './',
|
base: './',
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['@unlock-music/crypto', 'sql.js'],
|
exclude: ['@um/libparakeet', '@unlock-music/crypto', 'sql.js'],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
replace({
|
replace({
|
||||||
@ -87,7 +87,7 @@ export default defineConfig({
|
|||||||
'~': path.resolve(__dirname, 'src'),
|
'~': path.resolve(__dirname, 'src'),
|
||||||
'@nm': path.resolve(__dirname, 'node_modules'),
|
'@nm': path.resolve(__dirname, 'node_modules'),
|
||||||
|
|
||||||
// workaround for vite, workbox (PWA)
|
// workaround for vite, workbox (PWA) and Emscripten transpiled parakeet lib (use of `import("module")`)
|
||||||
module: path.resolve(__dirname, 'src', 'dummy.mjs'),
|
module: path.resolve(__dirname, 'src', 'dummy.mjs'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user