feat: add KWMv2 support
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing

This commit is contained in:
鲁树人 2023-06-17 14:29:50 +01:00
parent c7846ec15b
commit 1a8e7db9be
8 changed files with 112 additions and 20 deletions

25
src/crypto/pasreKuwo.ts Normal file
View File

@ -0,0 +1,25 @@
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
import { strlen } from './strlen';
export interface KuwoHeader {
rid: string; // uint64
encVersion: 1 | 2; // uint32
quality: string;
}
export function parseKuwoHeader(view: DataView): KuwoHeader | null {
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10);
if (bytesToUTF8String(magic) !== 'yeelion-kuwo-tme') {
return null; // not kuwo-encrypted file
}
const qualityBytes = new Uint8Array(view.buffer.slice(view.byteOffset + 0x30, view.byteOffset + 0x40));
const qualityLen = strlen(qualityBytes);
const quality = bytesToUTF8String(qualityBytes.slice(0, qualityLen));
return {
encVersion: view.getUint32(0x10, true) as 1 | 2,
rid: view.getUint32(0x18, true).toString(),
quality,
};
}

9
src/crypto/strlen.ts Normal file
View File

@ -0,0 +1,9 @@
export function strlen(data: Uint8Array): number {
const n = data.byteLength;
for (let i = 0; i < n; i++) {
if (data[i] === 0) {
return i;
}
}
return n;
}

View File

@ -1,14 +1,25 @@
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
import type { CryptoBase } from '../CryptoBase';
import { KWM_KEY } from './kwm.key';
import { DecryptCommandOptions } from '~/decrypt-worker/types';
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto';
import { fetchParakeet } from '@jixun/libparakeet';
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder';
// v1 only
export class KWMCrypto implements CryptoBase {
cryptoName = 'KWM';
checkByDecryptHeader = true;
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
return transformBlob(buffer, (p) => p.make.KuwoKWM(KWM_KEY));
async decrypt(buffer: ArrayBuffer, opts: DecryptCommandOptions): Promise<Blob> {
const kwm2key = opts.kwm2key ?? '';
const parakeet = await fetchParakeet();
const keyCrypto = makeQMCv2KeyCrypto(parakeet);
return transformBlob(buffer, (p) => p.make.KuwoKWMv2(KWM_KEY, stringToUTF8Bytes(kwm2key), keyCrypto), {
cleanup: () => keyCrypto.delete(),
parakeet,
});
}
public static make() {

View File

@ -1,5 +1,6 @@
export interface DecryptCommandOptions {
qmc2Key?: string;
kwm2key?: string;
}
export interface DecryptCommandPayload {

View File

@ -1,11 +1,12 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '~/store';
import { decryptionQueue } from '~/decrypt-worker/client';
import type { DecryptionResult } from '~/decrypt-worker/constants';
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
import { decryptionQueue } from '~/decrypt-worker/client';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
import { selectDecryptOptionByFile } from '../settings/settingsSelector';
import { selectQMCv2KeyByFileName, selectKWMv2Key } from '../settings/settingsSelector';
export enum ProcessState {
QUEUED = 'QUEUED',
@ -63,7 +64,18 @@ export const processFile = createAsyncThunk<
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
};
const options = selectDecryptOptionByFile(state, file.fileName);
const fileHeader = await fetch(file.raw, {
headers: {
Range: 'bytes=0-1023',
},
})
.then((r) => r.blob())
.then((r) => r.arrayBuffer());
const options: DecryptCommandOptions = {
qmc2Key: selectQMCv2KeyByFileName(state, file.fileName),
kwm2key: selectKWMv2Key(state, new DataView(fileHeader)),
};
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
});

View File

@ -51,6 +51,13 @@ export interface StagingKWMv2Key {
export type ProductionKWMv2Keys = Record<string /* `${rid}-${quality}` */, string /* ekey */>;
export const parseKwm2ProductionKey = (key: string): null | { rid: string; quality: string } => {
const m = key.match(/^(\d+)-(\w+)$/);
if (!m) return null;
const [_, rid, quality] = m;
return { rid, quality };
};
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality}`;
export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
export const kwm2ProductionToStaging = (
@ -59,9 +66,8 @@ export const kwm2ProductionToStaging = (
): null | StagingKWMv2Key => {
if (typeof value !== 'string') return null;
const m = key.match(/^(\d+)-(\w+)$/);
if (!m) return null;
const parsed = parseKwm2ProductionKey(key);
if (!parsed) return null;
const [_, rid, quality] = m;
return { id: nanoid(), rid, quality, ekey: value };
return { id: nanoid(), rid: parsed.rid, quality: parsed.quality, ekey: value };
};

View File

@ -5,6 +5,7 @@ import type { AppStore } from '~/store';
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
import { enumObject } from '~/util/objects';
import { getLogger } from '~/util/logUtils';
import { parseKwm2ProductionKey } from './keyFormats';
const DEFAULT_STORAGE_KEY = 'um-react-settings';
@ -22,6 +23,16 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings {
draft.qmc2.allowFuzzyNameSearch = allowFuzzyNameSearch;
}
}
if (settings?.kwm2) {
const { keys } = settings.kwm2;
for (const [k, v] of enumObject(keys)) {
if (typeof v === 'string' && parseKwm2ProductionKey(k)) {
draft.kwm2.keys[k] = v;
}
}
}
});
}

View File

@ -1,30 +1,47 @@
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
import { parseKuwoHeader } from '~/crypto/pasreKuwo';
import type { RootState } from '~/store';
import { closestByLevenshtein } from '~/util/levenshtein';
import { hasOwn } from '~/util/objects';
import { kwm2StagingToProductionKey } from './keyFormats';
export const selectStagingQMCv2Settings = (state: RootState) => state.settings.staging.qmc2;
export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2;
export const selectStagingKWMv2Keys = (state: RootState) => state.settings.staging.kwm2.keys;
export const selectFinalKWMv2Keys = (state: RootState) => state.settings.production.kwm2.keys;
export const selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => {
export const selectQMCv2KeyByFileName = (state: RootState, name: string): string | undefined => {
const normalizedName = name.normalize();
let qmc2Key: string | undefined;
const { keys: qmc2Keys, allowFuzzyNameSearch } = selectFinalQMCv2Settings(state);
if (hasOwn(qmc2Keys, normalizedName)) {
qmc2Key = qmc2Keys[normalizedName];
let ekey: string | undefined;
const { keys, allowFuzzyNameSearch } = selectFinalQMCv2Settings(state);
if (hasOwn(keys, normalizedName)) {
ekey = keys[normalizedName];
} else if (allowFuzzyNameSearch) {
const qmc2KeyStoreNames = Object.keys(qmc2Keys);
const qmc2KeyStoreNames = Object.keys(keys);
if (qmc2KeyStoreNames.length > 0) {
const closestName = closestByLevenshtein(normalizedName, qmc2KeyStoreNames);
console.debug('qmc2: key db could not find %o, using closest %o instead.', normalizedName, closestName);
qmc2Key = qmc2Keys[closestName];
ekey = keys[closestName];
}
}
return {
qmc2Key,
return ekey;
};
export const selectKWMv2Key = (state: RootState, headerView: DataView): string | undefined => {
const hdr = parseKuwoHeader(headerView);
if (!hdr) {
return;
}
const keys = selectFinalKWMv2Keys(state);
const lookupKey = kwm2StagingToProductionKey({ id: '', ekey: '', quality: hdr.quality, rid: hdr.rid });
let ekey: string | undefined;
if (hasOwn(keys, lookupKey)) {
ekey = keys[lookupKey];
}
return ekey;
};