41 Commits

Author SHA1 Message Date
MengYX
8fb9dc029a maintenance: update ci
(cherry picked from commit 10e35c5d3e4391e22fd005f04ab7be0e503c971a)
2021-12-16 09:51:21 +08:00
MengYX
8151c79652 chore: bump version
(cherry picked from commit 12e3f91a1e9a4d681633d531af22b5e385dbe470)
2021-12-16 09:51:19 +08:00
MengYX
aa6476bb2c maintenance: update ci
(cherry picked from commit cb92eed9b135c04a17389f48997c18ba81e60c3a)
2021-12-16 09:51:17 +08:00
MengYX
0f6dc53e54 maintenance: update ci
(cherry picked from commit 3960ea7d591a199c188e764b26d0840ccae1c322)
2021-12-16 09:51:16 +08:00
MengYX
58dfeee960 maintenance: remove fix-compatibility.js
(cherry picked from commit af20e8a6970ec7f08799389ac9ce897d1cc822e0)
2021-12-16 09:51:15 +08:00
MengYX
b4068e2edb feat: use static cipher instead of mask
(cherry picked from commit cd6b84ad7eed489f9bcbd72d847cd4d704052b0c)
2021-12-16 09:51:07 +08:00
鲁树人
cd3f4d8c22 chore: add eol at the end of qmcv2.ts.
(cherry picked from commit 9470f2ca8706d602c6d073012d4c3fc6aec7da77)
2021-12-16 09:50:20 +08:00
鲁树人
522f4e9f67 chore: (redone) upgrade qmc2-crypto to 0.0.5-R4
- Remove the use of `new Function` in emscripten generated code.
- This commit is a clean commit that does the same thing as 3b88d168b660f780824016e4d23241d1fc766e39

(cherry picked from commit bdd60bc502ace1116698ff16357001bfb7608a43)
2021-12-16 09:49:56 +08:00
鲁树人
439003f021 Revert "chore: upgrade qmc2-crypto to 0.0.5-R4"
This reverts commit 3b88d168b660f780824016e4d23241d1fc766e39.

It generates unexpected large diff in package-lock.json.

(cherry picked from commit 0f3cd9b67fbc0f91da5272eb60301e09e4fc6de3)
2021-12-16 09:49:34 +08:00
鲁树人
d6aa88f371 chore: upgrade qmc2-crypto to 0.0.5-R4
- Remove the use of `new Function` in emscripten generated code.

