diff --git a/src/decrypt-worker/crypto/CryptoBase.ts b/src/decrypt-worker/crypto/CryptoBase.ts index 744cb51..4fcc0d9 100644 --- a/src/decrypt-worker/crypto/CryptoBase.ts +++ b/src/decrypt-worker/crypto/CryptoBase.ts @@ -1,10 +1,10 @@ export interface CryptoBase { - /** - * When returning false, a successful decryption should be checked by its decrypted content instead. - */ - hasSignature(): boolean; - isSupported(blob: Blob): Promise; - decrypt(blob: Blob): Promise; + cryptoName: string; + checkByDecryptHeader: boolean; + decryptTargetAudio: boolean; + + checkBySignature?: (buffer: ArrayBuffer) => Promise; + decrypt(buffer: ArrayBuffer, blob: Blob): Promise; } export type CryptoFactory = () => CryptoBase; diff --git a/src/decrypt-worker/crypto/CryptoFactory.ts b/src/decrypt-worker/crypto/CryptoFactory.ts new file mode 100644 index 0000000..3f5daba --- /dev/null +++ b/src/decrypt-worker/crypto/CryptoFactory.ts @@ -0,0 +1,19 @@ +import { CryptoFactory } from './CryptoBase'; + +import { QMC1Crypto } from './qmc/qmc_v1'; +import { QMC2Crypto } from './qmc/qmc_v2'; +import { XiamiCrypto } from './xiami/xiami'; + +export const allCryptoFactories: CryptoFactory[] = [ + // Xiami (*.xm) + () => new XiamiCrypto(), + + // QMCv2 (*.mflac) + () => new QMC2Crypto(), + + // 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) + () => new QMC1Crypto(), +]; diff --git a/src/decrypt-worker/crypto/qmc/qmc_v1.ts b/src/decrypt-worker/crypto/qmc/qmc_v1.ts index 4f8876d..d7a0acd 100644 --- a/src/decrypt-worker/crypto/qmc/qmc_v1.ts +++ b/src/decrypt-worker/crypto/qmc/qmc_v1.ts @@ -3,15 +3,11 @@ import type { CryptoBase } from '../CryptoBase'; import key from './qmc_v1.key.ts'; export class QMC1Crypto implements CryptoBase { - hasSignature(): boolean { - return false; - } + cryptoName = 'QMCv1'; + checkByDecryptHeader = true; + decryptTargetAudio = true; - async isSupported(): Promise { - return true; - } - - async decrypt(blob: Blob): Promise { + async decrypt(_buffer: ArrayBuffer, blob: Blob): Promise { return transformBlob(blob, (p) => p.make.QMCv1(key)); } } diff --git a/src/decrypt-worker/crypto/qmc/qmc_v2.ts b/src/decrypt-worker/crypto/qmc/qmc_v2.ts index 04b31bc..4c74240 100644 --- a/src/decrypt-worker/crypto/qmc/qmc_v2.ts +++ b/src/decrypt-worker/crypto/qmc/qmc_v2.ts @@ -3,15 +3,11 @@ import type { CryptoBase } from '../CryptoBase'; import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts'; export class QMC2Crypto implements CryptoBase { - hasSignature(): boolean { - return false; - } + cryptoName = 'QMCv2'; + checkByDecryptHeader = false; + decryptTargetAudio = true; - async isSupported(): Promise { - return true; - } - - async decrypt(blob: Blob): Promise { + async decrypt(_buffer: ArrayBuffer, blob: Blob): Promise { return transformBlob(blob, (p) => p.make.QMCv2(p.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2))); } } diff --git a/src/decrypt-worker/crypto/xiami/xiami.ts b/src/decrypt-worker/crypto/xiami/xiami.ts index e065e54..98afd6d 100644 --- a/src/decrypt-worker/crypto/xiami/xiami.ts +++ b/src/decrypt-worker/crypto/xiami/xiami.ts @@ -11,8 +11,9 @@ import type { CryptoBase } from '../CryptoBase'; -const XIAMI_FILE_MAGIC = new Uint8Array('ifmt'.split('').map((x) => x.charCodeAt(0))); -const XIAMI_EXPECTED_PADDING = new Uint8Array([0xfe, 0xfe, 0xfe, 0xfe]); +// little endian +const XIAMI_FILE_MAGIC = 0x746d6669; +const XIAMI_EXPECTED_PADDING = 0xfefefefe; const u8Sub = (a: number, b: number) => { if (a > b) { @@ -23,29 +24,25 @@ const u8Sub = (a: number, b: number) => { }; export class XiamiCrypto implements CryptoBase { - hasSignature(): boolean { - return true; + cryptoName = 'Xiami'; + checkByDecryptHeader = false; + decryptTargetAudio = true; + + async checkBySignature(buffer: ArrayBuffer): Promise { + const header = new DataView(buffer); + + return header.getUint32(0x00, true) === XIAMI_FILE_MAGIC && header.getUint32(0x08, true) === XIAMI_EXPECTED_PADDING; } - async isSupported(blob: Blob): Promise { - const headerBuffer = await blob.slice(0, 0x10).arrayBuffer(); - const header = new Uint8Array(headerBuffer); - - return ( - header.slice(0x00, 0x04).every((b, i) => b === XIAMI_FILE_MAGIC[i]) && - header.slice(0x08, 0x0c).every((b, i) => b === XIAMI_EXPECTED_PADDING[i]) - ); - } - - async decrypt(blob: Blob): Promise { - const headerBuffer = await blob.slice(0, 0x10).arrayBuffer(); + async decrypt(src: ArrayBuffer): Promise { + 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(await blob.slice(0x10).arrayBuffer()); + const decrypted = new Uint8Array(src.slice(0x10)); for (let i = decrypted.byteLength - 1; i >= plainTextSize; i--) { decrypted[i] = u8Sub(key, decrypted[i]); } - return new Blob([decrypted]); + return decrypted; } } diff --git a/src/decrypt-worker/util/buffer.ts b/src/decrypt-worker/util/buffer.ts new file mode 100644 index 0000000..a150c28 --- /dev/null +++ b/src/decrypt-worker/util/buffer.ts @@ -0,0 +1,2 @@ +export const toArrayBuffer = async (src: Blob | ArrayBuffer) => (src instanceof Blob ? await src.arrayBuffer() : src); +export const toBlob = (src: Blob | ArrayBuffer) => (src instanceof Blob ? src : new Blob([src])); diff --git a/src/decrypt-worker/worker-handler/decrypt.ts b/src/decrypt-worker/worker-handler/decrypt.ts deleted file mode 100644 index ad2ac73..0000000 --- a/src/decrypt-worker/worker-handler/decrypt.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { fetchParakeet } from '@jixun/libparakeet'; -import { CryptoFactory } from '../crypto/CryptoBase'; - -import { XiamiCrypto } from '../crypto/xiami/xiami'; -import { QMC1Crypto } from '../crypto/qmc/qmc_v1'; -import { QMC2Crypto } from '../crypto/qmc/qmc_v2'; - -// Use first 4MiB of the file to perform check. -const TEST_FILE_HEADER_LEN = 1024 * 1024 * 4; - -const decryptorFactories: CryptoFactory[] = [ - // Xiami (*.xm) - () => new XiamiCrypto(), - - // QMCv1 (*.qmcflac) - () => new QMC1Crypto(), - - // QMCv2 (*.mflac) - () => new QMC2Crypto(), -]; - -export const workerDecryptHandler = async (blobURI: string) => { - const blob = await fetch(blobURI).then((r) => r.blob()); - const parakeet = await fetchParakeet(); - - for (const factory of decryptorFactories) { - const decryptor = factory(); - if (await decryptor.isSupported(blob)) { - try { - const decryptedBlob = await decryptor.decrypt(blob); - - // Check if we had a successful decryption - const header = await decryptedBlob.slice(0, TEST_FILE_HEADER_LEN).arrayBuffer(); - const audioExt = parakeet.detectAudioExtension(header); - if (!decryptor.hasSignature() && audioExt === 'bin') { - // skip this decryptor result - continue; - } - - return { decrypted: URL.createObjectURL(decryptedBlob), ext: audioExt }; - } catch (error) { - console.error('decrypt failed: ', error); - continue; - } - } - } - - throw new Error('could not decrypt file: no working decryptor found'); -}; diff --git a/src/decrypt-worker/worker.ts b/src/decrypt-worker/worker.ts index 43679ea..eee2239 100644 --- a/src/decrypt-worker/worker.ts +++ b/src/decrypt-worker/worker.ts @@ -3,7 +3,7 @@ import { DECRYPTION_WORKER_ACTION_NAME } from './constants'; import { getSDKVersion } from '@jixun/libparakeet'; -import { workerDecryptHandler } from './worker-handler/decrypt'; +import { workerDecryptHandler } from './worker/handler/decrypt'; const bus = new WorkerServerBus(); onmessage = bus.onmessage; diff --git a/src/decrypt-worker/worker/handler/decrypt.ts b/src/decrypt-worker/worker/handler/decrypt.ts new file mode 100644 index 0000000..1aa7422 --- /dev/null +++ b/src/decrypt-worker/worker/handler/decrypt.ts @@ -0,0 +1,93 @@ +import { Parakeet, fetchParakeet } from '@jixun/libparakeet'; +import { timedLogger } from '~/util/timedLogger'; +import { allCryptoFactories } from '../../crypto/CryptoFactory'; +import { toArrayBuffer, toBlob } from '~/decrypt-worker/util/buffer'; +import { CryptoBase, CryptoFactory } from '~/decrypt-worker/crypto/CryptoBase'; + +// 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 blob: Blob, private buffer: ArrayBuffer) { + this.label = `DecryptCommandHandler( ${label} )`; + } + + log(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.decryptFile(decryptor); + if (result === null) { + continue; + } + return result; + } catch (error) { + console.error('decrypt failed: ', error); + continue; + } + } + + throw new Error('could not decrypt file: no working decryptor found'); + } + + async decryptFile(crypto: CryptoBase) { + if (crypto.checkBySignature && !(await crypto.checkBySignature(this.buffer))) { + return null; + } + + if (crypto.checkByDecryptHeader && !(await this.acceptByDecryptFileHeader(crypto))) { + return null; + } + + const decrypted = await this.log('decrypt', async () => crypto.decrypt(this.buffer, this.blob)); + + // Check if we had a successful decryption + const audioExt = await this.log(`detect-audio-ext`, async () => { + const header = await toArrayBuffer(decrypted.slice(0, TEST_FILE_HEADER_LEN)); + return this.parakeet.detectAudioExtension(header); + }); + + return { decrypted: URL.createObjectURL(toBlob(decrypted)), ext: audioExt }; + } + + async acceptByDecryptFileHeader(crypto: CryptoBase): Promise { + // 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.blob.slice(0, TEST_FILE_HEADER_LEN)) + ) + ); + + return this.parakeet.detectAudioExtension(decryptedBuffer) !== 'bin'; + } +} + +export const workerDecryptHandler = async ({ id, blobURI }: { id: string; blobURI: string }) => { + const label = `decrypt( ${id} )`; + console.group(label); + + try { + return await timedLogger(`${label}/total`, async () => { + 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, blob, buffer); + return handler.decrypt(allCryptoFactories); + }); + } finally { + (console.groupEnd as (label: string) => void)(label); + } +}; diff --git a/src/util/DecryptionQueue.ts b/src/util/DecryptionQueue.ts index 339035f..8e9346f 100644 --- a/src/util/DecryptionQueue.ts +++ b/src/util/DecryptionQueue.ts @@ -8,6 +8,6 @@ export class DecryptionQueue extends ConcurrentQueue<{ id: string; blobURI: stri } async handler(item: { id: string; blobURI: string }): Promise { - return this.workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, item.blobURI); + return this.workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, item); } } diff --git a/src/util/__tests__/DecryptionQueue.test.ts b/src/util/__tests__/DecryptionQueue.test.ts index a1c89a2..4f2bf35 100644 --- a/src/util/__tests__/DecryptionQueue.test.ts +++ b/src/util/__tests__/DecryptionQueue.test.ts @@ -24,6 +24,9 @@ test('should be able to forward request to worker client bus', async () => { const queue = new DecryptionQueue(bus, 1); await expect(queue.add({ id: 'file://1', blobURI: 'blob://mock-file' })).resolves.toEqual({ actionName: DECRYPTION_WORKER_ACTION_NAME.DECRYPT, - payload: 'blob://mock-file', + payload: { + blobURI: 'blob://mock-file', + id: 'file://1', + }, }); }); diff --git a/src/util/timedLogger.ts b/src/util/timedLogger.ts new file mode 100644 index 0000000..056d53b --- /dev/null +++ b/src/util/timedLogger.ts @@ -0,0 +1,22 @@ +function isPromise(p: unknown): p is Promise { + return !!p && typeof p === 'object' && 'then' in p && 'catch' in p && 'finally' in p; +} + +export function timedLogger(label: string, fn: () => R): R { + console.time(label); + + try { + const result = fn(); + + if (isPromise(result)) { + result.finally(() => { + console.timeEnd(label); + }); + } + + return result; + } catch (e) { + console.timeEnd(label); + throw e; + } +}