feat: print performance logs to console.
This commit is contained in:
parent
c4e3999546
commit
4cfc672646
@ -1,10 +1,10 @@
|
|||||||
export interface CryptoBase {
|
export interface CryptoBase {
|
||||||
/**
|
cryptoName: string;
|
||||||
* When returning false, a successful decryption should be checked by its decrypted content instead.
|
checkByDecryptHeader: boolean;
|
||||||
*/
|
decryptTargetAudio: boolean;
|
||||||
hasSignature(): boolean;
|
|
||||||
isSupported(blob: Blob): Promise<boolean>;
|
checkBySignature?: (buffer: ArrayBuffer) => Promise<boolean>;
|
||||||
decrypt(blob: Blob): Promise<Blob>;
|
decrypt(buffer: ArrayBuffer, blob: Blob): Promise<Blob | ArrayBuffer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CryptoFactory = () => CryptoBase;
|
export type CryptoFactory = () => CryptoBase;
|
||||||
|
19
src/decrypt-worker/crypto/CryptoFactory.ts
Normal file
19
src/decrypt-worker/crypto/CryptoFactory.ts
Normal file
@ -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(),
|
||||||
|
];
|
@ -3,15 +3,11 @@ import type { CryptoBase } from '../CryptoBase';
|
|||||||
import key from './qmc_v1.key.ts';
|
import key from './qmc_v1.key.ts';
|
||||||
|
|
||||||
export class QMC1Crypto implements CryptoBase {
|
export class QMC1Crypto implements CryptoBase {
|
||||||
hasSignature(): boolean {
|
cryptoName = 'QMCv1';
|
||||||
return false;
|
checkByDecryptHeader = true;
|
||||||
}
|
decryptTargetAudio = true;
|
||||||
|
|
||||||
async isSupported(): Promise<boolean> {
|
async decrypt(_buffer: ArrayBuffer, blob: Blob): Promise<Blob> {
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(blob: Blob): Promise<Blob> {
|
|
||||||
return transformBlob(blob, (p) => p.make.QMCv1(key));
|
return transformBlob(blob, (p) => p.make.QMCv1(key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,15 +3,11 @@ import type { CryptoBase } from '../CryptoBase';
|
|||||||
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts';
|
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts';
|
||||||
|
|
||||||
export class QMC2Crypto implements CryptoBase {
|
export class QMC2Crypto implements CryptoBase {
|
||||||
hasSignature(): boolean {
|
cryptoName = 'QMCv2';
|
||||||
return false;
|
checkByDecryptHeader = false;
|
||||||
}
|
decryptTargetAudio = true;
|
||||||
|
|
||||||
async isSupported(): Promise<boolean> {
|
async decrypt(_buffer: ArrayBuffer, blob: Blob): Promise<Blob> {
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(blob: Blob): Promise<Blob> {
|
|
||||||
return transformBlob(blob, (p) => p.make.QMCv2(p.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2)));
|
return transformBlob(blob, (p) => p.make.QMCv2(p.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,8 +11,9 @@
|
|||||||
|
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
import type { CryptoBase } from '../CryptoBase';
|
||||||
|
|
||||||
const XIAMI_FILE_MAGIC = new Uint8Array('ifmt'.split('').map((x) => x.charCodeAt(0)));
|
// little endian
|
||||||
const XIAMI_EXPECTED_PADDING = new Uint8Array([0xfe, 0xfe, 0xfe, 0xfe]);
|
const XIAMI_FILE_MAGIC = 0x746d6669;
|
||||||
|
const XIAMI_EXPECTED_PADDING = 0xfefefefe;
|
||||||
|
|
||||||
const u8Sub = (a: number, b: number) => {
|
const u8Sub = (a: number, b: number) => {
|
||||||
if (a > b) {
|
if (a > b) {
|
||||||
@ -23,29 +24,25 @@ const u8Sub = (a: number, b: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class XiamiCrypto implements CryptoBase {
|
export class XiamiCrypto implements CryptoBase {
|
||||||
hasSignature(): boolean {
|
cryptoName = 'Xiami';
|
||||||
return true;
|
checkByDecryptHeader = false;
|
||||||
|
decryptTargetAudio = true;
|
||||||
|
|
||||||
|
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 isSupported(blob: Blob): Promise<boolean> {
|
async decrypt(src: ArrayBuffer): Promise<ArrayBuffer> {
|
||||||
const headerBuffer = await blob.slice(0, 0x10).arrayBuffer();
|
const headerBuffer = src.slice(0, 0x10);
|
||||||
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<Blob> {
|
|
||||||
const headerBuffer = await blob.slice(0, 0x10).arrayBuffer();
|
|
||||||
const header = new Uint8Array(headerBuffer);
|
const header = new Uint8Array(headerBuffer);
|
||||||
const key = u8Sub(header[0x0f], 1);
|
const key = u8Sub(header[0x0f], 1);
|
||||||
const plainTextSize = header[0x0c] | (header[0x0d] << 8) | (header[0x0e] << 16);
|
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--) {
|
for (let i = decrypted.byteLength - 1; i >= plainTextSize; i--) {
|
||||||
decrypted[i] = u8Sub(key, decrypted[i]);
|
decrypted[i] = u8Sub(key, decrypted[i]);
|
||||||
}
|
}
|
||||||
return new Blob([decrypted]);
|
return decrypted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2
src/decrypt-worker/util/buffer.ts
Normal file
2
src/decrypt-worker/util/buffer.ts
Normal file
@ -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]));
|
@ -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');
|
|
||||||
};
|
|
@ -3,7 +3,7 @@ import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
|||||||
|
|
||||||
import { getSDKVersion } from '@jixun/libparakeet';
|
import { getSDKVersion } from '@jixun/libparakeet';
|
||||||
|
|
||||||
import { workerDecryptHandler } from './worker-handler/decrypt';
|
import { workerDecryptHandler } from './worker/handler/decrypt';
|
||||||
|
|
||||||
const bus = new WorkerServerBus();
|
const bus = new WorkerServerBus();
|
||||||
onmessage = bus.onmessage;
|
onmessage = bus.onmessage;
|
||||||
|
93
src/decrypt-worker/worker/handler/decrypt.ts
Normal file
93
src/decrypt-worker/worker/handler/decrypt.ts
Normal file
@ -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<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.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<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.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);
|
||||||
|
}
|
||||||
|
};
|
@ -8,6 +8,6 @@ export class DecryptionQueue extends ConcurrentQueue<{ id: string; blobURI: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handler(item: { id: string; blobURI: string }): Promise<DecryptionResult> {
|
async handler(item: { id: string; blobURI: string }): Promise<DecryptionResult> {
|
||||||
return this.workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, item.blobURI);
|
return this.workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,9 @@ test('should be able to forward request to worker client bus', async () => {
|
|||||||
const queue = new DecryptionQueue(bus, 1);
|
const queue = new DecryptionQueue(bus, 1);
|
||||||
await expect(queue.add({ id: 'file://1', blobURI: 'blob://mock-file' })).resolves.toEqual({
|
await expect(queue.add({ id: 'file://1', blobURI: 'blob://mock-file' })).resolves.toEqual({
|
||||||
actionName: DECRYPTION_WORKER_ACTION_NAME.DECRYPT,
|
actionName: DECRYPTION_WORKER_ACTION_NAME.DECRYPT,
|
||||||
payload: 'blob://mock-file',
|
payload: {
|
||||||
|
blobURI: 'blob://mock-file',
|
||||||
|
id: 'file://1',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
22
src/util/timedLogger.ts
Normal file
22
src/util/timedLogger.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
function isPromise<T = unknown>(p: unknown): p is Promise<T> {
|
||||||
|
return !!p && typeof p === 'object' && 'then' in p && 'catch' in p && 'finally' in p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timedLogger<R = unknown>(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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user