酷我 iOS 数据库支持 #60
@ -7,9 +7,11 @@ export interface KuwoHeader {
|
||||
quality: string;
|
||||
}
|
||||
|
||||
const KUWO_MAGIC_HDRS = new Set(['yeelion-kuwo\x00\x00\x00\x00', 'yeelion-kuwo-tme']);
|
||||
|
||||
export function parseKuwoHeader(view: DataView): KuwoHeader | null {
|
||||
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10);
|
||||
if (bytesToUTF8String(magic) !== 'yeelion-kuwo-tme') {
|
||||
if (!KUWO_MAGIC_HDRS.has(bytesToUTF8String(magic))) {
|
||||
return null; // not kuwo-encrypted file
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,7 @@ import { objectify } from 'radash';
|
||||
|
||||
export function productionKeyToStaging<S, P extends Record<string, unknown>>(
|
||||
src: P,
|
||||
make: (k: keyof P, v: P[keyof P]) => null | S
|
||||
make: (k: keyof P, v: P[keyof P]) => null | S,
|
||||
): S[] {
|
||||
const result: S[] = [];
|
||||
for (const [key, value] of Object.entries(src)) {
|
||||
@ -31,7 +31,7 @@ export const qmc2StagingToProductionKey = (key: StagingQMCv2Key) => key.name.nor
|
||||
export const qmc2StagingToProductionValue = (key: StagingQMCv2Key) => key.ekey.trim();
|
||||
export const qmc2ProductionToStaging = (
|
||||
key: keyof ProductionQMCv2Keys,
|
||||
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys]
|
||||
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys],
|
||||
): StagingQMCv2Key => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
@ -44,7 +44,13 @@ export const qmc2ProductionToStaging = (
|
||||
|
||||
export interface StagingKWMv2Key {
|
||||
id: string;
|
||||
/**
|
||||
* Resource ID
|
||||
*/
|
||||
rid: string;
|
||||
/**
|
||||
* Quality String
|
||||
*/
|
||||
quality: string;
|
||||
ekey: string;
|
||||
}
|
||||
@ -58,16 +64,17 @@ export const parseKwm2ProductionKey = (key: string): null | { rid: string; quali
|
||||
|
||||
return { rid, quality };
|
||||
};
|
||||
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality}`;
|
||||
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,
|
||||
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys]
|
||||
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys],
|
||||
): null | StagingKWMv2Key => {
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
const parsed = parseKwm2ProductionKey(key);
|
||||
if (!parsed) return null;
|
||||
const { quality, rid } = parsed;
|
||||
|
||||
return { id: nanoid(), rid: parsed.rid, quality: parsed.quality, ekey: value };
|
||||
return { id: nanoid(), rid, quality, ekey: value };
|
||||
};
|
||||
|
33
src/features/settings/panels/KWMv2/InstructionsIOS.tsx
Normal file
33
src/features/settings/panels/KWMv2/InstructionsIOS.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { Code, ListItem, OrderedList, Text, chakra } from '@chakra-ui/react';
|
||||
|
||||
const KUWO_IOS_DIR = '/var/mobile/Containers/Data/Application/<酷我数据目录>/mmkv';
|
||||
|
||||
export function InstructionsIOS() {
|
||||
return (
|
||||
<>
|
||||
<Text>你需要越狱来访问 iOS 应用的私有数据。</Text>
|
||||
<Text>
|
||||
⚠️ 请注意,越狱通常意味着你的设备
|
||||
<chakra.span color="red.400">将失去保修资格</chakra.span>。
|
||||
</Text>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
访问设备的这个目录:
|
||||
<Code wordBreak="break-word">{KUWO_IOS_DIR}</Code>
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
提取密钥数据库文件 <Code>kw_ekey</Code> 至浏览器可访问的目录,如下载目录。
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
提交刚刚提取的 <Code>kw_ekey</Code> 密钥数据库。
|
||||
</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,12 +1,14 @@
|
||||
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
|
||||
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||
import { InstructionsPC } from './InstructionsPC';
|
||||
import { InstructionsIOS } from './InstructionsIOS';
|
||||
|
||||
export function KWMv2AllInstructions() {
|
||||
return (
|
||||
<>
|
||||
<TabList>
|
||||
<Tab>安卓</Tab>
|
||||
<Tab>iOS</Tab>
|
||||
<Tab>Windows</Tab>
|
||||
</TabList>
|
||||
<TabPanels flex={1} overflow="auto">
|
||||
@ -16,6 +18,9 @@ export function KWMv2AllInstructions() {
|
||||
file="cn.kuwo.player.mmkv.defaultconfig"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsIOS />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsPC />
|
||||
</TabPanel>
|
||||
|
@ -22,7 +22,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
||||
|
||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||
import { MMKVParser } from '~/util/MMKVParser';
|
||||
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '~/util/mmkv/kuwo';
|
||||
|
||||
import { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice';
|
||||
import { selectStagingKWMv2Keys } from '../settingsSelector';
|
||||
@ -41,9 +41,11 @@ export function PanelKWMv2Key() {
|
||||
const handleSecretImport = async (file: File) => {
|
||||
let keys: Omit<StagingKWMv2Key, 'id'>[] | null = null;
|
||||
if (/cn\.kuwo\.player\.mmkv/i.test(file.name)) {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
keys = MMKVParser.parseKuwoEKey(new DataView(fileBuffer));
|
||||
keys = parseAndroidKuwoEKey(new DataView(await file.arrayBuffer()));
|
||||
} else if (/kw_ekey/.test(file.name)) {
|
||||
keys = parseIosKuwoEKey(new DataView(await file.arrayBuffer()));
|
||||
}
|
||||
|
||||
if (keys?.length === 0) {
|
||||
toast({
|
||||
title: '未导入密钥',
|
||||
|
@ -29,7 +29,7 @@ import { InfoOutlineIcon } from '@chakra-ui/icons';
|
||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||
import { StagingQMCv2Key } from '../keyFormats';
|
||||
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
|
||||
import { MMKVParser } from '~/util/MMKVParser';
|
||||
import { parseAndroidQmEKey } from '~/util/mmkv/qm';
|
||||
import { getFileName } from '~/util/pathHelper';
|
||||
import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions';
|
||||
import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions';
|
||||
@ -63,7 +63,7 @@ export function PanelQMCv2Key() {
|
||||
}
|
||||
} else if (/MMKVStreamEncryptId|filenameEkeyMap/i.test(file.name)) {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
const map = MMKVParser.toStringMap(new DataView(fileBuffer));
|
||||
const map = parseAndroidQmEKey(new DataView(fileBuffer));
|
||||
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,11 @@ export const theme = extendTheme({
|
||||
'Segoe UI,Helvetica,Arial,sans-serif',
|
||||
'Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol',
|
||||
].join(','),
|
||||
mono: [
|
||||
'SFMono-Regular,Menlo,Monaco',
|
||||
'"Sarasa Mono CJK SC",',
|
||||
'Consolas,"Liberation Mono","Courier New",monospace',
|
||||
].join(','),
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
|
@ -1,4 +1,3 @@
|
||||
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
|
||||
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
|
||||
import { formatHex } from './formatHex';
|
||||
|
||||
@ -69,7 +68,11 @@ export class MMKVParser {
|
||||
return bytesToUTF8String(data).normalize();
|
||||
}
|
||||
|
||||
public readOptionalString() {
|
||||
public readKey() {
|
||||
return this.readString();
|
||||
}
|
||||
|
||||
public readStringValue(): string | null {
|
||||
// Container [
|
||||
// len: int,
|
||||
// data: variant
|
||||
@ -96,37 +99,4 @@ export class MMKVParser {
|
||||
const containerLen = this.readInt();
|
||||
this.offset += containerLen;
|
||||
}
|
||||
|
||||
public static toStringMap(view: DataView): Map<string, string> {
|
||||
const mmkv = new MMKVParser(view);
|
||||
const result = new Map<string, string>();
|
||||
while (!mmkv.eof) {
|
||||
const key = mmkv.readString();
|
||||
const value = mmkv.readOptionalString();
|
||||
if (value) {
|
||||
result.set(key, value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static parseKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
|
||||
const mmkv = new MMKVParser(view);
|
||||
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
|
||||
while (!mmkv.eof) {
|
||||
const key = mmkv.readString();
|
||||
const idMatch = key.match(/^sec_ekey#(\d+)-(.+)/);
|
||||
if (!idMatch) {
|
||||
mmkv.skipContainer();
|
||||
continue;
|
||||
}
|
||||
|
||||
const [_, rid, quality] = idMatch;
|
||||
const ekey = mmkv.readOptionalString();
|
||||
if (ekey) {
|
||||
result.push({ rid, quality, ekey });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
@ -1,46 +1,28 @@
|
||||
import { MMKVParser } from '../MMKVParser';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const makeViewFromBuffer = (buff: Buffer) =>
|
||||
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
|
||||
|
||||
test('parse qm mmkv file', () => {
|
||||
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm.mmkv'));
|
||||
expect(Object.fromEntries(MMKVParser.toStringMap(view).entries())).toMatchInlineSnapshot(`
|
||||
{
|
||||
"Lorem Ipsum": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum congue volutpat metus non molestie. Quisque id est sapien. Fusce eget tristique sem. Donec tellus lacus, viverra sed lectus eget, elementum ultrices dolor. Integer non urna justo.",
|
||||
"key": "value",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('parse qm mmkv file with optional str', () => {
|
||||
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm_optional.mmkv'));
|
||||
expect(Object.fromEntries(MMKVParser.toStringMap(view).entries())).toMatchInlineSnapshot(`
|
||||
{
|
||||
"key": "value",
|
||||
"key2": "value2",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('parse kuwo mmkv file', () => {
|
||||
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo.mmkv'));
|
||||
expect(MMKVParser.parseKuwoEKey(view)).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"ekey": "xyz123",
|
||||
"quality": "20201kmflac",
|
||||
"rid": "1234567",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('throw error on broken file', () => {
|
||||
const view = makeViewFromBuffer(
|
||||
Buffer.from([0x27, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x06, 0x07, 0x62, 0x61, 0x64, 0xff, 0xff]),
|
||||
);
|
||||
|
||||
expect(() => Object.fromEntries(MMKVParser.toStringMap(view).entries())).toThrow(/offset mismatch/i);
|
||||
expect(() => {
|
||||
const parser = new MMKVParser(view);
|
||||
parser.readKey();
|
||||
parser.readStringValue();
|
||||
}).toThrow(/offset mismatch/i);
|
||||
});
|
||||
|
||||
test('able to handle empty value', () => {
|
||||
const view = makeViewFromBuffer(
|
||||
Buffer.from([0x0b, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x00, 0x01, 0x31, 0x02, 0x01, 0x32, 0xff]),
|
||||
);
|
||||
|
||||
const parser = new MMKVParser(view);
|
||||
expect(parser.readKey()).toEqual('key');
|
||||
expect(parser.readStringValue()).toEqual(null);
|
||||
expect(parser.readKey()).toEqual('1');
|
||||
expect(parser.readStringValue()).toEqual('2');
|
||||
});
|
||||
|
Binary file not shown.
BIN
src/util/mmkv/__tests__/__fixture__/kuwo_ios.mmkv
Normal file
BIN
src/util/mmkv/__tests__/__fixture__/kuwo_ios.mmkv
Normal file
Binary file not shown.
Binary file not shown.
31
src/util/mmkv/__tests__/kuwo.test.ts
Normal file
31
src/util/mmkv/__tests__/kuwo.test.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '../kuwo';
|
||||
|
||||
const makeViewFromBuffer = (buff: Buffer) =>
|
||||
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
|
||||
|
||||
test('parse kuwo android ekey mmkv file "cn.kuwo.player.mmkv.defaultconfig"', () => {
|
||||
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo_android.mmkv'));
|
||||
expect(parseAndroidKuwoEKey(view)).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"ekey": "xyz123",
|
||||
"quality": "20201kmflac",
|
||||
"rid": "1234567",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('parse kuwo ios ekey mmkv file "kw_ekey"', () => {
|
||||
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo_ios.mmkv'));
|
||||
expect(parseIosKuwoEKey(view)).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"ekey": "xyz123",
|
||||
"quality": "20201",
|
||||
"rid": "1234567",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
15
src/util/mmkv/__tests__/qm.test.ts
Normal file
15
src/util/mmkv/__tests__/qm.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { parseAndroidQmEKey } from '../qm';
|
||||
|
||||
const makeViewFromBuffer = (buff: Buffer) =>
|
||||
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
|
||||
|
||||
test('parse qm mmkv file', () => {
|
||||
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm.mmkv'));
|
||||
expect(Object.fromEntries(parseAndroidQmEKey(view).entries())).toMatchInlineSnapshot(`
|
||||
{
|
||||
"Lorem Ipsum": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum congue volutpat metus non molestie. Quisque id est sapien. Fusce eget tristique sem. Donec tellus lacus, viverra sed lectus eget, elementum ultrices dolor. Integer non urna justo.",
|
||||
"key": "value",
|
||||
}
|
||||
`);
|
||||
});
|
41
src/util/mmkv/kuwo.ts
Normal file
41
src/util/mmkv/kuwo.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
|
||||
import { MMKVParser } from '../MMKVParser';
|
||||
|
||||
export function parseAndroidKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
|
||||
const mmkv = new MMKVParser(view);
|
||||
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
|
||||
while (!mmkv.eof) {
|
||||
const key = mmkv.readString();
|
||||
const idMatch = key.match(/^sec_ekey#(\d+)-(\w+)$/);
|
||||
if (!idMatch) {
|
||||
mmkv.skipContainer();
|
||||
continue;
|
||||
}
|
||||
|
||||
const [_, rid, quality] = idMatch;
|
||||
const ekey = mmkv.readStringValue();
|
||||
if (ekey) {
|
||||
result.push({ rid, quality, ekey });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseIosKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
|
||||
const mmkv = new MMKVParser(view);
|
||||
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
|
||||
while (!mmkv.eof) {
|
||||
const key = mmkv.readKey();
|
||||
const idMatch = key.match(/^(\d+)_(\d+)$/);
|
||||
if (!idMatch) {
|
||||
mmkv.skipContainer();
|
||||
continue;
|
||||
}
|
||||
const [_, rid, quality] = idMatch;
|
||||
const ekey = mmkv.readStringValue();
|
||||
if (ekey) {
|
||||
result.push({ rid, quality, ekey });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
14
src/util/mmkv/qm.ts
Normal file
14
src/util/mmkv/qm.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { MMKVParser } from '../MMKVParser';
|
||||
|
||||
export function parseAndroidQmEKey(view: DataView): Map<string, string> {
|
||||
const mmkv = new MMKVParser(view);
|
||||
const result = new Map<string, string>();
|
||||
while (!mmkv.eof) {
|
||||
const key = mmkv.readString();
|
||||
const value = mmkv.readStringValue();
|
||||
if (value) {
|
||||
result.set(key, value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
@ -114,7 +114,6 @@ export default defineConfig({
|
||||
],
|
||||
// workaround: sql.js is not ESModule friendly, yet...
|
||||
deps: {
|
||||
// inline: ['sql.js'],
|
||||
optimizer: {
|
||||
web: {
|
||||
include: ['sql.js'],
|
||||
|
Loading…
Reference in New Issue
Block a user