From c71078f5da92d11c561384d80efa5e9fae686341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E6=A0=91=E4=BA=BA?= Date: Tue, 25 Feb 2025 07:05:31 +0900 Subject: [PATCH] feat(kgm): kgm v5 (aka. kgg) support --- package.json | 2 +- pnpm-lock.yaml | 10 +-- src/components/AddKey.tsx | 47 ++++++++++ src/components/ImportSecretModal.tsx | 12 ++- src/decrypt-worker/constants.ts | 1 + src/decrypt-worker/decipher/KugouMusic.ts | 9 +- src/decrypt-worker/types.ts | 10 +++ src/decrypt-worker/worker.ts | 2 + .../worker/kugou_parse_header.ts | 23 +++++ .../worker/kuwo_header_parse.ts | 4 +- src/faq/KugouFAQ.tsx | 31 +++++++ src/features/file-listing/fileListingSlice.ts | 18 +++- src/features/settings/Settings.tsx | 2 + src/features/settings/keyFormats.ts | 22 ++++- .../settings/panels/Kugou/InstructionsPC.tsx | 34 ++++++++ .../panels/Kugou/KugouAllInstructions.tsx | 25 ++++++ .../settings/panels/Kugou/KugouEKeyItem.tsx | 72 +++++++++++++++ src/features/settings/panels/PanelKGGKey.tsx | 87 +++++++++++++++++++ src/features/settings/persistSettings.ts | 10 +++ src/features/settings/settingsSelector.ts | 16 +++- src/features/settings/settingsSlice.ts | 62 ++++++++++++- src/tabs/FaqTab.tsx | 41 +++++---- src/util/DatabaseKeyExtractor.ts | 36 ++++++++ src/util/mmkv/kugou.ts | 16 ++++ 24 files changed, 553 insertions(+), 39 deletions(-) create mode 100644 src/components/AddKey.tsx create mode 100644 src/decrypt-worker/worker/kugou_parse_header.ts create mode 100644 src/faq/KugouFAQ.tsx create mode 100644 src/features/settings/panels/Kugou/InstructionsPC.tsx create mode 100644 src/features/settings/panels/Kugou/KugouAllInstructions.tsx create mode 100644 src/features/settings/panels/Kugou/KugouEKeyItem.tsx create mode 100644 src/features/settings/panels/PanelKGGKey.tsx create mode 100644 src/util/mmkv/kugou.ts diff --git a/package.json b/package.json index 94fe563..ec756d2 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@reduxjs/toolkit": "^2.5.0", - "@unlock-music/crypto": "0.1.2", + "@unlock-music/crypto": "0.1.6", "framer-motion": "^11.14.4", "nanoid": "^5.0.9", "radash": "^12.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b9970d..c377efd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,8 +39,8 @@ importers: specifier: ^2.5.0 version: 2.5.0(react-redux@9.2.0(@types/react@18.3.16)(react@18.3.1)(redux@5.0.1))(react@18.3.1) '@unlock-music/crypto': - specifier: 0.1.2 - version: 0.1.2 + specifier: 0.1.6 + version: 0.1.6 framer-motion: specifier: ^11.14.4 version: 11.14.4(@emotion/is-prop-valid@1.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1491,8 +1491,8 @@ packages: '@ungap/structured-clone@1.2.1': resolution: {integrity: sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==} - '@unlock-music/crypto@0.1.2': - resolution: {integrity: sha512-Ed72WZuQsuBvs3Cmo0fdlV1BllG81WK5wyDTFA5QThIZ2OkJVixi6jde6wbFJQZ6KBimC/e0Qk0ST1G8pyJcTg==, tarball: https://git.unlock-music.dev/api/packages/um/npm/%40unlock-music%2Fcrypto/-/0.1.2/crypto-0.1.2.tgz} + '@unlock-music/crypto@0.1.6': + resolution: {integrity: sha512-dFIL+25H0lrloGYZzm2ICNar0oGPCzwHDosK54au3rdbFI4XpD0wizOLs9K+huqspnnsXAxeaOQkamtd27fI2Q==, tarball: https://git.unlock-music.dev/api/packages/um/npm/%40unlock-music%2Fcrypto/-/0.1.6/crypto-0.1.6.tgz} '@vitejs/plugin-react@4.3.4': resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} @@ -5156,7 +5156,7 @@ snapshots: '@ungap/structured-clone@1.2.1': {} - '@unlock-music/crypto@0.1.2': {} + '@unlock-music/crypto@0.1.6': {} '@vitejs/plugin-react@4.3.4(vite@5.4.11(@types/node@22.10.2)(sass@1.83.0)(terser@5.37.0))': dependencies: diff --git a/src/components/AddKey.tsx b/src/components/AddKey.tsx new file mode 100644 index 0000000..0088f77 --- /dev/null +++ b/src/components/AddKey.tsx @@ -0,0 +1,47 @@ +import { + Button, + ButtonGroup, + HStack, + Icon, + IconButton, + Menu, + MenuButton, + MenuDivider, + MenuItem, + MenuList, +} from '@chakra-ui/react'; +import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md'; + +export interface AddKeyProps { + addKey: () => void; + importKeyFromFile?: () => void; + clearKeys?: () => void; +} + +export function AddKey({ addKey, importKeyFromFile, clearKeys }: AddKeyProps) { + return ( + + + + + }> + + {importKeyFromFile && ( + }> + 从文件导入密钥… + + )} + {importKeyFromFile && clearKeys && } + {clearKeys && ( + }> + 清空密钥 + + )} + + + + + ); +} diff --git a/src/components/ImportSecretModal.tsx b/src/components/ImportSecretModal.tsx index cc099bc..d79932b 100644 --- a/src/components/ImportSecretModal.tsx +++ b/src/components/ImportSecretModal.tsx @@ -18,11 +18,19 @@ export interface ImportSecretModalProps { children: React.ReactNode; show: boolean; onClose: () => void; - onImport: (file: File) => void; + onImport: (file: File) => void|Promise; } export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) { - const handleFileReceived = (files: File[]) => onImport(files[0]); + const handleFileReceived = (files: File[]) => { + const promise = onImport(files[0]); + if (promise instanceof Promise) { + promise.catch(err => { + console.error('could not import: ', err); + }); + } + return promise; + }; return ( diff --git a/src/decrypt-worker/constants.ts b/src/decrypt-worker/constants.ts index 82cd52c..91c9e71 100644 --- a/src/decrypt-worker/constants.ts +++ b/src/decrypt-worker/constants.ts @@ -2,6 +2,7 @@ export enum DECRYPTION_WORKER_ACTION_NAME { DECRYPT = 'DECRYPT', FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME', KUWO_PARSE_HEADER = 'KUWO_PARSE_HEADER', + KUGOU_PARSE_HEADER = 'KUGOU_PARSE_HEADER', QINGTING_FM_GET_DEVICE_KEY = 'QINGTING_FM_GET_DEVICE_KEY', VERSION = 'VERSION', } diff --git a/src/decrypt-worker/decipher/KugouMusic.ts b/src/decrypt-worker/decipher/KugouMusic.ts index 567975f..f5cebcc 100644 --- a/src/decrypt-worker/decipher/KugouMusic.ts +++ b/src/decrypt-worker/decipher/KugouMusic.ts @@ -1,16 +1,18 @@ import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers'; -import { KuGou } from '@unlock-music/crypto'; +import { KuGou, KuGouHeader } from '@unlock-music/crypto'; import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts'; import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts'; export class KugouMusicDecipher implements DecipherInstance { cipherName = 'Kugou'; - async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise { + async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise { let kgm: KuGou | undefined; + let kgmHdr: KuGouHeader | undefined; try { - kgm = KuGou.from_header(buffer.subarray(0, 0x400)); + kgmHdr = new KuGouHeader(buffer.subarray(0, 0x400)); + kgm = KuGou.fromHeaderV5(kgmHdr, options.kugouKey); const audioBuffer = new Uint8Array(buffer.subarray(0x400)); for (const [block, offset] of chunkBuffer(audioBuffer)) { @@ -23,6 +25,7 @@ export class KugouMusicDecipher implements DecipherInstance { data: audioBuffer, }; } finally { + kgmHdr?.free(); kgm?.free(); } } diff --git a/src/decrypt-worker/types.ts b/src/decrypt-worker/types.ts index a36f66c..17bbfc5 100644 --- a/src/decrypt-worker/types.ts +++ b/src/decrypt-worker/types.ts @@ -2,6 +2,7 @@ export interface DecryptCommandOptions { fileName: string; qmc2Key?: string; kwm2key?: string; + kugouKey?: string; qingTingAndroidKey?: string; } @@ -24,6 +25,15 @@ export type ParseKuwoHeaderResponse = null | { qualityId: number; }; +export interface ParseKugouHeaderPayload { + blobURI: string; +} + +export type ParseKugouHeaderResponse = null | { + version: number; + audioHash: string; +}; + export interface GetQingTingFMDeviceKeyPayload { product: string; device: string; diff --git a/src/decrypt-worker/worker.ts b/src/decrypt-worker/worker.ts index c5862d7..b27560b 100644 --- a/src/decrypt-worker/worker.ts +++ b/src/decrypt-worker/worker.ts @@ -6,6 +6,7 @@ import { workerDecryptHandler } from './worker/decrypt.ts'; import { workerParseMusicExMediaName } from './worker/qmcv2_parser.ts'; import { workerGetQtfmDeviceKey } from '~/decrypt-worker/worker/qtfm_device_key.ts'; import { workerParseKuwoHeader } from '~/decrypt-worker/worker/kuwo_header_parse.ts'; +import { workerParseKugouHeader } from '~/decrypt-worker/worker/kugou_parse_header.ts'; const bus = new WorkerServerBus(); onmessage = bus.onmessage; @@ -14,4 +15,5 @@ bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler) bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName); bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getUmcVersion); bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER, workerParseKuwoHeader); +bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUGOU_PARSE_HEADER, workerParseKugouHeader); bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, workerGetQtfmDeviceKey); diff --git a/src/decrypt-worker/worker/kugou_parse_header.ts b/src/decrypt-worker/worker/kugou_parse_header.ts new file mode 100644 index 0000000..42d4a74 --- /dev/null +++ b/src/decrypt-worker/worker/kugou_parse_header.ts @@ -0,0 +1,23 @@ +import { + ParseKugouHeaderPayload, ParseKugouHeaderResponse, + +} from '~/decrypt-worker/types.ts'; +import { KuGouHeader } from '@unlock-music/crypto'; + +export const workerParseKugouHeader = async ({ blobURI }: ParseKugouHeaderPayload): Promise => { + const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob()); + const arrayBuffer = await blob.arrayBuffer(); + const buffer = new Uint8Array(arrayBuffer.slice(0, 0x400)); + + let kwm : KuGouHeader | undefined; + + try { + kwm = new KuGouHeader(buffer); + const { version, audioHash } = kwm; + return { version, audioHash }; + } catch { + return null; + } finally { + kwm?.free(); + } +} diff --git a/src/decrypt-worker/worker/kuwo_header_parse.ts b/src/decrypt-worker/worker/kuwo_header_parse.ts index dc00439..b9fa3e4 100644 --- a/src/decrypt-worker/worker/kuwo_header_parse.ts +++ b/src/decrypt-worker/worker/kuwo_header_parse.ts @@ -1,7 +1,7 @@ -import { FetchMusicExNamePayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts'; +import { ParseKuwoHeaderPayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts'; import { KuwoHeader } from '@unlock-music/crypto'; -export const workerParseKuwoHeader = async ({ blobURI }: FetchMusicExNamePayload): Promise => { +export const workerParseKuwoHeader = async ({ blobURI }: ParseKuwoHeaderPayload): Promise => { const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob()); const arrayBuffer = await blob.arrayBuffer(); diff --git a/src/faq/KugouFAQ.tsx b/src/faq/KugouFAQ.tsx new file mode 100644 index 0000000..75a4c31 --- /dev/null +++ b/src/faq/KugouFAQ.tsx @@ -0,0 +1,31 @@ +import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react'; +import { Header4 } from '~/components/HelpText/Headers'; +import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions'; +import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx'; + +export function KugouFAQ() { + return ( + <> + 解锁失败 + + + + 酷狗现在对部分用户推送了 kgg 加密格式(安卓、Windows 客户端)。 + + 根据平台不同,你需要提取密钥数据库。 + + + + + + 安卓用户提取密钥需要 root 权限,或注入文件提供器。 + + + + + } /> + + + + ); +} diff --git a/src/features/file-listing/fileListingSlice.ts b/src/features/file-listing/fileListingSlice.ts index 54cb9f4..02a792e 100644 --- a/src/features/file-listing/fileListingSlice.ts +++ b/src/features/file-listing/fileListingSlice.ts @@ -5,13 +5,18 @@ import type { RootState } from '~/store'; import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants'; import type { DecryptCommandOptions, - FetchMusicExNamePayload, + FetchMusicExNamePayload, ParseKugouHeaderPayload, ParseKugouHeaderResponse, ParseKuwoHeaderPayload, - ParseKuwoHeaderResponse, + ParseKuwoHeaderResponse } from '~/decrypt-worker/types'; import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client'; import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError'; -import { selectKWMv2Key, selectQMCv2KeyByFileName, selectQtfmAndroidKey } from '../settings/settingsSelector'; +import { + selectKugouKey, + selectKWMv2Key, + selectQMCv2KeyByFileName, + selectQtfmAndroidKey +} from '../settings/settingsSelector'; export enum ProcessState { QUEUED = 'QUEUED', @@ -70,7 +75,7 @@ export const processFile = createAsyncThunk< thunkAPI.dispatch(setFileAsProcessing({ id: fileId })); }; - const [qmcv2MusicExMediaFile, kuwoHdr] = await Promise.all([ + const [qmcv2MusicExMediaFile, kuwoHdr, kugouHdr] = await Promise.all([ workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, { blobURI: file.raw, }), @@ -78,12 +83,17 @@ export const processFile = createAsyncThunk< DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER, { blobURI: file.raw }, ), + workerClientBus.request( + DECRYPTION_WORKER_ACTION_NAME.KUGOU_PARSE_HEADER, + { blobURI: file.raw }, + ), ]); const options: DecryptCommandOptions = { fileName: file.fileName, qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName), kwm2key: selectKWMv2Key(state, kuwoHdr), + kugouKey: selectKugouKey(state, kugouHdr), qingTingAndroidKey: selectQtfmAndroidKey(state), }; return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess); diff --git a/src/features/settings/Settings.tsx b/src/features/settings/Settings.tsx index 7209143..ecbded8 100644 --- a/src/features/settings/Settings.tsx +++ b/src/features/settings/Settings.tsx @@ -31,10 +31,12 @@ import { commitStagingChange, discardStagingChanges } from './settingsSlice'; import { PanelKWMv2Key } from './panels/PanelKWMv2Key'; import { selectIsSettingsNotSaved } from './settingsSelector'; import { PanelQingTing } from './panels/PanelQingTing'; +import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx'; const TABS: { name: string; Tab: () => JSX.Element }[] = [ { name: 'QMCv2 密钥', Tab: PanelQMCv2Key }, { name: 'KWMv2 密钥', Tab: PanelKWMv2Key }, + { name: 'KGG 密钥', Tab: PanelKGGKey }, { name: '蜻蜓 FM', Tab: PanelQingTing }, { name: '其它/待定', diff --git a/src/features/settings/keyFormats.ts b/src/features/settings/keyFormats.ts index 17df923..559e3c9 100644 --- a/src/features/settings/keyFormats.ts +++ b/src/features/settings/keyFormats.ts @@ -14,6 +14,7 @@ export function productionKeyToStaging>( } return result; } + export function stagingKeyToProduction(src: S[], toKey: (s: S) => keyof P, toValue: (s: S) => P[keyof P]): P { return objectify(src, toKey, toValue) as P; } @@ -41,7 +42,6 @@ export const qmc2ProductionToStaging = ( }; // KWMv2 (KuWo) - export interface StagingKWMv2Key { id: string; /** @@ -64,7 +64,7 @@ export const parseKwm2ProductionKey = (key: string): null | { rid: string; quali return { rid, quality }; }; -export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/[\D]/g, '')}`; +export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/\D/g, '')}`; export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey; export const kwm2ProductionToStaging = ( key: keyof ProductionKWMv2Keys, @@ -78,3 +78,21 @@ export const kwm2ProductionToStaging = ( return { id: nanoid(), rid, quality, ekey: value }; }; + +// KuGou (kgg, kgm v5) +export interface StagingKugouKey { + id: string; + audioHash: string; + ekey: string; +} + +export type ProductionKugouKey = Record; +export const kugouStagingToProductionKey = (key: StagingKugouKey) => key.audioHash.normalize(); +export const kugouStagingToProductionValue = (key: StagingKugouKey) => key.ekey.normalize(); +export const kugouProductionToStaging = ( + key: keyof ProductionKugouKey, + value: ProductionKugouKey[keyof ProductionKugouKey], +): null | StagingKugouKey => { + if (typeof value !== 'string') return null; + return { id: nanoid(), audioHash: key.normalize(), ekey: value }; +}; diff --git a/src/features/settings/panels/Kugou/InstructionsPC.tsx b/src/features/settings/panels/Kugou/InstructionsPC.tsx new file mode 100644 index 0000000..7e30e9f --- /dev/null +++ b/src/features/settings/panels/Kugou/InstructionsPC.tsx @@ -0,0 +1,34 @@ +import { Code, Heading, ListItem, OrderedList, Text } from '@chakra-ui/react'; +import { FilePathBlock } from '~/components/FilePathBlock.tsx'; + +export function InstructionsPC() { + return ( + <> + 酷狗的 Windows 客户端使用 SQLite 数据库储存密钥。 + 该密钥文件通常存储在下述路径: + %APPDATA%\KuGou8\KGMusicV3.db + + + 导入密钥 + + + + + 选中并复制上述的 KGMusicV3.db 文件路径 + + + + 点击上方的「文件选择区域」,打开「文件选择框」 + + + + 在「文件名」输入框中粘贴之前复制的 KGMusicV3.db 文件路径 + + + + 按下「回车键」确认。 + + + + ); +} diff --git a/src/features/settings/panels/Kugou/KugouAllInstructions.tsx b/src/features/settings/panels/Kugou/KugouAllInstructions.tsx new file mode 100644 index 0000000..bb25b70 --- /dev/null +++ b/src/features/settings/panels/Kugou/KugouAllInstructions.tsx @@ -0,0 +1,25 @@ +import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react'; +import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction'; +import { InstructionsPC } from './InstructionsPC'; + +export function KugouAllInstructions() { + return ( + <> + + 安卓 + Windows + + + + + + + + + + + ); +} diff --git a/src/features/settings/panels/Kugou/KugouEKeyItem.tsx b/src/features/settings/panels/Kugou/KugouEKeyItem.tsx new file mode 100644 index 0000000..e5eba16 --- /dev/null +++ b/src/features/settings/panels/Kugou/KugouEKeyItem.tsx @@ -0,0 +1,72 @@ +import { + HStack, + Icon, + IconButton, + Input, + InputGroup, + InputLeftElement, + InputRightElement, + ListItem, + Text, + VStack, +} from '@chakra-ui/react'; +import { MdDelete, MdVpnKey } from 'react-icons/md'; +import { kugouDeleteKey, kugouUpdateKey } from '../../settingsSlice'; +import { useAppDispatch } from '~/hooks'; +import { memo } from 'react'; +import { StagingKugouKey } from '../../keyFormats'; + +export const KugouEKeyItem = memo(({ id, ekey, audioHash, i }: StagingKugouKey & { i: number }) => { + const dispatch = useAppDispatch(); + + const updateKey = (prop: keyof StagingKugouKey, e: React.ChangeEvent) => + dispatch(kugouUpdateKey({ id, field: prop, value: e.target.value })); + const deleteKey = () => dispatch(kugouDeleteKey({ id })); + + return ( + + + + {i + 1} + + + + + updateKey('audioHash', e)} + /> + + + + + + + updateKey('ekey', e)} + /> + + + {ekey.length || '?'} + + + + + + } + variant="ghost" + colorScheme="red" + type="button" + onClick={deleteKey} + /> + + + ); +}); diff --git a/src/features/settings/panels/PanelKGGKey.tsx b/src/features/settings/panels/PanelKGGKey.tsx new file mode 100644 index 0000000..7db3962 --- /dev/null +++ b/src/features/settings/panels/PanelKGGKey.tsx @@ -0,0 +1,87 @@ +import { Box, Flex, Heading, List, Text, useToast } from '@chakra-ui/react'; +import { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { ImportSecretModal } from '~/components/ImportSecretModal'; + +import { kugouAddKey, kugouClearKeys, kugouImportKeys } from '../settingsSlice'; +import { selectStagingKugouV5Keys } from '../settingsSelector'; +import type { StagingKugouKey } from '../keyFormats'; +import { AddKey } from '~/components/AddKey.tsx'; +import { KugouEKeyItem } from '~/features/settings/panels/Kugou/KugouEKeyItem.tsx'; +import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx'; +import { parseAndroidKugouMMKV } from '~/util/mmkv/kugou.ts'; +import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor.ts'; + +export function PanelKGGKey() { + const toast = useToast(); + const dispatch = useDispatch(); + const kugouKeys = useSelector(selectStagingKugouV5Keys); + const [showImportModal, setShowImportModal] = useState(false); + + const addKey = () => dispatch(kugouAddKey()); + const clearAll = () => dispatch(kugouClearKeys()); + const handleSecretImport = async (file: File) => { + let keys: Omit[] | null = null; + if (/mggkey_multi_process/i.test(file.name)) { + keys = parseAndroidKugouMMKV(new DataView(await file.arrayBuffer())); + } else if (/^KGMusicV3\.db$/.test(file.name)) { + const extractor = await DatabaseKeyExtractor.getInstance(); + keys = extractor.extractKugouKeyFromEncryptedDb(await file.arrayBuffer()); + } + + if (keys?.length === 0) { + toast({ + title: '未导入密钥', + description: '选择的密钥数据库文件未发现任何可用的密钥。', + isClosable: true, + status: 'warning', + }); + } else if (keys) { + dispatch(kugouImportKeys(keys)); + setShowImportModal(false); + toast({ + title: `导入完成,共导入了 ${keys.length} 个密钥。`, + description: '记得按下「保存」来应用。', + isClosable: true, + status: 'success', + }); + } else { + toast({ + title: `不支持的文件:${file.name}`, + isClosable: true, + status: 'error', + }); + } + }; + + return ( + + + 酷狗解密密钥 (KGG / KGM v5) + + + 酷狗已经升级了加密方式,现在使用 KGG / KGM v5 加密。 + + setShowImportModal(true)} clearKeys={clearAll} /> + + + + {kugouKeys.map(({ id, audioHash, ekey }, i) => ( + + ))} + + {kugouKeys.length === 0 && 还没有添加密钥。} + + + setShowImportModal(false)} + onImport={handleSecretImport} + > + + + + ); +} diff --git a/src/features/settings/persistSettings.ts b/src/features/settings/persistSettings.ts index 17b548b..21f4fc7 100644 --- a/src/features/settings/persistSettings.ts +++ b/src/features/settings/persistSettings.ts @@ -34,6 +34,16 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings { } } + if (settings?.kugou) { + const { keys } = settings.kugou; + + for (const [k, v] of enumObject(keys)) { + if (typeof v === 'string') { + draft.kugou.keys[k] = v; + } + } + } + if (typeof settings?.qtfm?.android === 'string') { draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, ''); } diff --git a/src/features/settings/settingsSelector.ts b/src/features/settings/settingsSelector.ts index 4d37903..991ba3e 100644 --- a/src/features/settings/settingsSelector.ts +++ b/src/features/settings/settingsSelector.ts @@ -2,7 +2,7 @@ import type { RootState } from '~/store'; import { closestByLevenshtein } from '~/util/levenshtein'; import { hasOwn } from '~/util/objects'; import { kwm2StagingToProductionKey } from './keyFormats'; -import type { ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts'; +import type { ParseKugouHeaderResponse, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts'; export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty; @@ -12,6 +12,9 @@ export const selectFinalQMCv2Settings = (state: RootState) => state.settings.pro export const selectStagingKWMv2Keys = (state: RootState) => state.settings.staging.kwm2.keys; export const selectFinalKWMv2Keys = (state: RootState) => state.settings.production.kwm2.keys; +export const selectStagingKugouV5Keys = (state: RootState) => state.settings.staging.kugou.keys; +export const selectFinalKugouV5Keys = (state: RootState) => state.settings.production.kugou.keys; + export const selectQMCv2KeyByFileName = (state: RootState, name: string): string | undefined => { const normalizedName = name.normalize(); @@ -50,5 +53,16 @@ export const selectKWMv2Key = (state: RootState, hdr: ParseKuwoHeaderResponse): return ekey; }; +export const selectKugouKey = (state: RootState, hdr: ParseKugouHeaderResponse): string | undefined => { + if (!hdr) { + return; + } + + const keys = selectFinalKugouV5Keys(state); + const lookupKey = hdr.audioHash; + + return hasOwn(keys, lookupKey) ? keys[lookupKey] : undefined; +}; + export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android; export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android; diff --git a/src/features/settings/settingsSlice.ts b/src/features/settings/settingsSlice.ts index ce9325f..852e969 100644 --- a/src/features/settings/settingsSlice.ts +++ b/src/features/settings/settingsSlice.ts @@ -14,6 +14,11 @@ import { qmc2StagingToProductionKey, qmc2StagingToProductionValue, stagingKeyToProduction, + ProductionKugouKey, + kugouProductionToStaging, + kugouStagingToProductionKey, + kugouStagingToProductionValue, + StagingKugouKey, } from './keyFormats'; export interface StagingSettings { @@ -24,6 +29,9 @@ export interface StagingSettings { kwm2: { keys: StagingKWMv2Key[]; }; + kugou: { + keys: StagingKugouKey[]; + }; qtfm: { android: string; }; @@ -37,6 +45,9 @@ export interface ProductionSettings { kwm2: { keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey } }; + kugou: { + keys: ProductionKugouKey; // { [fileName]: ekey } + }; qtfm: { android: string; }; @@ -47,16 +58,19 @@ export interface SettingsState { staging: StagingSettings; production: ProductionSettings; } + const initialState: SettingsState = { dirty: false, staging: { qmc2: { allowFuzzyNameSearch: true, keys: [] }, kwm2: { keys: [] }, qtfm: { android: '' }, + kugou: { keys: [] }, }, production: { qmc2: { allowFuzzyNameSearch: true, keys: {} }, kwm2: { keys: {} }, + kugou: { keys: {} }, qtfm: { android: '' }, }, }; @@ -69,6 +83,9 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({ kwm2: { keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue), }, + kugou: { + keys: stagingKeyToProduction(staging.kugou.keys, kugouStagingToProductionKey, kugouStagingToProductionValue), + }, qtfm: staging.qtfm, }); @@ -80,6 +97,9 @@ const productionToStaging = (production: ProductionSettings): StagingSettings => kwm2: { keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging), }, + kugou: { + keys: productionKeyToStaging(production.kugou.keys, kugouProductionToStaging), + }, qtfm: production.qtfm, }); @@ -152,14 +172,42 @@ export const settingsSlice = createSlice({ state.dirty = true; } }, - qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) { - state.staging.qtfm.android = deviceKey; - state.dirty = true; - }, kwm2ClearKeys(state) { state.staging.kwm2.keys = []; state.dirty = true; }, + kugouAddKey(state) { + state.staging.kugou.keys.push({ id: nanoid(), audioHash: '', ekey: '' }); + state.dirty = true; + }, + kugouImportKeys(state, { payload }: PayloadAction[]>) { + const newItems = payload.map((item) => ({ id: nanoid(), ...item })); + state.staging.kugou.keys.push(...newItems); + state.dirty = true; + }, + kugouDeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) { + const kugou = state.staging.kugou; + kugou.keys = kugou.keys.filter((item) => item.id !== id); + state.dirty = true; + }, + kugouUpdateKey( + state, + { payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingKugouKey; value: string }>, + ) { + const keyItem = state.staging.kugou.keys.find((item) => item.id === id); + if (keyItem) { + keyItem[field] = value; + state.dirty = true; + } + }, + kugouClearKeys(state) { + state.staging.kugou.keys = []; + state.dirty = true; + }, + qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) { + state.staging.qtfm.android = deviceKey; + state.dirty = true; + }, // discardStagingChanges: (state) => { state.dirty = false; @@ -197,6 +245,12 @@ export const { kwm2ClearKeys, kwm2ImportKeys, + kugouAddKey, + kugouUpdateKey, + kugouDeleteKey, + kugouClearKeys, + kugouImportKeys, + qtfmAndroidUpdateKey, commitStagingChange, diff --git a/src/tabs/FaqTab.tsx b/src/tabs/FaqTab.tsx index 1aec5ff..ae6834c 100644 --- a/src/tabs/FaqTab.tsx +++ b/src/tabs/FaqTab.tsx @@ -1,8 +1,23 @@ +import { FC, Fragment } from 'react'; import { Center, Container, Heading, Link, ListItem, UnorderedList } from '@chakra-ui/react'; import { Header3 } from '~/components/HelpText/Headers'; import { KuwoFAQ } from '~/faq/KuwoFAQ'; import { OtherFAQ } from '~/faq/OtherFAQ'; import { QQMusicFAQ } from '~/faq/QQMusicFAQ'; +import { KugouFAQ } from '~/faq/KugouFAQ.tsx'; + +type FAQEntry = { + id: string; + title: string; + Help: FC; +}; + +const faqEntries: FAQEntry[] = [ + { id: 'qqmusic', title: 'QQ 音乐', Help: QQMusicFAQ }, + { id: 'kuwo', title: '酷我音乐', Help: KuwoFAQ }, + { id: 'kugou', title: '酷狗音乐', Help: KugouFAQ }, + { id: 'other', title: '其它问题', Help: OtherFAQ }, +]; export function FaqTab() { return ( @@ -12,22 +27,18 @@ export function FaqTab() { 答疑目录 - - QQ 音乐 - - - 酷我音乐 - - - 其它问题 - + {faqEntries.map(({ id, title }) => ( + + {title} + + ))} - QQ 音乐 - - 酷我音乐 - - 其它问题 - + {faqEntries.map(({ id, title, Help }) => ( + + {title} + + + ))} ); } diff --git a/src/util/DatabaseKeyExtractor.ts b/src/util/DatabaseKeyExtractor.ts index c749463..2caac6b 100644 --- a/src/util/DatabaseKeyExtractor.ts +++ b/src/util/DatabaseKeyExtractor.ts @@ -1,11 +1,17 @@ import { getFileName } from './pathHelper'; import { SQLDatabase, SQLStatic, loadSQL } from './sqlite'; +import { KuGou } from '@unlock-music/crypto'; export interface QMAndroidKeyEntry { name: string; ekey: string; } +export type KugouKeyEntry = { + audioHash: string; + ekey: string; +}; + export class DatabaseKeyExtractor { private static _instance: DatabaseKeyExtractor; @@ -52,4 +58,34 @@ export class DatabaseKeyExtractor { db?.close(); } } + + extractKugouKeyFromEncryptedDb(buffer: ArrayBuffer): null | KugouKeyEntry[] { + const dbBuffer = new Uint8Array(buffer); + let db: SQLDatabase | null = null; + + try { + KuGou.decryptDatabase(dbBuffer); + db = new this.SQL.Database(dbBuffer); + + let sql: undefined | string; + if (this.hasTable(db, 'ShareFileItems')) { + sql = `select EncryptionKeyId, EncryptionKey from ShareFileItems where EncryptionKey != '' group by EncryptionKeyId`; + } + if (!sql) return null; + + const result = db.exec(sql); + if (result.length === 0) { + return []; + } + + const keys = result[0].values; + return keys.map(([audioHash, ekey]) => ({ + // strip dir name + audioHash: String(audioHash).normalize(), + ekey: String(ekey).normalize(), + })); + } finally { + db?.close(); + } + } } diff --git a/src/util/mmkv/kugou.ts b/src/util/mmkv/kugou.ts new file mode 100644 index 0000000..a58cbaf --- /dev/null +++ b/src/util/mmkv/kugou.ts @@ -0,0 +1,16 @@ +import type { StagingKugouKey } from '~/features/settings/keyFormats'; +import { MMKVParser } from '../MMKVParser'; + +export function parseAndroidKugouMMKV(view: DataView): Omit[] { + const mmkv = new MMKVParser(view); + const result: Omit[] = []; + while (!mmkv.eof) { + const audioHash = mmkv.readString(); + const ekey = mmkv.readStringValue(); + + if (audioHash.length === 0x20 && ekey) { + result.push({ audioHash, ekey }); + } + } + return result; +}