diff --git a/src/SelectFile.tsx b/src/SelectFile.tsx index fca2afc..df28f78 100644 --- a/src/SelectFile.tsx +++ b/src/SelectFile.tsx @@ -24,7 +24,7 @@ export function SelectFile() { fileName, }) ); - dispatch(processFile(fileId)); + dispatch(processFile({ fileId })); } } diff --git a/src/decrypt-worker/constants.ts b/src/decrypt-worker/constants.ts index 0cbfbdf..e5caa2f 100644 --- a/src/decrypt-worker/constants.ts +++ b/src/decrypt-worker/constants.ts @@ -1,3 +1,7 @@ export enum DECRYPTION_WORKER_ACTION_NAME { DECRYPT = 'DECRYPT', } + +export interface DecryptionResult { + decrypted: string; // blob uri +} diff --git a/src/decrypt-worker/crypto/CryptoBase.ts b/src/decrypt-worker/crypto/CryptoBase.ts new file mode 100644 index 0000000..3fb5636 --- /dev/null +++ b/src/decrypt-worker/crypto/CryptoBase.ts @@ -0,0 +1,6 @@ +export interface CryptoBase { + isSupported(blob: Blob): Promise; + decrypt(blob: Blob): Promise; +} + +export type CryptoFactory = () => CryptoBase; diff --git a/src/decrypt-worker/crypto/xiami/xiami.ts b/src/decrypt-worker/crypto/xiami/xiami.ts new file mode 100644 index 0000000..6cf1b38 --- /dev/null +++ b/src/decrypt-worker/crypto/xiami/xiami.ts @@ -0,0 +1,47 @@ +// Xiami file header +// offset description +// 0x00 "ifmt" +// 0x04 Format name, e.g. "FLAC". +// 0x08 0xfe, 0xfe, 0xfe, 0xfe +// 0x0C (3 bytes) Little-endian, size of data to copy without modification. +// e.g. [ 8a 19 00 ] = 6538 bytes of plaintext data. +// 0x0F (1 byte) File key, applied to +// 0x10 Plaintext data +// ???? Encrypted data + +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]); + +const u8Sub = (a: number, b: number) => { + if (a > b) { + return a - b; + } + + return a + 0xff - b; +}; + +export class XiamiCrypto implements CryptoBase { + 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(); + 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()); + for (let i = decrypted.byteLength - 1; i >= plainTextSize; i--) { + decrypted[i] = u8Sub(key, decrypted[i]); + } + return new Blob([decrypted]); + } +} diff --git a/src/decrypt-worker/worker.ts b/src/decrypt-worker/worker.ts index 932e38a..3b03ae6 100644 --- a/src/decrypt-worker/worker.ts +++ b/src/decrypt-worker/worker.ts @@ -1,12 +1,27 @@ import { WorkerServerBus } from '~/util/WorkerEventBus'; import { DECRYPTION_WORKER_ACTION_NAME } from './constants'; +import type { CryptoFactory } from './crypto/CryptoBase'; +import { XiamiCrypto } from './crypto/xiami/xiami'; + const bus = new WorkerServerBus(); onmessage = bus.onmessage; +const decryptorFactories: CryptoFactory[] = [ + // Xiami (*.xm) + () => new XiamiCrypto(), +]; + bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, async (blobURI) => { - const blob = await fetch(blobURI).then((r) => r.arrayBuffer()); - // TODO: Implement decryptor for blob received here. - console.log(blob); - return { hello: true }; + const blob = await fetch(blobURI).then((r) => r.blob()); + + for (const factory of decryptorFactories) { + const decryptor = factory(); + if (await decryptor.isSupported(blob)) { + const decrypted = await decryptor.decrypt(blob); + return { decrypted: URL.createObjectURL(decrypted) }; + } + } + + throw new Error('could not decrypt file: no working decryptor found'); }); diff --git a/src/features/file-listing/fileListingSlice.ts b/src/features/file-listing/fileListingSlice.ts index 88b6a16..fd44715 100644 --- a/src/features/file-listing/fileListingSlice.ts +++ b/src/features/file-listing/fileListingSlice.ts @@ -3,6 +3,8 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from '~/store'; import { decryptionQueue } from '~/decrypt-worker/client'; +import type { DecryptionResult } from '~/decrypt-worker/constants'; + export enum ProcessState { UNTOUCHED = 'UNTOUCHED', COMPLETE = 'COMPLETE', @@ -40,10 +42,15 @@ const initialState: FileListingState = { displayMode: ListingMode.LIST, }; -export const processFile = createAsyncThunk('fileListing/processFile', async (fileId: string, thunkAPI) => { +export const processFile = createAsyncThunk< + DecryptionResult, + { fileId: string }, + { rejectValue: { message: string; stack?: string } } +>('fileListing/processFile', async ({ fileId }, thunkAPI) => { const file = selectFiles(thunkAPI.getState() as RootState)[fileId]; if (!file) { - return thunkAPI.rejectWithValue('ERROR: File not found'); + const { message, stack } = new Error('ERROR: File not found'); + return thunkAPI.rejectWithValue({ message, stack }); } return decryptionQueue.add({ id: fileId, blobURI: file.raw }); @@ -76,6 +83,29 @@ export const fileListingSlice = createSlice({ } }, }, + extraReducers(builder) { + builder.addCase(processFile.fulfilled, (state, action) => { + const { fileId } = action.meta.arg; + const file = state.files[fileId]; + if (!file) return; + + file.state = ProcessState.COMPLETE; + file.decrypted = action.payload.decrypted; + // TODO: populate file metadata + }); + + builder.addCase(processFile.rejected, (state, action) => { + const { fileId } = action.meta.arg; + const file = state.files[fileId]; + if (!file) return; + + if (action.payload) { + file.errorMessage = action.payload.message; + } else { + file.errorMessage = action.error.message || 'unknown error'; + } + }); + }, }); export const { addNewFile, setDecryptedContent } = fileListingSlice.actions;