From 32dbed45cbdeaed6415d8c01402997a699d89bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E6=A0=91=E4=BA=BA?= Date: Sat, 17 Jun 2023 14:29:50 +0100 Subject: [PATCH] feat: add KWMv2 support --- src/crypto/pasreKuwo.ts | 25 ++++++++++++ src/crypto/strlen.ts | 9 +++++ src/decrypt-worker/crypto/kwm/kwm.ts | 15 ++++++- src/decrypt-worker/types.ts | 1 + src/features/file-listing/fileListingSlice.ts | 18 +++++++-- src/features/settings/keyFormats.ts | 14 +++++-- src/features/settings/persistSettings.ts | 11 ++++++ src/features/settings/settingsSelector.ts | 39 +++++++++++++------ 8 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 src/crypto/pasreKuwo.ts create mode 100644 src/crypto/strlen.ts diff --git a/src/crypto/pasreKuwo.ts b/src/crypto/pasreKuwo.ts new file mode 100644 index 0000000..db6cf90 --- /dev/null +++ b/src/crypto/pasreKuwo.ts @@ -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, + }; +} diff --git a/src/crypto/strlen.ts b/src/crypto/strlen.ts new file mode 100644 index 0000000..b4d67df --- /dev/null +++ b/src/crypto/strlen.ts @@ -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; +} diff --git a/src/decrypt-worker/crypto/kwm/kwm.ts b/src/decrypt-worker/crypto/kwm/kwm.ts index 063002d..f4c08c4 100644 --- a/src/decrypt-worker/crypto/kwm/kwm.ts +++ b/src/decrypt-worker/crypto/kwm/kwm.ts @@ -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 { - return transformBlob(buffer, (p) => p.make.KuwoKWM(KWM_KEY)); + async decrypt(buffer: ArrayBuffer, opts: DecryptCommandOptions): Promise { + 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() { diff --git a/src/decrypt-worker/types.ts b/src/decrypt-worker/types.ts index 79ea815..232264f 100644 --- a/src/decrypt-worker/types.ts +++ b/src/decrypt-worker/types.ts @@ -1,5 +1,6 @@ export interface DecryptCommandOptions { qmc2Key?: string; + kwm2key?: string; } export interface DecryptCommandPayload { diff --git a/src/features/file-listing/fileListingSlice.ts b/src/features/file-listing/fileListingSlice.ts index d1e575b..533f7ea 100644 --- a/src/features/file-listing/fileListingSlice.ts +++ b/src/features/file-listing/fileListingSlice.ts @@ -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); }); diff --git a/src/features/settings/keyFormats.ts b/src/features/settings/keyFormats.ts index 65ce879..a9647d6 100644 --- a/src/features/settings/keyFormats.ts +++ b/src/features/settings/keyFormats.ts @@ -51,6 +51,13 @@ export interface StagingKWMv2Key { export type ProductionKWMv2Keys = Record; +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 }; }; diff --git a/src/features/settings/persistSettings.ts b/src/features/settings/persistSettings.ts index c77d543..c16163c 100644 --- a/src/features/settings/persistSettings.ts +++ b/src/features/settings/persistSettings.ts @@ -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; + } + } + } }); } diff --git a/src/features/settings/settingsSelector.ts b/src/features/settings/settingsSelector.ts index 3798f41..42d61b7 100644 --- a/src/features/settings/settingsSelector.ts +++ b/src/features/settings/settingsSelector.ts @@ -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; };