diff --git a/package.json b/package.json index fc7b2a6..b5afac9 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@emotion/styled": "^11.11.0", "@reduxjs/toolkit": "^2.0.1", "@um/libparakeet": "0.4.5", - "@unlock-music/crypto": "0.0.0-alpha.10", + "@unlock-music/crypto": "0.0.0-alpha.11", "framer-motion": "^10.16.16", "nanoid": "^5.0.4", "radash": "^11.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00785fd..492ae43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: 0.4.5 version: 0.4.5 '@unlock-music/crypto': - specifier: 0.0.0-alpha.10 - version: 0.0.0-alpha.10 + specifier: 0.0.0-alpha.11 + version: 0.0.0-alpha.11 framer-motion: specifier: ^10.16.16 version: 10.16.16(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1927,8 +1927,8 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - '@unlock-music/crypto@0.0.0-alpha.10': - resolution: {integrity: sha512-Y8PWd/f4KEh2WU5Uz4QesnYMelDvioLYMOVvpPceMk62P2LivQLgvl5+ytDmD2yzmsnE/sXOQztTg+1WsuePCA==, tarball: https://git.unlock-music.dev/api/packages/um/npm/%40unlock-music%2Fcrypto/-/0.0.0-alpha.10/crypto-0.0.0-alpha.10.tgz} + '@unlock-music/crypto@0.0.0-alpha.11': + resolution: {integrity: sha512-lA3xryziHULhkPbuQFI2HrfwDREUD9YoaZOTMQqcu/8mKF2/hA3sCK0Uoq0miYr+7VUbE5sMBvl9dcrnCI1UWA==, tarball: https://git.unlock-music.dev/api/packages/um/npm/%40unlock-music%2Fcrypto/-/0.0.0-alpha.11/crypto-0.0.0-alpha.11.tgz} '@vitejs/plugin-react@4.2.1': resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} @@ -6118,7 +6118,7 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@unlock-music/crypto@0.0.0-alpha.10': {} + '@unlock-music/crypto@0.0.0-alpha.11': {} '@vitejs/plugin-react@4.2.1(vite@5.0.10(@types/node@20.10.5)(sass@1.69.5)(terser@5.27.0))': dependencies: diff --git a/src/decrypt-worker/Deciphers.ts b/src/decrypt-worker/Deciphers.ts index e77dbd4..d4782ec 100644 --- a/src/decrypt-worker/Deciphers.ts +++ b/src/decrypt-worker/Deciphers.ts @@ -1,6 +1,8 @@ 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'; export enum Status { OK = 0, @@ -41,7 +43,7 @@ export const allCryptoFactories: DecipherFactory[] = [ // KGMCrypto.make, // KWMv1 (*.kwm) - // KWMCrypto.make, + KuwoMusicDecipher.make, // Xiami (*.xm) // XiamiCrypto.make, @@ -49,8 +51,8 @@ export const allCryptoFactories: DecipherFactory[] = [ /// File with a fixed footer goes second // QMCv2 (*.mflac) - // QMC2CryptoWithKey.make, - // QMC2Crypto.make, + QQMusicV2Decipher.createWithUserKey, + QQMusicV2Decipher.createWithEmbeddedEKey, /// File without an obvious header or footer goes last. @@ -61,7 +63,7 @@ export const allCryptoFactories: DecipherFactory[] = [ // should be moved to the bottom of the list for performance reasons. // QMCv1 (*.qmcflac) - // QMC1Crypto.make, + QQMusicV1Decipher.create, // Ximalaya (Android) // XimalayaAndroidCrypto.makeX2M, diff --git a/src/decrypt-worker/crypto/qmc/qmc_v2.ts b/src/decrypt-worker/crypto/qmc/qmc_v2.ts deleted file mode 100644 index 2482a1d..0000000 --- a/src/decrypt-worker/crypto/qmc/qmc_v2.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { transformBlob } from '~/decrypt-worker/util/transformBlob'; -import type { DecipherInstance } from '~/decrypt-worker/Deciphers.ts'; -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 DecipherInstance { - cryptoName = 'QMC/v2'; - checkByDecryptHeader = false; - - async decrypt(buffer: ArrayBuffer): Promise { - 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 DecipherInstance { - cryptoName = 'QMC/v2 (key)'; - checkByDecryptHeader = true; - - async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions): Promise { - return Boolean(options.qmc2Key); - } - - async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise { - 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(); - } -} diff --git a/src/decrypt-worker/decipher/KuwoMusic.ts b/src/decrypt-worker/decipher/KuwoMusic.ts new file mode 100644 index 0000000..8520b30 --- /dev/null +++ b/src/decrypt-worker/decipher/KuwoMusic.ts @@ -0,0 +1,35 @@ +import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers'; +import { KuwoHeader, KWMCipher } 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 { + let header: KuwoHeader | undefined; + let kwm: KWMCipher | undefined; + + try { + header = KuwoHeader.parse(buffer.subarray(0, 0x400)); + kwm = header.makeCipher(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(); + } +} diff --git a/src/decrypt-worker/decipher/NetEaseCloudMusic.ts b/src/decrypt-worker/decipher/NetEaseCloudMusic.ts index f8b05aa..f3ecd1f 100644 --- a/src/decrypt-worker/decipher/NetEaseCloudMusic.ts +++ b/src/decrypt-worker/decipher/NetEaseCloudMusic.ts @@ -1,7 +1,6 @@ import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers'; import { NCMFile } from '@unlock-music/crypto'; import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts'; -import { withWasmClass } from '~/decrypt-worker/util/wasmClass.ts'; import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts'; export class NetEaseCloudMusicDecipher implements DecipherInstance { @@ -18,12 +17,12 @@ export class NetEaseCloudMusicDecipher implements DecipherInstance { } } - async decrypt(buffer: ArrayBuffer): Promise { - return withWasmClass(new NCMFile(), async (ncm): Promise => { - const data = new Uint8Array(buffer); - this.tryInit(ncm, data); + async decrypt(buffer: Uint8Array): Promise { + const ncm = new NCMFile(); + try { + this.tryInit(ncm, buffer); - const audioBuffer = data.subarray(ncm.audioOffset); + const audioBuffer = buffer.slice(ncm.audioOffset); for (const [block, offset] of chunkBuffer(audioBuffer)) { ncm.decrypt(block, offset); } @@ -32,7 +31,9 @@ export class NetEaseCloudMusicDecipher implements DecipherInstance { cipherName: this.cipherName, data: audioBuffer, }; - }); + } finally { + ncm.free(); + } } public static make() { diff --git a/src/decrypt-worker/decipher/QQMusic.ts b/src/decrypt-worker/decipher/QQMusic.ts new file mode 100644 index 0000000..5e53455 --- /dev/null +++ b/src/decrypt-worker/decipher/QQMusic.ts @@ -0,0 +1,74 @@ +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 { + 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 { + 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); + } +} diff --git a/src/decrypt-worker/util/audioType.ts b/src/decrypt-worker/util/audioType.ts index fa2f8b5..71b1b58 100644 --- a/src/decrypt-worker/util/audioType.ts +++ b/src/decrypt-worker/util/audioType.ts @@ -1,6 +1,6 @@ import { detectAudioType } from '@unlock-music/crypto'; -export async function detectAudioExtension(buffer: Uint8Array): Promise { +export function detectAudioExtension(buffer: Uint8Array): string { let neededLength = 0x100; let extension = 'bin'; while (neededLength !== 0) { @@ -12,3 +12,15 @@ export async function detectAudioExtension(buffer: Uint8Array): Promise } 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; +} diff --git a/src/decrypt-worker/worker/handler/decrypt.ts b/src/decrypt-worker/worker/handler/decrypt.ts index 53b94ac..7dc2a91 100644 --- a/src/decrypt-worker/worker/handler/decrypt.ts +++ b/src/decrypt-worker/worker/handler/decrypt.ts @@ -65,7 +65,7 @@ class DecryptCommandHandler { } // Check if we had a successful decryption - let audioExt = result.overrideExtension || (await detectAudioExtension(result.data)); + let audioExt = result.overrideExtension || detectAudioExtension(result.data); if (!result.overrideExtension && audioExt === 'bin') { throw new UnsupportedSourceFile('unable to produce valid audio file'); } diff --git a/vite.config.ts b/vite.config.ts index f90db5b..5770cce 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ // Allow pnpm to link. process.env.LIB_PARAKEET_JS_DIR || '../libparakeet-js', + process.env.LIB_UM_WASM_LOADER_DIR || '../lib_um_crypto_rust/um_wasm_loader', ], }, },