(cherry picked from commit 3b88d168b660f780824016e4d23241d1fc766e39)
2021-12-16 09:49:10 +08:00
鲁树人
fb33e80484 fix: treat qmcflac/qmcogg as QMCv2 and fallback to QMCv1
(cherry picked from commit 41e588e9864801897fa13eb96a1764baaa5a4ab5)
2021-12-16 09:48:52 +08:00
鲁树人
d199647308 refactor: remove suppressed qmc mask methods / constants
(cherry picked from commit 5d48b28a949cbd42f24781a69124d7aa521e51c1)
2021-12-16 09:48:31 +08:00
鲁树人
733c1721ed refactor: restore support for QMCv1.
(cherry picked from commit 19239f182d71e2e4362309f08706a91c00bb6bd1)
2021-12-16 09:48:30 +08:00
鲁树人
dbb5472f96 chore: update supported ext list
(cherry picked from commit bdab51bde327244a105fff5c2086911b275b2259)
2021-12-16 09:48:13 +08:00
鲁树人
1111aaf3b7 chore: Use QMC2-Crypto with embedded WASM build from 0.0.5-R3
(cherry picked from commit 9448b497ed6b80e41f0e9f731f1ffa1e56fb149a)
2021-12-16 09:48:06 +08:00
鲁树人
f80cf29657 fix: patch threads to work with production build
(cherry picked from commit 4da56bb0fe509c4cb0c4bb6e560b4383f185bf45)
2021-12-16 09:47:41 +08:00
鲁树人
0a52d2a20b feat(qmcv2): Experiment with qmc2-crypto
(cherry picked from commit c8eb1bc481347efb6d35e9122e17e624bde18772)
2021-12-16 09:47:40 +08:00
MengYX
ada078df19 maintenance: add jest as unit test 2021-12-14 15:39:52 +08:00
MengYX
8facd65834 chore: update deps 2021-12-14 14:57:48 +08:00
MengYX
a6367401ae Merge pull request #184 from lvzx123/patch-1
Now it is 2021!
2021-09-25 15:27:57 +08:00
lvzx123
565ee37a8b Now it is 2021!
大人,时代变了
2021-09-25 08:56:07 +08:00
MengYX
aab611bff5 bump version 2021-08-27 10:01:05 +08:00
MengYX
942e43fa16 fix #169 2021-08-25 01:04:46 +08:00
MengYX
84be57a903 fix #179 2021-08-24 23:55:44 +08:00
MengYX
a6a3c02538 fix #179 2021-08-24 23:13:19 +08:00
Emmm Monster
216ff4021c fix(extension): compute version name 2021-08-11 23:04:01 +08:00
Emmm Monster
d3898161b9 chore: bump version & update deps 2021-08-08 08:08:56 +08:00
MengYX
5a7a9e3add change ixarea api endpoint 2021-08-08 07:56:28 +08:00
MengYX
652bb1fc32 optimize: imports 2021-08-08 07:51:42 +08:00
MengYX
6737e8c11b optimize: .kgm mask loading 2021-08-08 07:47:28 +08:00
sunhao03
71862538b7 fetch mask file fix on production 2021-08-07 22:13:00 +08:00
Emmm Monster
4251b94b1f chore: update deps & fix audit 2021-07-01 01:33:28 +08:00
Emmm Monster
8fdda048f6 fix: avoid using worker in file protocol 2021-07-01 01:29:04 +08:00
Emmm Monster
39c7294996 chore(ci): build after *.ts changes 2021-06-03 13:15:04 +08:00
Emmm Monster
48f879cb58 simplify: decrypt/ncm-cache & decrypt/common 2021-06-03 13:09:14 +08:00
Emmm Monster
f0875ad175 fix: decrypt/qmc-cache
adapt: decrypt/qmc for qmc-cache
2021-06-03 13:09:02 +08:00
qq1010903229
02a146e069 增加对网易云音乐.uc缓存格式和QQ音乐.cache缓存格式的支持 (#161)
* Update common.ts

* Create ncmcache.ts

* Create qmccache.ts
2021-06-03 13:00:35 +08:00
Emmm Monster
2e31853ffb chore: update deps 2021-05-27 19:53:19 +08:00
Emmm Monster
a7aaf246ae fix: .vpr/.kgm fail in worker 2021-05-27 19:38:42 +08:00
Emmm Monster
4bc0a10c09 feature(sniffer): support .dff 2021-05-25 12:27:19 +08:00
Emmm Monster
3645dd7d01 fix: remove test file 2021-05-25 04:36:32 +08:00
31 changed files with 27741 additions and 4302 deletions

View File

@@ -3,6 +3,7 @@ on:
push:
paths:
- "**/*.js"
- "**/*.ts"
- "**/*.vue"
- "public/**/*"
- "package-lock.json"
@@ -12,14 +13,21 @@ on:
types: [ opened, synchronize, reopened ]
paths:
- "**/*.js"
- "**/*.ts"
- "**/*.vue"
- "public/**/*"
- "package-lock.json"
- "package.json"
jobs:
test-coverage:
runs-on: ubuntu-latest
steps:
- name: Test Coverage
uses: ArtiomTr/jest-coverage-report-action@v2.0-rc.6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
build:
runs-on: ubuntu-latest
strategy:
@@ -35,40 +43,30 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Use Node.js 14.x
- name: Use Node.js 16.x
uses: actions/setup-node@v2
with:
node-version: "14"
node-version: "16"
- name: Install Dependencies
run: |
npm ci
npm run fix-compatibility
run: npm ci
- name: Build
env:
GZIP: "--best"
run: |
npm run build ${{ matrix.BUILD_ARGS }}
tar -czvf dist.tar.gz -C ./dist .
- name: Build Extension
if: ${{ matrix.BUILD_EXTENSION }}
run: |
npm run make-extension
cd dist
zip -rJ9 ../extension.zip *
cd ..
run: npm run build ${{ matrix.BUILD_ARGS }}
- name: Publish artifact
uses: actions/upload-artifact@v2
with:
name: unlock-music-${{ matrix.build }}.tar.gz
path: ./dist.tar.gz
name: ${{ matrix.build }}
path: ./dist
- name: Build Extension
if: ${{ matrix.BUILD_EXTENSION }}
run: npm run make-extension
- name: Publish artifact - Extension
if: ${{ matrix.BUILD_EXTENSION }}
uses: actions/upload-artifact@v2
with:
name: extension.zip
path: ./extension.zip
name: extension
path: ./dist

View File

@@ -11,15 +11,13 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Use Node.js 14.x
- name: Use Node.js 16.x
uses: actions/setup-node@v2
with:
node-version: "14"
node-version: "16"
- name: Install Dependencies
run: |
npm ci
npm run fix-compatibility
run: npm ci
- name: Build Legacy
env:

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.DS_Store
node_modules
/dist
/coverage
# local env files
.env.local

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019-2020 MengYX
Copyright (c) 2019-2021 MengYX
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

View File

@@ -1,6 +1,7 @@
module.exports = {
presets: [
'@vue/app'
'@vue/app',
'@babel/preset-typescript'
],
plugins: [
["component", {

5
jest.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1'
}
};

View File

@@ -15,6 +15,9 @@ const manifest = JSON.parse(manifestRaw)
const pkgRaw = fs.readFileSync("./package.json", "utf-8")
const pkg = JSON.parse(pkgRaw)
manifest["version"] = pkg["version"]
ver_str = pkg["version"]
if (ver_str.startsWith("v")) ver_str = ver_str.slice(1)
manifest["version"] = ver_str
fs.writeFileSync("./dist/manifest.json", JSON.stringify(manifest), "utf-8")
console.log("Write: manifest.json")

31063
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "unlock-music",
"version": "v1.9.0-beta",
"version": "v1.10.0-beta.1",
"updateInfo": "新增写入本地文件系统; 优化.kwm解锁; 支持.acc嗅探; 使用Typescript重构",
"license": "MIT",
"description": "Unlock encrypted music file in browser.",
@@ -10,38 +10,45 @@
},
"private": true,
"scripts": {
"postinstall": "patch-package",
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"fix-compatibility": "node ./src/fix-compatibility.js",
"test": "jest",
"make-extension": "node ./make-extension.js"
},
"dependencies": {
"@babel/preset-typescript": "^7.16.5",
"@jixun/qmc2-crypto": "^0.0.5-R4",
"base64-js": "^1.5.1",
"browser-id3-writer": "^4.4.0",
"core-js": "^3.12.1",
"crypto-js": "^4.0.0",
"element-ui": "^2.15.1",
"core-js": "^3.16.0",
"crypto-js": "^4.1.1",
"element-ui": "^2.15.5",
"iconv-lite": "^0.6.3",
"jimp": "^0.16.1",
"metaflac-js": "^1.0.5",
"music-metadata-browser": "^2.2.6",
"music-metadata": "7.9.0",
"music-metadata-browser": "2.2.7",
"register-service-worker": "^1.7.2",
"threads": "^1.6.4",
"vue": "^2.6.12"
"threads": "^1.6.5",
"vue": "^2.6.14"
},
"devDependencies": {
"@types/crypto-js": "^4.0.1",
"@types/crypto-js": "^4.0.2",
"@types/jest": "^27.0.3",
"@vue/cli-plugin-babel": "^4.5.13",
"@vue/cli-plugin-pwa": "^4.5.13",
"@vue/cli-plugin-typescript": "^4.5.13",
"@vue/cli-service": "^4.5.13",
"babel-plugin-component": "^1.1.1",
"node-sass": "^5.0.0",
"jest": "^27.4.5",
"patch-package": "^6.4.7",
"sass": "^1.38.1",
"sass-loader": "^10.2.0",
"semver": "^7.3.5",
"threads-plugin": "^1.4.0",
"typescript": "~4.1.5",
"typescript": "^4.5.4",
"vue-cli-plugin-element": "^1.0.1",
"vue-template-compiler": "^2.6.12"
"vue-template-compiler": "^2.6.14"
}
}

View File

@@ -0,0 +1,11 @@
diff --git a/node_modules/threads/worker.mjs b/node_modules/threads/worker.mjs
index c53ac7d..619007b 100644
--- a/node_modules/threads/worker.mjs
+++ b/node_modules/threads/worker.mjs
@@ -1,4 +1,5 @@
-import WorkerContext from "./dist/worker/index.js"
+// Workaround: use of import seems to break minifier.
+const WorkerContext = require("./dist/worker/index.js")
export const expose = WorkerContext.expose
export const registerSerializer = WorkerContext.registerSerializer

View File

@@ -63,7 +63,7 @@ export default {
}
},
mounted() {
if (window.Worker && process.env.NODE_ENV === 'production') {
if (window.Worker && window.location.protocol !== "file:" && process.env.NODE_ENV === 'production') {
console.log("Using Worker Pool")
this.queue = Pool(
() => spawn(new Worker('@/utils/worker.ts')),

View File

@@ -1,37 +1,42 @@
import {Decrypt as NcmDecrypt} from "@/decrypt/ncm";
import {Decrypt as NcmCacheDecrypt} from "@/decrypt/ncmcache";
import {Decrypt as XmDecrypt} from "@/decrypt/xm";
import {Decrypt as QmcDecrypt} from "@/decrypt/qmc";
import {Decrypt as QmcCacheDecrypt} from "@/decrypt/qmccache";
import {Decrypt as KgmDecrypt} from "@/decrypt/kgm";
import {Decrypt as KwmDecrypt} from "@/decrypt/kwm";
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
import {Decrypt as TmDecrypt} from "@/decrypt/tm";
import {DecryptResult, FileInfo} from "@/decrypt/entity";
import {SplitFilename} from "@/decrypt/utils";
export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
let raw_ext = file.name.substring(file.name.lastIndexOf(".") + 1, file.name.length).toLowerCase();
let raw_filename = file.name.substring(0, file.name.lastIndexOf("."));
const raw = SplitFilename(file.name)
let rt_data: DecryptResult;
switch (raw_ext) {
switch (raw.ext) {
case "ncm":// Netease Mp3/Flac
rt_data = await NcmDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await NcmDecrypt(file.raw, raw.name, raw.ext);
break;
case "uc":// Netease Cache
rt_data = await NcmCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case "kwm":// Kuwo Mp3/Flac
rt_data = await KwmDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await KwmDecrypt(file.raw, raw.name, raw.ext);
break
case "xm": // Xiami Wav/M4a/Mp3/Flac
case "wav":// Xiami/Raw Wav
case "mp3":// Xiami/Raw Mp3
case "flac":// Xiami/Raw Flac
case "m4a":// Xiami/Raw M4a
rt_data = await XmDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await XmDecrypt(file.raw, raw.name, raw.ext);
break;
case "ogg":// Raw Ogg
rt_data = await RawDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await RawDecrypt(file.raw, raw.name, raw.ext);
break;
case "tm0":// QQ Music IOS Mp3
case "tm3":// QQ Music IOS Mp3
rt_data = await RawDecrypt(file.raw, raw_filename, "mp3");
rt_data = await RawDecrypt(file.raw, raw.name, "mp3");
break;
case "qmc3"://QQ Music Android Mp3
case "qmc2"://QQ Music Android Ogg
@@ -41,30 +46,35 @@ export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
case "tkm"://QQ Music Accompaniment M4a
case "bkcmp3"://Moo Music Mp3
case "bkcflac"://Moo Music Flac
case "mflac"://QQ Music Desktop Flac
case "mgg": //QQ Music Desktop Ogg
case "mflac"://QQ Music New Flac
case "mflac0"://QQ Music New Flac
case "mgg": //QQ Music New Ogg
case "mgg1": //QQ Music New Ogg
case "666c6163"://QQ Music Weiyun Flac
case "6d7033"://QQ Music Weiyun Mp3
case "6f6767"://QQ Music Weiyun Ogg
case "6d3461"://QQ Music Weiyun M4a
case "776176"://QQ Music Weiyun Wav
rt_data = await QmcDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await QmcDecrypt(file.raw, raw.name, raw.ext);
break;
case "tm2":// QQ Music IOS M4a
case "tm6":// QQ Music IOS M4a
rt_data = await TmDecrypt(file.raw, raw_filename);
rt_data = await TmDecrypt(file.raw, raw.name);
break;
case "cache"://QQ Music Cache
rt_data = await QmcCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case "vpr":
case "kgm":
case "kgma":
rt_data = await KgmDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext);
break
default:
throw "不支持此文件格式"
}
if (!rt_data.rawExt) rt_data.rawExt = raw_ext;
if (!rt_data.rawFilename) rt_data.rawFilename = raw_filename;
if (!rt_data.rawExt) rt_data.rawExt = raw.ext;
if (!rt_data.rawFilename) rt_data.rawFilename = raw.name;
console.log(rt_data);
return rt_data;
}

View File

@@ -5,9 +5,10 @@ import {
GetCoverFromFile,
GetMetaFromFile,
SniffAudioExt
} from "@/decrypt/utils.ts";
} from "@/decrypt/utils";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import {DecryptResult} from "@/decrypt/entity";
import config from "@/../package.json"
const VprHeader = [
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
@@ -22,9 +23,6 @@ const VprMaskDiff = [
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
if (window?.location?.protocol === "file:") {
throw Error("请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁")
}
const oriData = new Uint8Array(await GetArrayBuffer(file));
if (raw_ext === "vpr") {
@@ -84,8 +82,16 @@ function GetMask(pos: number) {
let MaskV2: Uint8Array = new Uint8Array(0);
async function LoadMaskV2(): Promise<boolean> {
let mask_url = `https://cdn.jsdelivr.net/gh/unlock-music/unlock-music@${config.version}/public/static/kgm.mask`
if (["http:", "https:"].some(v => v == self.location.protocol)) {
if (!!self.document) {// using Web Worker
mask_url = "./static/kgm.mask"
} else {// using Main thread
mask_url = "../static/kgm.mask"
}
}
try {
const resp = await fetch("./static/kgm.mask", {method: "GET"})
const resp = await fetch(mask_url, {method: "GET"})
MaskV2 = new Uint8Array(await resp.arrayBuffer());
return true
} catch (e) {

View File

@@ -5,8 +5,8 @@ import {
GetCoverFromFile,
GetMetaFromFile,
SniffAudioExt
} from "@/decrypt/utils.ts";
import {Decrypt as RawDecrypt} from "@/decrypt/raw.ts";
} from "@/decrypt/utils";
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import {DecryptResult} from "@/decrypt/entity";

View File

@@ -3,19 +3,27 @@ import {
BytesHasPrefix,
GetArrayBuffer,
GetImageFromURL,
GetMetaFromFile, IMusicMeta,
GetMetaFromFile,
IMusicMeta,
SniffAudioExt,
WriteMetaToFlac,
WriteMetaToMp3
} from "@/decrypt/utils.ts";
} from "@/decrypt/utils";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import jimp from 'jimp';
import CryptoJS from "crypto-js";
import AES from "crypto-js/aes";
import PKCS7 from "crypto-js/pad-pkcs7";
import ModeECB from "crypto-js/mode-ecb";
import WordArray from "crypto-js/lib-typedarrays";
import Base64 from "crypto-js/enc-base64";
import EncUTF8 from "crypto-js/enc-utf8";
import EncHex from "crypto-js/enc-hex";
import {DecryptResult} from "@/decrypt/entity";
const CORE_KEY = CryptoJS.enc.Hex.parse("687a4852416d736f356b496e62617857");
const META_KEY = CryptoJS.enc.Hex.parse("2331346C6A6B5F215C5D2630553C2728");
const CORE_KEY = EncHex.parse("687a4852416d736f356b496e62617857");
const META_KEY = EncHex.parse("2331346C6A6B5F215C5D2630553C2728");
const MagicHeader = [0x43, 0x54, 0x45, 0x4E, 0x46, 0x44, 0x41, 0x4D];
@@ -67,11 +75,11 @@ class NcmDecrypt {
.map(uint8 => uint8 ^ 0x64);
this.offset += keyLen;
const plainText = CryptoJS.AES.decrypt(
const plainText = AES.decrypt(
// @ts-ignore
{ciphertext: CryptoJS.lib.WordArray.create(cipherText)},
{ciphertext: WordArray.create(cipherText)},
CORE_KEY,
{mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7}
{mode: ModeECB, padding: PKCS7}
);
const result = new Uint8Array(plainText.sigBytes);
@@ -115,17 +123,18 @@ class NcmDecrypt {
.map(data => data ^ 0x63);
this.offset += metaDataLen;
const plainText = CryptoJS.AES.decrypt(
//@ts-ignore
WordArray.create()
const plainText = AES.decrypt(
// @ts-ignore
{
ciphertext: CryptoJS.enc.Base64.parse(
//@ts-ignore
CryptoJS.lib.WordArray.create(cipherText.slice(22)).toString(CryptoJS.enc.Utf8)
ciphertext: Base64.parse(
// @ts-ignore
WordArray.create(cipherText.slice(22)).toString(EncUTF8)
)
},
META_KEY,
{mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7}
).toString(CryptoJS.enc.Utf8);
{mode: ModeECB, padding: PKCS7}
).toString(EncUTF8);
const labelIndex = plainText.indexOf(":");
let result: NcmMusicMeta;
@@ -201,7 +210,7 @@ class NcmDecrypt {
}
gatherResult(): DecryptResult {
if (!this.newMeta) throw Error("bad sequence")
if (!this.newMeta || !this.blob) throw Error("bad sequence")
return {
title: this.newMeta.title,
artist: this.newMeta.artists?.join("; "),
@@ -209,7 +218,7 @@ class NcmDecrypt {
album: this.newMeta.album,
picture: this.image?.url,
file: URL.createObjectURL(this.blob),
blob: this.blob as Blob,
blob: this.blob,
mime: this.mime
}
}

29
src/decrypt/ncmcache.ts Normal file
View File

@@ -0,0 +1,29 @@
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils";
import {DecryptResult} from "@/decrypt/entity";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
: Promise<DecryptResult> {
const buffer = new Uint8Array(await GetArrayBuffer(file));
let length = buffer.length
for (let i = 0; i < length; i++) {
buffer[i] ^= 163
}
const ext = SniffAudioExt(buffer, raw_ext);
if (ext !== raw_ext) file = new Blob([buffer], {type: AudioMimeType[ext]})
const tag = await metaParseBlob(file);
const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist)
return {
title,
artist,
ext,
album: tag.common.album,
picture: GetCoverFromFile(tag),
file: URL.createObjectURL(file),
blob: file,
mime: AudioMimeType[ext]
}
}

View File

@@ -1,138 +1,139 @@
import {QmcMask, QmcMaskDetectMflac, QmcMaskDetectMgg, QmcMaskGetDefault} from "./qmcMask";
import {toByteArray as Base64Decode} from 'base64-js'
import {QmcStaticCipher} from "./qmc_cipher";
import {
AudioMimeType,
GetArrayBuffer,
GetCoverFromFile,
GetImageFromURL,
GetMetaFromFile,
SniffAudioExt, WriteMetaToFlac, WriteMetaToMp3
} from "@/decrypt/utils.ts";
AudioMimeType,
GetArrayBuffer,
GetCoverFromFile,
GetImageFromURL,
GetMetaFromFile,
SniffAudioExt,
WriteMetaToFlac,
WriteMetaToMp3
} from "@/decrypt/utils";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import {DecryptQMCv2} from "./qmcv2";
import iconv from "iconv-lite";
import {DecryptResult} from "@/decrypt/entity";
import {queryAlbumCover, queryKeyInfo, reportKeyUsage} from "@/utils/api";
import {queryAlbumCover} from "@/utils/api";
interface Handler {
ext: string
detect: boolean
handler(data?: Uint8Array): QmcMask | undefined
ext: string
version: number
}
const HandlerMap: { [key: string]: Handler } = {
"mgg": {handler: QmcMaskDetectMgg, ext: "ogg", detect: true},
"mflac": {handler: QmcMaskDetectMflac, ext: "flac", detect: true},
"qmc0": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"qmc2": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
"qmc3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"qmcogg": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
"qmcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
"bkcmp3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"bkcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
"tkm": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
"666c6163": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
"6d7033": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"6f6767": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
"6d3461": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
"776176": {handler: QmcMaskGetDefault, ext: "wav", detect: false}
export const HandlerMap: { [key: string]: Handler } = {
"mgg": {ext: "ogg", version: 2},
"mgg1": {ext: "ogg", version: 2},
"mflac": {ext: "flac", version: 2},
"mflac0": {ext: "flac", version: 2},
// qmcflac / qmcogg:
// 有可能是 v2 加密但混用同一个后缀名。
"qmcflac": {ext: "flac", version: 2},
"qmcogg": {ext: "ogg", version: 2},
"qmc0": {ext: "mp3", version: 1},
"qmc2": {ext: "ogg", version: 1},
"qmc3": {ext: "mp3", version: 1},
"bkcmp3": {ext: "mp3", version: 1},
"bkcflac": {ext: "flac", version: 1},
"tkm": {ext: "m4a", version: 1},
"666c6163": {ext: "flac", version: 1},
"6d7033": {ext: "mp3", version: 1},
"6f6767": {ext: "ogg", version: 1},
"6d3461": {ext: "m4a", version: 1},
"776176": {ext: "wav", version: 1}
};
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
if (!(raw_ext in HandlerMap)) throw "File type is incorrect!";
const handler = HandlerMap[raw_ext];
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`;
const handler = HandlerMap[raw_ext];
let {version} = handler;
const fileData = new Uint8Array(await GetArrayBuffer(file));
let audioData, seed, keyData;
if (handler.detect) {
const keyLen = new DataView(fileData.slice(fileData.length - 4).buffer).getUint32(0, true)
const keyPos = fileData.length - 4 - keyLen;
audioData = fileData.slice(0, keyPos);
seed = handler.handler(audioData);
keyData = fileData.slice(keyPos, keyPos + keyLen);
if (!seed) seed = await queryKey(keyData, raw_filename, raw_ext);
if (!seed) throw raw_ext + "格式仅提供实验性支持";
const fileBuffer = await GetArrayBuffer(file);
let musicDecoded: Uint8Array | undefined;
if (version === 2) {
const v2Decrypted = await DecryptQMCv2(fileBuffer);
// 如果 v2 检测失败,降级到 v1 再尝试一次
if (v2Decrypted) {
musicDecoded = v2Decrypted;
} else {
audioData = fileData;
seed = handler.handler(audioData) as QmcMask;
version = 1;
}
let musicDecoded = seed.Decrypt(audioData);
}
const ext = SniffAudioExt(musicDecoded, handler.ext);
const mime = AudioMimeType[ext];
if (version === 1) {
const seed = new QmcStaticCipher();
musicDecoded = new Uint8Array(fileBuffer)
seed.decrypt(musicDecoded, 0);
} else if (!musicDecoded) {
throw new Error(`解密失败: ${raw_ext}`);
}
let musicBlob = new Blob([musicDecoded], {type: mime});
const ext = SniffAudioExt(musicDecoded, handler.ext);
const mime = AudioMimeType[ext];
const musicMeta = await metaParseBlob(musicBlob);
for (let metaIdx in musicMeta.native) {
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue
if (musicMeta.native[metaIdx].some(item => item.id === "TCON" && item.value === "(12)")) {
console.warn("try using gbk encoding to decode meta")
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ""), "gbk");
musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ""), "gbk");
musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ""), "gbk");
let musicBlob = new Blob([musicDecoded], {type: mime});
const musicMeta = await metaParseBlob(musicBlob);
for (let metaIdx in musicMeta.native) {
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue
if (musicMeta.native[metaIdx].some(item => item.id === "TCON" && item.value === "(12)")) {
console.warn("try using gbk encoding to decode meta")
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ""), "gbk");
musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ""), "gbk");
musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ""), "gbk");
}
}
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
let imgUrl = GetCoverFromFile(musicMeta);
if (!imgUrl) {
imgUrl = await getCoverImage(info.title, info.artist, musicMeta.common.album);
if (imgUrl) {
const imageInfo = await GetImageFromURL(imgUrl);
if (imageInfo) {
imgUrl = imageInfo.url
try {
const newMeta = {picture: imageInfo.buffer, title: info.title, artists: info.artist?.split(" _ ")}
if (ext === "mp3") {
musicDecoded = WriteMetaToMp3(Buffer.from(musicDecoded), newMeta, musicMeta)
musicBlob = new Blob([musicDecoded], {type: mime});
} else if (ext === 'flac') {
musicDecoded = WriteMetaToFlac(Buffer.from(musicDecoded), newMeta, musicMeta)
musicBlob = new Blob([musicDecoded], {type: mime});
} else {
console.info("writing metadata for " + ext + " is not being supported for now")
}
} catch (e) {
console.warn("Error while appending cover image to file " + e)
}
}
}
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
if (keyData) reportKeyUsage(keyData, seed.getMatrix128(),
raw_filename, raw_ext, info.title, info.artist, musicMeta.common.album).then().catch();
let imgUrl = GetCoverFromFile(musicMeta);
if (!imgUrl) {
imgUrl = await getCoverImage(info.title, info.artist, musicMeta.common.album);
if (imgUrl) {
const imageInfo = await GetImageFromURL(imgUrl);
if (imageInfo) {
imgUrl = imageInfo.url
try {
const newMeta = {picture: imageInfo.buffer, title: info.title, artists: info.artist?.split(" _ ")}
if (ext === "mp3") {
musicDecoded = WriteMetaToMp3(Buffer.from(musicDecoded), newMeta, musicMeta)
musicBlob = new Blob([musicDecoded], {type: mime});
} else if (ext === 'flac') {
musicDecoded = WriteMetaToFlac(Buffer.from(musicDecoded), newMeta, musicMeta)
musicBlob = new Blob([musicDecoded], {type: mime});
} else {
console.info("writing metadata for " + ext + " is not being supported for now")
}
} catch (e) {
console.warn("Error while appending cover image to file " + e)
}
}
}
}
return {
title: info.title,
artist: info.artist,
ext: ext,
album: musicMeta.common.album,
picture: imgUrl,
file: URL.createObjectURL(musicBlob),
blob: musicBlob,
mime: mime
}
}
return {
title: info.title,
artist: info.artist,
ext: ext,
album: musicMeta.common.album,
picture: imgUrl,
file: URL.createObjectURL(musicBlob),
blob: musicBlob,
mime: mime
}
}
async function queryKey(keyData: Uint8Array, filename: string, format: string): Promise<QmcMask | undefined> {
try {
const data = await queryKeyInfo(keyData, filename, format)
return new QmcMask(Base64Decode(data.Matrix44));
} catch (e) {
console.warn(e);
}
}
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> {
const song_query_url = "https://stats.ixarea.com/apis" + "/music/qq-cover"
try {
const data = await queryAlbumCover(title, artist, album)
return `${song_query_url}/${data.Type}/${data.Id}`
} catch (e) {
console.warn(e);
}
return ""
const song_query_url = "https://stats.ixarea.com/apis" + "/music/qq-cover"
try {
const data = await queryAlbumCover(title, artist, album)
return `${song_query_url}/${data.Type}/${data.Id}`
} catch (e) {
console.warn(e);
}
return ""
}

View File

@@ -1,206 +0,0 @@
import {BytesHasPrefix, FLAC_HEADER, OGG_HEADER} from "@/decrypt/utils.ts";
const QMOggPublicHeader1 = [
0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff,
0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x1e, 0x01, 0x76, 0x6f, 0x72,
0x62, 0x69, 0x73, 0x00, 0x00, 0x00, 0x00, 0x02, 0x44, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0xee, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb8, 0x01, 0x4f, 0x67, 0x67, 0x53, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00,
0xff, 0xff, 0xff, 0xff];
const QMOggPublicHeader2 = [
0x03, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x2c, 0x00, 0x00, 0x00, 0x58, 0x69, 0x70, 0x68, 0x2e,
0x4f, 0x72, 0x67, 0x20, 0x6c, 0x69, 0x62, 0x56, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x20, 0x49, 0x20,
0x32, 0x30, 0x31, 0x35, 0x30, 0x31, 0x30, 0x35, 0x20, 0x28, 0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84,
0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84, 0x29, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x54,
0x49, 0x54, 0x4c, 0x45, 0x3d];
const QMOggPublicConf1 = [
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 0, 0,
0, 0, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9, 9, 9,
9, 9, 9, 9, 9, 9, 9, 6, 3, 3, 3, 3, 6, 6, 6, 6,
3, 3, 3, 3, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 9, 9,
9, 9, 9, 9, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9,
0, 0, 0, 0];
const QMOggPublicConf2 = [
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 0, 1, 3, 3, 0, 1, 3, 3, 3,
3, 3, 3, 3, 3];
const QMCDefaultMaskMatrix = [
0xde, 0x51, 0xfa, 0xc3, 0x4a, 0xd6, 0xca, 0x90,
0x7e, 0x67, 0x5e, 0xf7, 0xd5, 0x52, 0x84, 0xd8,
0x47, 0x95, 0xbb, 0xa1, 0xaa, 0xc6, 0x66, 0x23,
0x92, 0x62, 0xf3, 0x74, 0xa1, 0x9f, 0xf4, 0xa0,
0x1d, 0x3f, 0x5b, 0xf0, 0x13, 0x0e, 0x09, 0x3d,
0xf9, 0xbc, 0x00, 0x11];
const AllMapping: number[][] = [];
const Mask128to44: number[] = [];
(function () {
for (let i = 0; i < 128; i++) {
let realIdx = (i * i + 27) % 256
if (realIdx in AllMapping) {
AllMapping[realIdx].push(i)
} else {
AllMapping[realIdx] = [i]
}
}
let idx44 = 0
AllMapping.forEach(all128 => {
all128.forEach(_i128 => {
Mask128to44[_i128] = idx44
})
idx44++
})
})();
export class QmcMask {
private readonly Matrix128: number[];
constructor(matrix: number[] | Uint8Array) {
if (matrix instanceof Uint8Array) matrix = Array.from(matrix)
if (matrix.length === 44) {
this.Matrix128 = this._generate128(matrix)
} else if (matrix.length === 128) {
this.Matrix128 = matrix
} else {
throw Error("invalid mask length")
}
}
getMatrix128() {
return this.Matrix128
}
getMatrix44(): number[] {
const matrix44: number[] = []
let idxI44 = 0
AllMapping.forEach(it256 => {
let it256Len = it256.length
for (let i = 1; i < it256Len; i++) {
if (this.Matrix128[it256[0]] !== this.Matrix128[it256[i]]) {
throw "decode mask-128 to mask-44 failed"
}
}
matrix44[idxI44] = this.Matrix128[it256[0]]
idxI44++
})
return matrix44
}
Decrypt(data: Uint8Array) {
if (!this.Matrix128) throw Error("bad call sequence")
let dst = data.slice(0);
let index = -1;
let maskIdx = -1;
for (let cur = 0; cur < data.length; cur++) {
index++;
maskIdx++;
if (index === 0x8000 || (index > 0x8000 && (index + 1) % 0x8000 === 0)) {
index++;
maskIdx++;
}
if (maskIdx >= 128) maskIdx -= 128;
dst[cur] ^= this.Matrix128[maskIdx];
}
return dst;
}
private _generate128(matrix44: number[]): number[] {
const matrix128: number[] = []
let idx44 = 0
AllMapping.forEach(it256 => {
it256.forEach(m => {
matrix128[m] = matrix44[idx44]
})
idx44++
})
return matrix128
}
}
export function QmcMaskGetDefault() {
return new QmcMask(QMCDefaultMaskMatrix)
}
export function QmcMaskDetectMflac(data: Uint8Array) {
let search_len = Math.min(0x8000, data.length), mask;
for (let block_idx = 0; block_idx < search_len; block_idx += 128) {
try {
mask = new QmcMask(data.slice(block_idx, block_idx + 128));
if (BytesHasPrefix(mask.Decrypt(data.slice(0, FLAC_HEADER.length)), FLAC_HEADER)) {
break;
}
} catch (e) {
}
}
return mask;
}
export function QmcMaskDetectMgg(data: Uint8Array) {
if (data.length < 0x100) return
let matrixConfidence: { [key: number]: { [key: number]: number } } = {};
for (let i = 0; i < 44; i++) matrixConfidence[i] = {};
const page2 = data[0x54] ^ data[0xC] ^ QMOggPublicHeader1[0xC];
const spHeader = QmcGenerateOggHeader(page2)
const spConf = QmcGenerateOggConf(page2)
for (let idx128 = 0; idx128 < spHeader.length; idx128++) {
if (spConf[idx128] === 0) continue;
let idx44 = Mask128to44[idx128 % 128];
let _m = data[idx128] ^ spHeader[idx128]
let confidence = spConf[idx128];
if (_m in matrixConfidence[idx44]) {
matrixConfidence[idx44][_m] += confidence
} else {
matrixConfidence[idx44][_m] = confidence
}
}
let matrix = [];
try {
for (let i = 0; i < 44; i++)
matrix[i] = calcMaskFromConfidence(matrixConfidence[i]);
} catch (e) {
return;
}
const mask = new QmcMask(matrix);
if (!BytesHasPrefix(mask.Decrypt(data.slice(0, OGG_HEADER.length)), OGG_HEADER)) {
return;
}
return mask;
}
function calcMaskFromConfidence(confidence: { [key: number]: number }) {
const count = Object.keys(confidence).length
if (count === 0) throw "can not match at least one key";
if (count > 1) console.warn("There are 2 potential value for the mask!")
let result = ""
let conf = 0
for (let idx in confidence) {
if (confidence[idx] > conf) {
result = idx;
conf = confidence[idx];
}
}
return Number(result)
}
function QmcGenerateOggHeader(page2: number) {
let spec = [page2, 0xFF]
for (let i = 2; i < page2; i++) spec.push(0xFF)
spec.push(0xFF)
return QMOggPublicHeader1.concat(spec, QMOggPublicHeader2)
}
function QmcGenerateOggConf(page2: number) {
let specConf = [6, 0]
for (let i = 2; i < page2; i++) specConf.push(4)
specConf.push(0)
return QMOggPublicConf1.concat(specConf, QMOggPublicConf2)
}

View File

@@ -0,0 +1,27 @@
import {QmcStaticCipher} from "@/decrypt/qmc_cipher";
test("static cipher [0x7ff8,0x8000) ", () => {
const expected = new Uint8Array([
0xD8, 0x52, 0xF7, 0x67, 0x90, 0xCA, 0xD6, 0x4A,
0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 0xD8,
])
const c = new QmcStaticCipher()
const buf = new Uint8Array(16)
c.decrypt(buf, 0x7ff8)
expect(buf).toStrictEqual(expected)
})
test("static cipher [0,0x10) ", () => {
const expected = new Uint8Array([
0xC3, 0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52,
0xD8, 0xA1, 0x66, 0x62, 0x9F, 0x5B, 0x09, 0x00,
])
const c = new QmcStaticCipher()
const buf = new Uint8Array(16)
c.decrypt(buf, 0)
expect(buf).toStrictEqual(expected)
})

53
src/decrypt/qmc_cipher.ts Normal file
View File

@@ -0,0 +1,53 @@
const staticCipherBox = new Uint8Array([
0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, //0x00
0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, //0x08
0x9E, 0xE6, 0x9D, 0xCF, 0xFA, 0x7F, 0x14, 0xD1, //0x10
0xCE, 0xB8, 0xDC, 0xC3, 0x4A, 0x67, 0x93, 0xD6, //0x18
0x28, 0xC2, 0x91, 0x70, 0xCA, 0x8D, 0xA2, 0xA4, //0x20
0xF0, 0x08, 0x61, 0x90, 0x7E, 0x6F, 0xA2, 0xE0, //0x28
0xEB, 0xAE, 0x3E, 0xB6, 0x67, 0xC7, 0x92, 0xF4, //0x30
0x91, 0xB5, 0xF6, 0x6C, 0x5E, 0x84, 0x40, 0xF7, //0x38
0xF3, 0x1B, 0x02, 0x7F, 0xD5, 0xAB, 0x41, 0x89, //0x40
0x28, 0xF4, 0x25, 0xCC, 0x52, 0x11, 0xAD, 0x43, //0x48
0x68, 0xA6, 0x41, 0x8B, 0x84, 0xB5, 0xFF, 0x2C, //0x50
0x92, 0x4A, 0x26, 0xD8, 0x47, 0x6A, 0x7C, 0x95, //0x58
0x61, 0xCC, 0xE6, 0xCB, 0xBB, 0x3F, 0x47, 0x58, //0x60
0x89, 0x75, 0xC3, 0x75, 0xA1, 0xD9, 0xAF, 0xCC, //0x68
0x08, 0x73, 0x17, 0xDC, 0xAA, 0x9A, 0xA2, 0x16, //0x70
0x41, 0xD8, 0xA2, 0x06, 0xC6, 0x8B, 0xFC, 0x66, //0x78
0x34, 0x9F, 0xCF, 0x18, 0x23, 0xA0, 0x0A, 0x74, //0x80
0xE7, 0x2B, 0x27, 0x70, 0x92, 0xE9, 0xAF, 0x37, //0x88
0xE6, 0x8C, 0xA7, 0xBC, 0x62, 0x65, 0x9C, 0xC2, //0x90
0x08, 0xC9, 0x88, 0xB3, 0xF3, 0x43, 0xAC, 0x74, //0x98
0x2C, 0x0F, 0xD4, 0xAF, 0xA1, 0xC3, 0x01, 0x64, //0xA0
0x95, 0x4E, 0x48, 0x9F, 0xF4, 0x35, 0x78, 0x95, //0xA8
0x7A, 0x39, 0xD6, 0x6A, 0xA0, 0x6D, 0x40, 0xE8, //0xB0
0x4F, 0xA8, 0xEF, 0x11, 0x1D, 0xF3, 0x1B, 0x3F, //0xB8
0x3F, 0x07, 0xDD, 0x6F, 0x5B, 0x19, 0x30, 0x19, //0xC0
0xFB, 0xEF, 0x0E, 0x37, 0xF0, 0x0E, 0xCD, 0x16, //0xC8
0x49, 0xFE, 0x53, 0x47, 0x13, 0x1A, 0xBD, 0xA4, //0xD0
0xF1, 0x40, 0x19, 0x60, 0x0E, 0xED, 0x68, 0x09, //0xD8
0x06, 0x5F, 0x4D, 0xCF, 0x3D, 0x1A, 0xFE, 0x20, //0xE0
0x77, 0xE4, 0xD9, 0xDA, 0xF9, 0xA4, 0x2B, 0x76, //0xE8
0x1C, 0x71, 0xDB, 0x00, 0xBC, 0xFD, 0x0C, 0x6C, //0xF0
0xA5, 0x47, 0xF7, 0xF6, 0x00, 0x79, 0x4A, 0x11, //0xF8
])
interface streamCipher {
decrypt(buf: Uint8Array, offset: number): void
}
export class QmcStaticCipher implements streamCipher {
public getMask(offset: number) {
if (offset > 0x7FFF) offset %= 0x7FFF
return staticCipherBox[(offset * offset + 27) & 0xff]
}
public decrypt(buf: Uint8Array, offset: number) {
for (let i = 0; i < buf.length; i++) {
buf[i] ^= this.getMask(offset + i)
}
}
}

51
src/decrypt/qmccache.ts Normal file
View File

@@ -0,0 +1,51 @@
import {
AudioMimeType,
GetArrayBuffer,
GetCoverFromFile,
GetMetaFromFile,
SniffAudioExt,
SplitFilename
} from "@/decrypt/utils";
import {Decrypt as QmcDecrypt, HandlerMap} from "@/decrypt/qmc";
import {DecryptResult} from "@/decrypt/entity";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
export async function Decrypt(file: Blob, raw_filename: string, _: string)
: Promise<DecryptResult> {
const buffer = new Uint8Array(await GetArrayBuffer(file));
let length = buffer.length
for (let i = 0; i < length; i++) {
buffer[i] ^= 0xf4
if (buffer[i] <= 0x3f) buffer[i] = buffer[i] * 4;
else if (buffer[i] <= 0x7f) buffer[i] = (buffer[i] - 0x40) * 4 + 1;
else if (buffer[i] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2;
else buffer[i] = (buffer[i] - 0xc0) * 4 + 3;
}
let ext = SniffAudioExt(buffer, "");
const newName = SplitFilename(raw_filename)
let audioBlob: Blob
if (ext !== "" || newName.ext === "mp3") {
audioBlob = new Blob([buffer], {type: AudioMimeType[ext]})
} else if (newName.ext in HandlerMap) {
audioBlob = new Blob([buffer], {type: "application/octet-stream"})
return QmcDecrypt(audioBlob, newName.name, newName.ext);
} else {
throw "不支持的QQ音乐缓存格式"
}
const tag = await metaParseBlob(audioBlob);
const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist)
return {
title,
artist,
ext,
album: tag.common.album,
picture: GetCoverFromFile(tag),
file: URL.createObjectURL(audioBlob),
blob: audioBlob,
mime: AudioMimeType[ext]
}
}

102
src/decrypt/qmcv2.ts Normal file
View File

@@ -0,0 +1,102 @@
import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle';
// 检测文件末端使用的缓冲区大小
const DETECTION_SIZE = 40;
// 每次处理 2M 的数据
const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024;
function MergeUint8Array(array: Uint8Array[]): Uint8Array {
let length = 0;
array.forEach(item => {
length += item.length;
});
let mergedArray = new Uint8Array(length);
let offset = 0;
array.forEach(item => {
mergedArray.set(item, offset);
offset += item.length;
});
return mergedArray;
}
/**
* 解密一个 QMC2 加密的文件。
*
* 如果检测并解密成功,返回解密后的 Uint8Array 数据。
* @param {ArrayBuffer} mggBlob 读入的文件 Blob
* @return {Promise<Uint8Array|false>}
*/
export async function DecryptQMCv2(mggBlob: ArrayBuffer) {
// 初始化模组
const QMCCrypto = await QMCCryptoModule();
// 申请内存块,并文件末端数据到 WASM 的内存堆
const detectionBuf = new Uint8Array(mggBlob.slice(-DETECTION_SIZE));
const pDetectionBuf = QMCCrypto._malloc(detectionBuf.length);
QMCCrypto.writeArrayToMemory(detectionBuf, pDetectionBuf);
// 检测结果内存块
const pDetectionResult = QMCCrypto._malloc(QMCCrypto.sizeof_qmc_detection());
// 进行检测
const detectOK = QMCCrypto.detectKeyEndPosition(
pDetectionResult,
pDetectionBuf,
detectionBuf.length
);
// 提取结构体内容:
// (pos: i32; len: i32; error: char[??])
const position = QMCCrypto.getValue(pDetectionResult, "i32");
const len = QMCCrypto.getValue(pDetectionResult + 4, "i32");
// 释放内存
QMCCrypto._free(pDetectionBuf);
QMCCrypto._free(pDetectionResult);
if (!detectOK) {
return false;
}
// 计算解密后文件的大小。
// 之前得到的 position 为相对当前检测数据起点的偏移。
const decryptedSize = mggBlob.byteLength - DETECTION_SIZE + position;
// 提取嵌入到文件的 EKey
const ekey = new Uint8Array(
mggBlob.slice(decryptedSize, decryptedSize + len)
);
// 解码 UTF-8 数据到 string
const decoder = new TextDecoder();
const ekey_b64 = decoder.decode(ekey);
// 初始化加密与缓冲区
const hCrypto = QMCCrypto.createInstWidthEKey(ekey_b64);
const buf = QMCCrypto._malloc(DECRYPTION_BUF_SIZE);
const decryptedParts = [];
let offset = 0;
let bytesToDecrypt = decryptedSize;
while (bytesToDecrypt > 0) {
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
// 解密一些片段
const blockData = new Uint8Array(
mggBlob.slice(offset, offset + blockSize)
);
QMCCrypto.writeArrayToMemory(blockData, buf);
QMCCrypto.decryptStream(hCrypto, buf, offset, blockSize);
decryptedParts.push(QMCCrypto.HEAPU8.slice(buf, buf + blockSize));
offset += blockSize;
bytesToDecrypt -= blockSize;
}
QMCCrypto._free(buf);
hCrypto.delete();
return MergeUint8Array(decryptedParts);
}

View File

@@ -1,4 +1,4 @@
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils.ts";
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils";
import {DecryptResult} from "@/decrypt/entity";

View File

@@ -1,5 +1,5 @@
import {Decrypt as RawDecrypt} from "./raw";
import {GetArrayBuffer} from "@/decrypt/utils.ts";
import {GetArrayBuffer} from "@/decrypt/utils";
import {DecryptResult} from "@/decrypt/entity";
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70];

View File

@@ -12,13 +12,16 @@ export const WMA_HEADER = [
]
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]
export const AAC_HEADER = [0xFF, 0xF1]
export const DFF_HEADER = [0x46, 0x52, 0x4D, 0x38]
export const AudioMimeType: { [key: string]: string } = {
mp3: "audio/mpeg",
flac: "audio/flac",
m4a: "audio/mp4",
ogg: "audio/ogg",
wma: "audio/x-ms-wma",
wav: "audio/x-wav"
wav: "audio/x-wav",
dff: "audio/x-dff"
};
@@ -39,6 +42,7 @@ export function SniffAudioExt(data: Uint8Array, fallback_ext: string = "mp3"): s
if (BytesHasPrefix(data, WAV_HEADER)) return "wav"
if (BytesHasPrefix(data, WMA_HEADER)) return "wma"
if (BytesHasPrefix(data, AAC_HEADER)) return "aac"
if (BytesHasPrefix(data, DFF_HEADER)) return "dff"
return fallback_ext;
}
@@ -156,3 +160,11 @@ export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: I
}
return writer.save()
}
export function SplitFilename(n: string): { name: string; ext: string } {
const pos = n.lastIndexOf(".")
return {
ext: n.substring(pos + 1).toLowerCase(),
name: n.substring(0, pos)
}
}

View File

@@ -1,6 +1,6 @@
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
import {DecryptResult} from "@/decrypt/entity";
import {AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile} from "@/decrypt/utils.ts";
import {AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile} from "@/decrypt/utils";
import {parseBlob as metaParseBlob} from "music-metadata-browser";

View File

@@ -1,25 +0,0 @@
//TODO: Use other method to fix this
// !! Only Temporary Solution
// it seems like that @babel/plugin-proposal-object-rest-spread not working
// to fix up the compatibility for Edge 18 and some older Chromium
// now manually edit the dependency files
const fs = require('fs');
const filePath = "./node_modules/file-type/core.js";
const regReplace = /{\s*([a-zA-Z0-9:,\s]*),\s*\.\.\.([a-zA-Z0-9]*)\s*};/m;
if (fs.existsSync(filePath)) {
console.log("File Found!");
let data = fs.readFileSync(filePath).toString();
const regResult = regReplace.exec(data);
if (regResult != null) {
data = data.replace(regResult[0],
"Object.assign({ " + regResult[1] + " }, " + regResult[2] + ");"
);
fs.writeFileSync(filePath, data);
console.log("Object rest spread in file-type fixed!");
} else {
console.log("No fix needed.");
}
} else {
console.log("File Not Found!");
}

9
src/shims-fs.d.ts vendored
View File

@@ -6,6 +6,10 @@ interface FileSystemCreateWritableOptions {
keepExistingData?: boolean
}
interface FileSystemRemoveOptions {
recursive?: boolean
}
interface FileSystemFileHandle {
getFile(): Promise<File>;
@@ -37,13 +41,16 @@ interface FileSystemWritableFileStream extends WritableStream {
close(): Promise<undefined> // should be implemented in WritableStream
}
export declare interface FileSystemDirectoryHandle {
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>
}
declare global {
interface Window {
FileSystemDirectoryHandle
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>
}

View File

@@ -1,6 +1,6 @@
import {fromByteArray as Base64Encode} from "base64-js";
export const IXAREA_API_ENDPOINT = "https://stats.ixarea.com/apis"
export const IXAREA_API_ENDPOINT = "https://um-api.ixarea.com"
export interface UpdateInfo {
Found: boolean

View File

@@ -144,9 +144,9 @@ export default {
}
try {
this.dir = await window.showDirectoryPicker()
window.dir = this.dir
window.f = await this.dir.getFileHandle("write-test.txt", {create: true})
const test_filename = "__unlock_music_write_test.txt"
await this.dir.getFileHandle(test_filename, {create: true})
await this.dir.removeEntry(test_filename)
} catch (e) {
console.error(e)
}

View File

@@ -14,7 +14,8 @@
"sourceMap": true,
"baseUrl": ".",
"types": [
"webpack-env"
"webpack-env",
"jest"
],
"paths": {
"@/*": [
@@ -26,7 +27,8 @@
"dom",
"dom.iterable",
"scripthost"
]
],
"resolveJsonModule": true
},
"include": [
"src/**/*.ts",