mirror of
https://git.unlock-music.dev/um/um-react.git
synced 2024-11-23 23:22:18 +00:00
refactor: begin migrate to @unlock-music/crypto
This commit is contained in:
parent
c5bc436ab2
commit
8b416f8055
@ -24,7 +24,7 @@
|
|||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@reduxjs/toolkit": "^2.0.1",
|
"@reduxjs/toolkit": "^2.0.1",
|
||||||
"@um/libparakeet": "0.4.5",
|
"@um/libparakeet": "0.4.5",
|
||||||
"@unlock-music/crypto": "0.0.0-alpha.6",
|
"@unlock-music/crypto": "0.0.0-alpha.10",
|
||||||
"framer-motion": "^10.16.16",
|
"framer-motion": "^10.16.16",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
"radash": "^11.0.0",
|
"radash": "^11.0.0",
|
||||||
|
@ -42,8 +42,8 @@ importers:
|
|||||||
specifier: 0.4.5
|
specifier: 0.4.5
|
||||||
version: 0.4.5
|
version: 0.4.5
|
||||||
'@unlock-music/crypto':
|
'@unlock-music/crypto':
|
||||||
specifier: 0.0.0-alpha.6
|
specifier: 0.0.0-alpha.10
|
||||||
version: 0.0.0-alpha.6
|
version: 0.0.0-alpha.10
|
||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^10.16.16
|
specifier: ^10.16.16
|
||||||
version: 10.16.16(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
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':
|
'@ungap/structured-clone@1.2.0':
|
||||||
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
||||||
|
|
||||||
'@unlock-music/crypto@0.0.0-alpha.6':
|
'@unlock-music/crypto@0.0.0-alpha.10':
|
||||||
resolution: {integrity: sha512-hv1oTXPzsNmqrP5dmjkLa4rfZtd4U/Cu1Bake71QEZhbY1WCbgEX4haCAcXXd6DMGNg/Hv1JXL2TcXNUaqIiFA==, tarball: https://git.unlock-music.dev/api/packages/um/npm/%40unlock-music%2Fcrypto/-/0.0.0-alpha.6/crypto-0.0.0-alpha.6.tgz}
|
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}
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.2.1':
|
'@vitejs/plugin-react@4.2.1':
|
||||||
resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==}
|
resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==}
|
||||||
@ -6118,7 +6118,7 @@ snapshots:
|
|||||||
|
|
||||||
'@ungap/structured-clone@1.2.0': {}
|
'@ungap/structured-clone@1.2.0': {}
|
||||||
|
|
||||||
'@unlock-music/crypto@0.0.0-alpha.6': {}
|
'@unlock-music/crypto@0.0.0-alpha.10': {}
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.2.1(vite@5.0.10(@types/node@20.10.5)(sass@1.69.5)(terser@5.27.0))':
|
'@vitejs/plugin-react@4.2.1(vite@5.0.10(@types/node@20.10.5)(sass@1.69.5)(terser@5.27.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
75
src/decrypt-worker/Deciphers.ts
Normal file
75
src/decrypt-worker/Deciphers.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { NetEaseCloudMusicDecipher } from '~/decrypt-worker/decipher/NetEaseCloudMusic.ts';
|
||||||
|
import { TransparentDecipher } from './decipher/Transparent.ts';
|
||||||
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types.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)
|
||||||
|
// 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)
|
||||||
|
TransparentDecipher.make,
|
||||||
|
];
|
@ -1,17 +0,0 @@
|
|||||||
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;
|
|
@ -1,55 +0,0 @@
|
|||||||
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,
|
|
||||||
];
|
|
@ -1,14 +1,14 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
import type { DecipherInstance } from '~/decrypt-worker/Deciphers.ts';
|
||||||
import { KGM_SLOT_1_KEY, KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE, KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE } from './kgm_pc.key';
|
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 {
|
export class KGMCrypto implements DecipherInstance {
|
||||||
cryptoName = 'KGM/PC';
|
cryptoName = 'KGM/PC';
|
||||||
checkByDecryptHeader = true;
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||||
return transformBlob(buffer, (p) =>
|
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)
|
p.make.KugouKGM(KGM_SLOT_1_KEY, KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE, KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
import type { DecipherInstance } from '~/decrypt-worker/Deciphers.ts';
|
||||||
import { KWM_KEY } from './kwm.key';
|
import { KWM_KEY } from './kwm.key';
|
||||||
import { DecryptCommandOptions } from '~/decrypt-worker/types';
|
import { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||||
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto';
|
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto';
|
||||||
@ -7,7 +7,7 @@ import { fetchParakeet } from '@um/libparakeet';
|
|||||||
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder';
|
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder';
|
||||||
|
|
||||||
// v1 only
|
// v1 only
|
||||||
export class KWMCrypto implements CryptoBase {
|
export class KWMCrypto implements DecipherInstance {
|
||||||
cryptoName = 'KWM';
|
cryptoName = 'KWM';
|
||||||
checkByDecryptHeader = true;
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
import type { DecipherInstance } from '~/decrypt-worker/Deciphers.ts';
|
||||||
|
|
||||||
export class MiguCrypto implements CryptoBase {
|
export class MiguCrypto implements DecipherInstance {
|
||||||
cryptoName = 'Migu3D/Keyless';
|
cryptoName = 'Migu3D/Keyless';
|
||||||
checkByDecryptHeader = true;
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +1,8 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
import type { DecipherInstance } from '~/decrypt-worker/Deciphers.ts';
|
||||||
import key from './qmc_v1.key.ts';
|
import key from './qmc_v1.key.ts';
|
||||||
|
|
||||||
export class QMC1Crypto implements CryptoBase {
|
export class QMC1Crypto implements DecipherInstance {
|
||||||
cryptoName = 'QMC/v1';
|
cryptoName = 'QMC/v1';
|
||||||
checkByDecryptHeader = true;
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
import type { DecipherInstance } from '~/decrypt-worker/Deciphers.ts';
|
||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||||
import { fetchParakeet } from '@um/libparakeet';
|
import { fetchParakeet } from '@um/libparakeet';
|
||||||
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
|
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
|
||||||
import { makeQMCv2FooterParser, makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
|
import { makeQMCv2FooterParser, makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
|
||||||
|
|
||||||
export class QMC2Crypto implements CryptoBase {
|
export class QMC2Crypto implements DecipherInstance {
|
||||||
cryptoName = 'QMC/v2';
|
cryptoName = 'QMC/v2';
|
||||||
checkByDecryptHeader = false;
|
checkByDecryptHeader = false;
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ export class QMC2Crypto implements CryptoBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class QMC2CryptoWithKey implements CryptoBase {
|
export class QMC2CryptoWithKey implements DecipherInstance {
|
||||||
cryptoName = 'QMC/v2 (key)';
|
cryptoName = 'QMC/v2 (key)';
|
||||||
checkByDecryptHeader = true;
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
import type { DecipherInstance } from '~/decrypt-worker/Deciphers.ts';
|
||||||
import { DecryptCommandOptions } from '~/decrypt-worker/types';
|
import { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||||
|
|
||||||
export class QingTingFM$Device implements CryptoBase {
|
export class QingTingFM$Device implements DecipherInstance {
|
||||||
cryptoName = 'QingTing FM/Device ID';
|
cryptoName = 'QingTing FM/Device ID';
|
||||||
checkByDecryptHeader = false;
|
checkByDecryptHeader = false;
|
||||||
|
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -9,7 +9,7 @@
|
|||||||
// 0x10 Plaintext data
|
// 0x10 Plaintext data
|
||||||
// ???? Encrypted data
|
// ???? Encrypted data
|
||||||
|
|
||||||
import type { CryptoBase } from '../CryptoBase';
|
import type { DecipherInstance } from '~/decrypt-worker/Deciphers.ts';
|
||||||
|
|
||||||
// little endian
|
// little endian
|
||||||
const XIAMI_FILE_MAGIC = 0x746d6669;
|
const XIAMI_FILE_MAGIC = 0x746d6669;
|
||||||
@ -23,7 +23,7 @@ const u8Sub = (a: number, b: number) => {
|
|||||||
return a + 0x100 - b;
|
return a + 0x100 - b;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class XiamiCrypto implements CryptoBase {
|
export class XiamiCrypto implements DecipherInstance {
|
||||||
cryptoName = 'Xiami';
|
cryptoName = 'Xiami';
|
||||||
checkByDecryptHeader = false;
|
checkByDecryptHeader = false;
|
||||||
|
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||||
import type { CryptoBase } from '../CryptoBase.js';
|
import type { DecipherInstance } from '~/decrypt-worker/Deciphers.ts';
|
||||||
import { XimalayaAndroidKey, XimalayaX2MKey, XimalayaX3MKey } from './xmly_android.key.js';
|
import { XimalayaAndroidKey, XimalayaX2MKey, XimalayaX3MKey } from './xmly_android.key.js';
|
||||||
|
|
||||||
export class XimalayaAndroidCrypto implements CryptoBase {
|
export class XimalayaAndroidCrypto implements DecipherInstance {
|
||||||
cryptoName = 'Ximalaya/Android';
|
cryptoName = 'Ximalaya/Android';
|
||||||
checkByDecryptHeader = true;
|
checkByDecryptHeader = true;
|
||||||
|
|
||||||
constructor(private key: XimalayaAndroidKey) {}
|
constructor(private key: XimalayaAndroidKey) {}
|
||||||
|
|
||||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||||
|
41
src/decrypt-worker/decipher/NetEaseCloudMusic.ts
Normal file
41
src/decrypt-worker/decipher/NetEaseCloudMusic.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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 {
|
||||||
|
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: ArrayBuffer): Promise<DecipherResult | DecipherOK> {
|
||||||
|
return withWasmClass(new NCMFile(), async (ncm): Promise<DecipherOK> => {
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
this.tryInit(ncm, data);
|
||||||
|
|
||||||
|
const audioBuffer = data.subarray(ncm.audioOffset);
|
||||||
|
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||||
|
ncm.decrypt(block, offset);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: Status.OK,
|
||||||
|
cipherName: this.cipherName,
|
||||||
|
data: audioBuffer,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static make() {
|
||||||
|
return new NetEaseCloudMusicDecipher();
|
||||||
|
}
|
||||||
|
}
|
18
src/decrypt-worker/decipher/Transparent.ts
Normal file
18
src/decrypt-worker/decipher/Transparent.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
14
src/decrypt-worker/util/audioType.ts
Normal file
14
src/decrypt-worker/util/audioType.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { detectAudioType } from '@unlock-music/crypto';
|
||||||
|
|
||||||
|
export async function detectAudioExtension(buffer: Uint8Array): Promise<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;
|
||||||
|
}
|
17
src/decrypt-worker/util/wasmClass.ts
Normal file
17
src/decrypt-worker/util/wasmClass.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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,22 +1,19 @@
|
|||||||
import { Parakeet, fetchParakeet } from '@um/libparakeet';
|
|
||||||
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
|
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
|
||||||
import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types';
|
import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types';
|
||||||
import { allCryptoFactories } from '../../crypto/CryptoFactory';
|
import { allCryptoFactories } from '../../Deciphers.ts';
|
||||||
import { toArrayBuffer, toBlob } from '~/decrypt-worker/util/buffer';
|
import { toBlob } from '~/decrypt-worker/util/buffer';
|
||||||
import { CryptoBase, CryptoFactory } from '~/decrypt-worker/crypto/CryptoBase';
|
import { DecipherFactory, DecipherInstance, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError';
|
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError';
|
||||||
import { ready as umCryptoReady } from '@unlock-music/crypto';
|
import { ready as umCryptoReady } from '@unlock-music/crypto';
|
||||||
|
import { go } from '~/util/go.ts';
|
||||||
// Use first 4MiB of the file to perform check.
|
import { detectAudioExtension } from '~/decrypt-worker/util/audioType.ts';
|
||||||
const TEST_FILE_HEADER_LEN = 4 * 1024 * 1024;
|
|
||||||
|
|
||||||
class DecryptCommandHandler {
|
class DecryptCommandHandler {
|
||||||
private label: string;
|
private readonly label: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
label: string,
|
label: string,
|
||||||
private parakeet: Parakeet,
|
private buffer: Uint8Array,
|
||||||
private buffer: ArrayBuffer,
|
|
||||||
private options: DecryptCommandOptions,
|
private options: DecryptCommandOptions,
|
||||||
) {
|
) {
|
||||||
this.label = `DecryptCommandHandler(${label})`;
|
this.label = `DecryptCommandHandler(${label})`;
|
||||||
@ -26,82 +23,68 @@ class DecryptCommandHandler {
|
|||||||
return timedLogger(`${this.label}: ${label}`, fn);
|
return timedLogger(`${this.label}: ${label}`, fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
async decrypt(factories: CryptoFactory[]) {
|
async decrypt(decipherFactories: DecipherFactory[]) {
|
||||||
for (const factory of factories) {
|
const errors: string[] = [];
|
||||||
const decryptor = factory();
|
for (const factory of decipherFactories) {
|
||||||
|
const decipher = factory();
|
||||||
|
|
||||||
try {
|
const [result, error] = await go(this.tryDecryptWith(decipher));
|
||||||
const result = await this.tryDecryptFile(decryptor);
|
if (!error) {
|
||||||
if (result === null) {
|
if (result) {
|
||||||
continue;
|
return result;
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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('could not decrypt file: no working decryptor found');
|
throw new UnsupportedSourceFile(errors.join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async tryDecryptFile(crypto: CryptoBase) {
|
async tryDecryptWith(decipher: DecipherInstance) {
|
||||||
if (crypto.checkBySignature && !(await crypto.checkBySignature(this.buffer, this.options))) {
|
const result = await this.log(`decrypt ${decipher.cipherName}`, async () =>
|
||||||
return null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// Check if we had a successful decryption
|
||||||
let audioExt = crypto.overrideExtension ?? (await this.detectAudioExtension(decrypted));
|
let audioExt = result.overrideExtension || (await detectAudioExtension(result.data));
|
||||||
if (crypto.checkByDecryptHeader && audioExt === 'bin') {
|
if (!result.overrideExtension && audioExt === 'bin') {
|
||||||
return null;
|
throw new UnsupportedSourceFile('unable to produce valid audio file');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert mp4 to m4a
|
||||||
if (audioExt.toLowerCase() === 'mp4') {
|
if (audioExt.toLowerCase() === 'mp4') {
|
||||||
audioExt = 'm4a';
|
audioExt = 'm4a';
|
||||||
}
|
}
|
||||||
|
|
||||||
return { decrypted: URL.createObjectURL(toBlob(decrypted)), ext: audioExt };
|
return { decrypted: URL.createObjectURL(toBlob(result.data)), 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) => {
|
export const workerDecryptHandler = async ({ id, blobURI, options }: DecryptCommandPayload) => {
|
||||||
|
await umCryptoReady;
|
||||||
const label = `decrypt(${id})`;
|
const label = `decrypt(${id})`;
|
||||||
return withTimeGroupedLogs(label, async () => {
|
return withTimeGroupedLogs(label, async () => {
|
||||||
await umCryptoReady;
|
const buffer = await fetch(blobURI).then((r) => r.arrayBuffer());
|
||||||
const parakeet = await timedLogger(`${label}/init`, fetchParakeet);
|
const handler = new DecryptCommandHandler(id, new Uint8Array(buffer), options);
|
||||||
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);
|
return handler.decrypt(allCryptoFactories);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { chakra, Box, Button, Collapse, Text, useDisclosure } from '@chakra-ui/react';
|
import { Box, Button, chakra, 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,11 +18,12 @@ 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>
|
||||||
|
7
src/util/go.ts
Normal file
7
src/util/go.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user