commit 5039640e4b7e3a57d97003c62990841a3089e871 Author: Flechazo <2558755403@qq.com> Date: Wed Aug 12 20:03:51 2026 +0800 init diff --git a/qmc-c/.gitignore b/qmc-c/.gitignore new file mode 100644 index 0000000..169020e --- /dev/null +++ b/qmc-c/.gitignore @@ -0,0 +1,15 @@ +# Build artifacts +build/ +*.exe +*.o +*.obj +*.a +*.so +*.dll + +# Editor/IDE +.idea/ +.vscode/ + +# OS +.DS_Store diff --git a/qmc-c/Makefile b/qmc-c/Makefile new file mode 100644 index 0000000..8279980 --- /dev/null +++ b/qmc-c/Makefile @@ -0,0 +1,22 @@ +CC = gcc +CFLAGS = -Wall -Wextra -O2 -Iinclude +LDFLAGS = -lm + +SRC = src/tea_cipher.c src/static_cipher.c src/map_cipher.c src/rc4_cipher.c \ + src/key_derivation.c src/qmc_decryptor.c +OBJ = $(SRC:src/%.c=build/%.o) + +all: build/qmc_test + +build/qmc_test: $(OBJ) test/test_qmc.c + @mkdir -p build + $(CC) $(CFLAGS) -o $@ test/test_qmc.c $(OBJ) $(LDFLAGS) + +build/%.o: src/%.c + @mkdir -p build + $(CC) $(CFLAGS) -c -o $@ $< + +clean: + rm -rf build + +.PHONY: all clean diff --git a/qmc-c/include/key_derivation.h b/qmc-c/include/key_derivation.h new file mode 100644 index 0000000..12707f6 --- /dev/null +++ b/qmc-c/include/key_derivation.h @@ -0,0 +1,21 @@ +#ifndef KEY_DERIVATION_H +#define KEY_DERIVATION_H + +#include +#include + +/** + * @brief Derives the cipher key from a base64-encoded ekey string. + * + * Automatically detects V2 format (prefix "QQMusic EncV2,Key:") and + * falls back to V1 derivation. The caller is responsible for freeing + * @p *out. + * + * @param raw_key The base64-encoded ekey string (null-terminated). + * @param out Receives the allocated derived key bytes. + * @param out_len Receives the derived key length. + * @return 0 on success, or -1 if base64 decoding or key derivation fails. + */ +int derive_key(const char *raw_key, uint8_t **out, size_t *out_len); + +#endif /* KEY_DERIVATION_H */ diff --git a/qmc-c/include/map_cipher.h b/qmc-c/include/map_cipher.h new file mode 100644 index 0000000..305393b --- /dev/null +++ b/qmc-c/include/map_cipher.h @@ -0,0 +1,51 @@ +#ifndef MAP_CIPHER_H +#define MAP_CIPHER_H + +#include +#include + +/** + * @brief State for the map cipher. + * + * @var key The key bytes used for mask lookup. + * @var len The length of @p key. + */ +typedef struct { + uint8_t *key; + int len; +} map_cipher_t; + +/** + * @brief Initializes a map cipher instance. + * + * Takes ownership of @p key; the caller must not free it separately. + * + * @param c The cipher instance to initialize. + * @param key The key bytes (allocated, ownership transferred). + * @param len The key length. + */ +void map_cipher_init(map_cipher_t *c, uint8_t *key, int len); + +/** + * @brief XOR-decrypts a buffer using the map cipher. + * + * Each byte is XORed with a rotated key byte selected from the + * key by the formula (offset^2 + 71214) % key_len. + * + * @param c The map cipher instance. + * @param buf The buffer to decrypt in-place. + * @param len The length of @p buf. + * @param offset The starting file offset for mask generation. + */ +void map_decrypt(map_cipher_t *c, uint8_t *buf, size_t len, int offset); + +/** + * @brief Frees resources held by a map cipher instance. + * + * The key buffer passed to map_cipher_init is freed. + * + * @param c The cipher instance to clean up. + */ +void map_cipher_free(map_cipher_t *c); + +#endif /* MAP_CIPHER_H */ diff --git a/qmc-c/include/qmc_decryptor.h b/qmc-c/include/qmc_decryptor.h new file mode 100644 index 0000000..083268b --- /dev/null +++ b/qmc-c/include/qmc_decryptor.h @@ -0,0 +1,64 @@ +#ifndef QMC_DECRYPTOR_H +#define QMC_DECRYPTOR_H + +#include +#include + +/** + * @brief Identifies the cipher type selected for decryption. + */ +typedef enum { + CIPHER_STATIC, + CIPHER_MAP, + CIPHER_RC4 +} cipher_kind_t; + +/** + * @brief Polymorphic cipher container. + * + * Holds the appropriate cipher state based on the derived key length: + * empty key uses Static, length 1-300 uses Map, greater than 300 uses RC4. + */ +typedef struct { + cipher_kind_t kind; + union { + void *_unused; /* Static cipher has no state */ + void *map; + void *rc4; + } data; +} cipher_type_t; + +/** + * @brief Decrypts MFLAC data using the specified ekey. + * + * Derives the cipher key, selects the appropriate cipher, decrypts the + * data, and removes trailing non-audio bytes. The caller is responsible + * for freeing @p *out. + * + * @param data The encrypted MFLAC file data. + * @param data_len The length of @p data. + * @param ekey The base64-encoded ekey string (null-terminated). + * @param out Receives the allocated decrypted FLAC bytes. + * @param out_len Receives the decrypted length (data_len or less after + * trailing trim). + * @return 0 on success, or -1 if key derivation or decryption fails. + */ +int decrypt_mflac(const uint8_t *data, size_t data_len, const char *ekey, + uint8_t **out, size_t *out_len); + +/** + * @brief Removes trailing bytes that are not part of a valid FLAC audio + * frame. + * + * Scans for FLAC frame sync markers (0xFF + byte with bits 7-2 set to + * 0x3E) and discards data from the last sync marker with fewer than + * 1000 bytes of trailing data. Operates in-place on @p *data by + * reducing @p *data_len. + * + * @param data Pointer to the FLAC buffer. + * @param data_len Pointer to the buffer length; reduced on truncation. + * @return 0 on success. + */ +int trim_flac_trailing(uint8_t **data, size_t *data_len); + +#endif /* QMC_DECRYPTOR_H */ diff --git a/qmc-c/include/rc4_cipher.h b/qmc-c/include/rc4_cipher.h new file mode 100644 index 0000000..be5ca3e --- /dev/null +++ b/qmc-c/include/rc4_cipher.h @@ -0,0 +1,58 @@ +#ifndef RC4_CIPHER_H +#define RC4_CIPHER_H + +#include +#include + +/** + * @brief State for the segmented RC4 cipher. + * + * @var key The key bytes. + * @var n The key length. + * @var hash The multiplicative hash of the key. + * @var box The KSA-initialized permutation box (length n). + */ +typedef struct { + uint8_t *key; + int n; + uint32_t hash; + uint8_t *box; +} rc4_cipher_t; + +/** + * @brief Initializes an RC4 cipher instance. + * + * Runs the Key Scheduling Algorithm on a box initialized with + * i % 256 and shuffled by the key bytes. Takes ownership of @p key. + * + * @param c The cipher instance to initialize. + * @param key The key bytes (allocated, ownership transferred). + * @param len The key length. + */ +void rc4_cipher_init(rc4_cipher_t *c, uint8_t *key, int len); + +/** + * @brief XOR-decrypts a buffer using the segmented RC4 cipher. + * + * Processes bytes in regions: first 128 bytes use direct key lookup, + * subsequent 5120-byte segments use the PRNG from a cloned KSA box + * with segment-specific skip, and any remaining data as a partial + * segment. + * + * @param c The RC4 cipher instance. + * @param buf The buffer to decrypt in-place. + * @param len The length of @p buf. + * @param offset The file offset at which @p buf starts. + */ +void rc4_decrypt(rc4_cipher_t *c, uint8_t *buf, size_t len, int offset); + +/** + * @brief Frees resources held by an RC4 cipher instance. + * + * Both the key and KSA box buffers are freed. + * + * @param c The cipher instance to clean up. + */ +void rc4_cipher_free(rc4_cipher_t *c); + +#endif /* RC4_CIPHER_H */ diff --git a/qmc-c/include/static_cipher.h b/qmc-c/include/static_cipher.h new file mode 100644 index 0000000..486c798 --- /dev/null +++ b/qmc-c/include/static_cipher.h @@ -0,0 +1,30 @@ +#ifndef STATIC_CIPHER_H +#define STATIC_CIPHER_H + +#include +#include + +/** + * @brief XOR-decrypts a buffer using the static cipher. + * + * Each byte is XORed with the mask byte computed from the fixed + * 256-byte substitution box at position + * S_BOX[(offset^2 + 27) & 0xFF]. + * + * @param buf The buffer to decrypt in-place. + * @param len The length of @p buf. + * @param offset The starting file offset for mask generation. + */ +void static_decrypt(uint8_t *buf, size_t len, int offset); + +/** + * @brief Computes the static cipher mask byte for the given offset. + * + * Exposed for testing. Offsets above 0x7FFF are reduced modulo 0x7FFF. + * + * @param offset The file offset. + * @return The mask byte. + */ +uint8_t static_mask(int offset); + +#endif /* STATIC_CIPHER_H */ diff --git a/qmc-c/include/tea_cipher.h b/qmc-c/include/tea_cipher.h new file mode 100644 index 0000000..be25d0e --- /dev/null +++ b/qmc-c/include/tea_cipher.h @@ -0,0 +1,59 @@ +#ifndef TEA_CIPHER_H +#define TEA_CIPHER_H + +#include +#include + +/** + * @brief Decrypts a single 64-bit block using the QQMusic TEA variant. + * + * Both block halves share the same sum value within each iteration, + * differing from standard TEA where the sum is updated between half-rounds. + * + * @param v The 2-element ciphertext block; replaced with the decrypted + * block on return. + * @param key The 4-element TEA key (32-bit unsigned integers). + */ +void tea_decrypt_block(uint32_t v[2], const uint32_t key[4]); + +/** + * @brief Reads a 32-bit unsigned integer from big-endian bytes. + * + * @param bytes The byte array. + * @param off The starting offset; must be at least 3 less than the + * array length. + * @return The 32-bit unsigned integer formed from the four bytes. + */ +uint32_t from_big_endian(const uint8_t *bytes, int off); + +/** + * @brief Writes a 32-bit unsigned integer as big-endian bytes. + * + * @param w The value to write. + * @param out The 4-element output array; receives the big-endian + * representation. + */ +void to_big_endian(uint32_t w, uint8_t out[4]); + +/** + * @brief Performs QQMusic-specific TEA-CBC decryption with custom padding. + * + * The input must be a multiple of 8 bytes and at least 16 bytes. + * Decrypted layout: [padding(1)] [salt(2)] [plaintext] [zero-check(7)]. + * + * On success, allocates @p *out and sets @p *out_len to the plaintext + * length (excluding salt and padding). The caller is responsible for + * freeing @p *out. + * + * @param in_buf The ciphertext buffer (multiple of 8, length >= 16). + * @param in_len Length of @p in_buf. + * @param key The 16-byte TEA key. + * @param out Receives the allocated decrypted plaintext. + * @param out_len Receives the plaintext length. + * @return 0 on success, or -1 if the input size is invalid or zero-check + * verification fails. + */ +int decrypt_tencent_tea(const uint8_t *in_buf, size_t in_len, + const uint8_t *key, uint8_t **out, size_t *out_len); + +#endif /* TEA_CIPHER_H */ diff --git a/qmc-c/src/key_derivation.c b/qmc-c/src/key_derivation.c new file mode 100644 index 0000000..e32b659 --- /dev/null +++ b/qmc-c/src/key_derivation.c @@ -0,0 +1,192 @@ +#include "key_derivation.h" +#include "tea_cipher.h" +#include +#include +#include +#include + +/* ---- Minimal base64 decoder ---- */ + +static const uint8_t b64_table[256] = { + ['A']=0,['B']=1,['C']=2,['D']=3,['E']=4,['F']=5,['G']=6,['H']=7, + ['I']=8,['J']=9,['K']=10,['L']=11,['M']=12,['N']=13,['O']=14,['P']=15, + ['Q']=16,['R']=17,['S']=18,['T']=19,['U']=20,['V']=21,['W']=22,['X']=23, + ['Y']=24,['Z']=25,['a']=26,['b']=27,['c']=28,['d']=29,['e']=30,['f']=31, + ['g']=32,['h']=33,['i']=34,['j']=35,['k']=36,['l']=37,['m']=38,['n']=39, + ['o']=40,['p']=41,['q']=42,['r']=43,['s']=44,['t']=45,['u']=46,['v']=47, + ['w']=48,['x']=49,['y']=50,['z']=51,['0']=52,['1']=53,['2']=54,['3']=55, + ['4']=56,['5']=57,['6']=58,['7']=59,['8']=60,['9']=61,['+']=62,['/']=63 +}; + +static int base64_decode(const char *in, size_t in_len, uint8_t **out, size_t *out_len) { + /* Remove trailing whitespace and padding */ + while (in_len > 0 && (in[in_len-1] == '\n' || in[in_len-1] == '\r' || in[in_len-1] == ' ')) { + in_len--; + } + + /* Expect only valid base64 chars + optional '=' padding */ + size_t padding = 0; + if (in_len > 0 && in[in_len-1] == '=') { padding++; in_len--; } + if (in_len > 0 && in[in_len-1] == '=') { padding++; in_len--; } + + if (in_len == 0) { *out = NULL; *out_len = 0; return 0; } + + size_t out_size = (in_len / 4) * 3 + (in_len % 4 == 0 ? 0 : in_len % 4 - 1); + if (padding > 0) out_size -= padding; + + uint8_t *result = malloc(out_size); + if (!result) return -1; + + size_t out_pos = 0; + for (size_t i = 0; i + 3 < in_len; i += 4) { + uint32_t accum = 0; + for (int j = 0; j < 4; j++) { + accum = (accum << 6) | b64_table[(unsigned char)in[i + j]]; + } + result[out_pos++] = (uint8_t)(accum >> 16); + if (out_pos < out_size) result[out_pos++] = (uint8_t)(accum >> 8); + if (out_pos < out_size) result[out_pos++] = (uint8_t)(accum); + } + + /* Handle final chunk (1-3 chars) */ + size_t remaining = in_len % 4; + if (remaining > 0) { + size_t i = in_len - remaining; + uint32_t accum = 0; + for (size_t j = 0; j < remaining; j++) { + accum = (accum << 6) | b64_table[(unsigned char)in[i + j]]; + } + accum <<= (6 * (4 - remaining)); + if (out_pos < out_size) result[out_pos++] = (uint8_t)(accum >> 16); + if (out_pos < out_size) result[out_pos++] = (uint8_t)(accum >> 8); + if (out_pos < out_size) result[out_pos++] = (uint8_t)(accum); + } + + *out = result; + *out_len = out_size; + return 0; +} + +/* ---- simpleMakeKey ---- */ + +static void simple_make_key(uint8_t salt, int len, uint8_t *out) { + for (int i = 0; i < len; i++) { + double tmp = tan((double)salt + (double)i * 0.1); + out[i] = (uint8_t)((int)(fabs(tmp) * 100.0) & 0xFF); + } +} + +/* ---- V1 derivation ---- */ + +static int derive_key_v1(const uint8_t *raw_key_dec, size_t raw_len, + uint8_t **out, size_t *out_len) { + if (raw_len < 16) return -1; + + uint8_t simple_key[8]; + simple_make_key(106, 8, simple_key); + + uint8_t tea_key[16]; + for (int i = 0; i < 8; i++) { + tea_key[i << 1] = simple_key[i]; + tea_key[(i << 1) + 1] = raw_key_dec[i]; + } + + const uint8_t *ciphertext = raw_key_dec + 8; + size_t ct_len = raw_len - 8; + + /* Pad if needed */ + uint8_t *padded = NULL; + size_t padded_len = ct_len; + if (ct_len % 8 != 0) { + padded_len = ((ct_len + 7) / 8) * 8; + padded = malloc(padded_len); + if (!padded) return -1; + memcpy(padded, ciphertext, ct_len); + memset(padded + ct_len, 0, padded_len - ct_len); + ciphertext = padded; + } + + uint8_t *tea_out = NULL; + size_t tea_len = 0; + int ret = decrypt_tencent_tea(ciphertext, padded_len, tea_key, &tea_out, &tea_len); + free(padded); + if (ret != 0) return -1; + + /* result = raw_key_dec[0..8] ++ tea_out */ + *out_len = 8 + tea_len; + *out = malloc(*out_len); + if (!*out) { free(tea_out); return -1; } + memcpy(*out, raw_key_dec, 8); + memcpy(*out + 8, tea_out, tea_len); + free(tea_out); + return 0; +} + +/* ---- V2 derivation ---- */ + +static const uint8_t DERIVE_V2_KEY1[16] = { + 0x33,0x38,0x36,0x5A,0x4A,0x59,0x21,0x40, + 0x23,0x2A,0x24,0x25,0x5E,0x26,0x29,0x28 +}; +static const uint8_t DERIVE_V2_KEY2[16] = { + 0x2A,0x2A,0x23,0x21,0x28,0x23,0x24,0x25, + 0x26,0x5E,0x61,0x31,0x63,0x5A,0x2C,0x54 +}; + +#define V2_PREFIX "QQMusic EncV2,Key:" +#define V2_PREFIX_LEN 18 + +static int derive_key_v2(const uint8_t *raw, size_t raw_len, + uint8_t **out, size_t *out_len) { + uint8_t *buf1 = NULL; + size_t len1 = 0; + if (decrypt_tencent_tea(raw, raw_len, DERIVE_V2_KEY1, &buf1, &len1) != 0) return -1; + + uint8_t *buf2 = NULL; + size_t len2 = 0; + int ret = decrypt_tencent_tea(buf1, len1, DERIVE_V2_KEY2, &buf2, &len2); + free(buf1); + if (ret != 0) return -1; + + /* The result should be base64-encoded */ + uint8_t *decoded = NULL; + size_t decoded_len = 0; + ret = base64_decode((const char *)buf2, len2, &decoded, &decoded_len); + free(buf2); + if (ret != 0) return -1; + + *out = decoded; + *out_len = decoded_len; + return 0; +} + +/* ---- public API ---- */ + +int derive_key(const char *raw_key, uint8_t **out, size_t *out_len) { + size_t raw_len = strlen(raw_key); + + uint8_t *raw_key_dec = NULL; + size_t dec_len = 0; + if (base64_decode(raw_key, raw_len, &raw_key_dec, &dec_len) != 0) return -1; + + /* Check for V2 prefix */ + int is_v2 = (dec_len >= V2_PREFIX_LEN && + memcmp(raw_key_dec, V2_PREFIX, V2_PREFIX_LEN) == 0); + + if (is_v2) { + uint8_t *v2_result = NULL; + size_t v2_len = 0; + int ret = derive_key_v2(raw_key_dec + V2_PREFIX_LEN, dec_len - V2_PREFIX_LEN, + &v2_result, &v2_len); + free(raw_key_dec); + if (ret != 0) return -1; + + ret = derive_key_v1(v2_result, v2_len, out, out_len); + free(v2_result); + return ret; + } else { + int ret = derive_key_v1(raw_key_dec, dec_len, out, out_len); + free(raw_key_dec); + return ret; + } +} diff --git a/qmc-c/src/map_cipher.c b/qmc-c/src/map_cipher.c new file mode 100644 index 0000000..b8a4813 --- /dev/null +++ b/qmc-c/src/map_cipher.c @@ -0,0 +1,28 @@ +#include "map_cipher.h" +#include +#include + +static uint8_t map_rotate(uint8_t value, int bits) { + int rot = (bits + 4) % 8; + return (uint8_t)((value << rot) | (value >> rot)); +} + +void map_cipher_init(map_cipher_t *c, uint8_t *key, int len) { + c->key = key; + c->len = len; +} + +void map_decrypt(map_cipher_t *c, uint8_t *buf, size_t len, int offset) { + for (size_t i = 0; i < len; i++) { + int off = (offset + (int)i) > 0x7FFF ? (offset + (int)i) % 0x7FFF : (offset + (int)i); + int idx = (off * off + 71214) % c->len; + uint8_t key_byte = c->key[idx]; + buf[i] ^= map_rotate(key_byte, idx & 0x07); + } +} + +void map_cipher_free(map_cipher_t *c) { + free(c->key); + c->key = NULL; + c->len = 0; +} diff --git a/qmc-c/src/qmc_decryptor.c b/qmc-c/src/qmc_decryptor.c new file mode 100644 index 0000000..cddb8dc --- /dev/null +++ b/qmc-c/src/qmc_decryptor.c @@ -0,0 +1,122 @@ +#include "qmc_decryptor.h" +#include "key_derivation.h" +#include "static_cipher.h" +#include "map_cipher.h" +#include "rc4_cipher.h" +#include +#include + +static void cipher_decrypt(cipher_type_t *c, uint8_t *buf, size_t len, int offset) { + switch (c->kind) { + case CIPHER_STATIC: + static_decrypt(buf, len, offset); + break; + case CIPHER_MAP: + map_decrypt((map_cipher_t *)c->data.map, buf, len, offset); + break; + case CIPHER_RC4: + rc4_decrypt((rc4_cipher_t *)c->data.rc4, buf, len, offset); + break; + } +} + +int trim_flac_trailing(uint8_t **data, size_t *data_len) { + uint8_t *buf = *data; + size_t len = *data_len; + + if (len < 42) return 0; + if (buf[0] != 0x66 || buf[1] != 0x4C || buf[2] != 0x61 || buf[3] != 0x43) return 0; + + /* Find last valid frame sync */ + int last_sync = -1; + for (size_t i = 4; i < len - 1; i++) { + if (buf[i] == (uint8_t)0xFF && (buf[i + 1] & 0xFC) == 0xF8) { + if (last_sync < 0 || (int)i - last_sync > 1000) { + size_t remaining = len - i; + if (remaining >= 1000) { + last_sync = (int)i; + } + } + } + } + + if (last_sync <= 0) return 0; + + size_t trailing = len - (size_t)last_sync; + if (trailing < 1000) { + *data_len = (size_t)last_sync; + return 0; + } + + /* Check for later valid sync in trailing data */ + int has_later = 0; + for (size_t i = (size_t)last_sync + 1000; i < len - 1; i++) { + if (buf[i] == (uint8_t)0xFF && (buf[i + 1] & 0xFC) == 0xF8) { + size_t rem = len - i; + if (rem >= 1000) { + last_sync = (int)i; + has_later = 1; + } + } + } + + if (!has_later && trailing < 10000) { + *data_len = (size_t)last_sync; + } + + return 0; +} + +int decrypt_mflac(const uint8_t *data, size_t data_len, const char *ekey, + uint8_t **out, size_t *out_len) { + uint8_t *derived_key = NULL; + size_t derived_len = 0; + + if (derive_key(ekey, &derived_key, &derived_len) != 0) return -1; + + /* Create cipher */ + cipher_type_t cipher; + map_cipher_t map_c; + rc4_cipher_t rc4_c; + + if (derived_len > 300) { + cipher.kind = CIPHER_RC4; + rc4_cipher_init(&rc4_c, derived_key, (int)derived_len); + cipher.data.rc4 = &rc4_c; + } else if (derived_len > 0) { + cipher.kind = CIPHER_MAP; + map_cipher_init(&map_c, derived_key, (int)derived_len); + cipher.data.map = &map_c; + } else { + cipher.kind = CIPHER_STATIC; + cipher.data._unused = NULL; + free(derived_key); + } + + /* Decrypt */ + uint8_t *result = malloc(data_len); + if (!result) { + if (derived_len > 300) rc4_cipher_free(&rc4_c); + else if (derived_len > 0) map_cipher_free(&map_c); + /* Static cipher: derived_key already freed at init, nothing to clean up */ + return -1; + } + memcpy(result, data, data_len); + + cipher_decrypt(&cipher, result, data_len, 0); + + /* Cleanup cipher resources */ + if (derived_len > 300) { + rc4_cipher_free(&rc4_c); + } else if (derived_len > 0) { + map_cipher_free(&map_c); + } + + *out = result; + *out_len = data_len; + + /* Trim trailing garbage */ + trim_flac_trailing(out, out_len); + + return 0; +} diff --git a/qmc-c/src/rc4_cipher.c b/qmc-c/src/rc4_cipher.c new file mode 100644 index 0000000..7ea542a --- /dev/null +++ b/qmc-c/src/rc4_cipher.c @@ -0,0 +1,144 @@ +#include "rc4_cipher.h" +#include +#include +#include + +#define FIRST_SEGMENT_SIZE 128 +#define SEGMENT_SIZE 5120 + +static uint32_t calc_hash(const uint8_t *key, int n) { + uint32_t hash = 1; + for (int i = 0; i < n; i++) { + uint32_t v = key[i]; + if (v == 0) continue; + uint32_t next_hash = hash * v; /* wrapping mul */ + if (next_hash == 0 || next_hash <= hash) break; + hash = next_hash; + } + return hash; +} + +static int segment_skip(const uint8_t *key, int n, uint32_t hash, int seg_id) { + if (n == 0) return 0; + int seed = key[seg_id % n] & 0xFF; + if (seed == 0) { + /* Match Rust: infinity as f64 → i64::MAX → modulo n */ + return (int)(9223372036854775807LL % n); + } + double d = (double)(uint64_t)hash / ((double)(seg_id + 1) * (double)seed) * 100.0; + long long idx = (long long)d; + return (int)(idx % n); +} + +void rc4_cipher_init(rc4_cipher_t *c, uint8_t *key, int len) { + c->key = key; + c->n = len; + if (len == 0) { + c->hash = 0; + c->box = NULL; + return; + } + + /* KSA: match Rust's (0..n).map(|i| i as u8) — wraps at 256 */ + uint8_t *box = malloc((size_t)len); + for (int i = 0; i < len; i++) { + box[i] = (uint8_t)(i % 256); + } + int j = 0; + for (int i = 0; i < len; i++) { + j = (j + box[i] + (key[i % len] & 0xFF)) % len; + uint8_t tmp = box[i]; + box[i] = box[j]; + box[j] = tmp; + } + c->box = box; + c->hash = calc_hash(key, len); +} + +void rc4_cipher_free(rc4_cipher_t *c) { + free(c->key); + free(c->box); + c->key = NULL; + c->box = NULL; + c->n = 0; +} + +/* PRNG step: update j, k, and swap box[j] and box[k] */ +static void prng_step(uint8_t *box, int n, int *j, int *k) { + *j = (*j + 1) % n; + *k = (box[*j] + *k) % n; + uint8_t tmp = box[*j]; + box[*j] = box[*k]; + box[*k] = tmp; +} + +/* Process one segment in-place */ +static void process_segment(uint8_t *buf, size_t len, const uint8_t *key, int n, + uint32_t hash, const uint8_t *box, int offset) { + uint8_t *box_copy = malloc((size_t)n); + memcpy(box_copy, box, n); + int j = 0, k = 0; + + int skip_len = (offset % SEGMENT_SIZE) + segment_skip(key, n, hash, offset / SEGMENT_SIZE); + + /* Skip phase: PRNG without XOR */ + for (int s = 0; s < skip_len; s++) { + prng_step(box_copy, n, &j, &k); + } + + /* Data phase: PRNG + XOR */ + for (size_t i = 0; i < len; i++) { + prng_step(box_copy, n, &j, &k); + int idx = (box_copy[j] + box_copy[k]) % n; + buf[i] ^= box_copy[idx]; + } + + free(box_copy); +} + +/* Process first segment (0-127 bytes) using direct key lookup */ +static void process_first_segment(uint8_t *buf, size_t len, const uint8_t *key, + int n, uint32_t hash, int offset) { + for (size_t i = 0; i < len; i++) { + int pos = offset + (int)i; + buf[i] ^= key[segment_skip(key, n, hash, pos) % n]; + } +} + +void rc4_decrypt(rc4_cipher_t *c, uint8_t *buf, size_t len, int offset) { + size_t processed = 0; + int off = offset; + + /* 1. First segment (0-127) */ + if (off < FIRST_SEGMENT_SIZE) { + size_t remaining = FIRST_SEGMENT_SIZE - off; + size_t block_size = len < remaining ? len : remaining; + process_first_segment(buf, block_size, c->key, c->n, c->hash, off); + off += (int)block_size; + processed += block_size; + if (processed >= len) return; + } + + /* 2. Align to segment boundary */ + if (off % SEGMENT_SIZE != 0) { + int remaining = (int)(len - processed); + int rem_in_seg = SEGMENT_SIZE - (off % SEGMENT_SIZE); + int block_size = remaining < rem_in_seg ? remaining : rem_in_seg; + process_segment(buf + processed, block_size, c->key, c->n, c->hash, c->box, off); + off += block_size; + processed += block_size; + if (processed >= len) return; + } + + /* 3. Full segments */ + while (len - processed > SEGMENT_SIZE) { + process_segment(buf + processed, SEGMENT_SIZE, c->key, c->n, c->hash, c->box, off); + off += SEGMENT_SIZE; + processed += SEGMENT_SIZE; + } + + /* 4. Remainder */ + if (processed < len) { + process_segment(buf + processed, len - processed, c->key, c->n, c->hash, c->box, off); + } +} diff --git a/qmc-c/src/static_cipher.c b/qmc-c/src/static_cipher.c new file mode 100644 index 0000000..7ab5f6a --- /dev/null +++ b/qmc-c/src/static_cipher.c @@ -0,0 +1,32 @@ +#include "static_cipher.h" + +static const uint8_t S_BOX[256] = { + 0x77,0x48,0x32,0x73,0xDE,0xF2,0xC0,0xC8,0x95,0xEC,0x30,0xB2,0x51,0xC3,0xE1,0xA0, + 0x9E,0xE6,0x9D,0xCF,0xFA,0x7F,0x14,0xD1,0xCE,0xB8,0xDC,0xC3,0x4A,0x67,0x93,0xD6, + 0x28,0xC2,0x91,0x70,0xCA,0x8D,0xA2,0xA4,0xF0,0x08,0x61,0x90,0x7E,0x6F,0xA2,0xE0, + 0xEB,0xAE,0x3E,0xB6,0x67,0xC7,0x92,0xF4,0x91,0xB5,0xF6,0x6C,0x5E,0x84,0x40,0xF7, + 0xF3,0x1B,0x02,0x7F,0xD5,0xAB,0x41,0x89,0x28,0xF4,0x25,0xCC,0x52,0x11,0xAD,0x43, + 0x68,0xA6,0x41,0x8B,0x84,0xB5,0xFF,0x2C,0x92,0x4A,0x26,0xD8,0x47,0x6A,0x7C,0x95, + 0x61,0xCC,0xE6,0xCB,0xBB,0x3F,0x47,0x58,0x89,0x75,0xC3,0x75,0xA1,0xD9,0xAF,0xCC, + 0x08,0x73,0x17,0xDC,0xAA,0x9A,0xA2,0x16,0x41,0xD8,0xA2,0x06,0xC6,0x8B,0xFC,0x66, + 0x34,0x9F,0xCF,0x18,0x23,0xA0,0x0A,0x74,0xE7,0x2B,0x27,0x70,0x92,0xE9,0xAF,0x37, + 0xE6,0x8C,0xA7,0xBC,0x62,0x65,0x9C,0xC2,0x08,0xC9,0x88,0xB3,0xF3,0x43,0xAC,0x74, + 0x2C,0x0F,0xD4,0xAF,0xA1,0xC3,0x01,0x64,0x95,0x4E,0x48,0x9F,0xF4,0x35,0x78,0x95, + 0x7A,0x39,0xD6,0x6A,0xA0,0x6D,0x40,0xE8,0x4F,0xA8,0xEF,0x11,0x1D,0xF3,0x1B,0x3F, + 0x3F,0x07,0xDD,0x6F,0x5B,0x19,0x30,0x19,0xFB,0xEF,0x0E,0x37,0xF0,0x0E,0xCD,0x16, + 0x49,0xFE,0x53,0x47,0x13,0x1A,0xBD,0xA4,0xF1,0x40,0x19,0x60,0x0E,0xED,0x68,0x09, + 0x06,0x5F,0x4D,0xCF,0x3D,0x1A,0xFE,0x20,0x77,0xE4,0xD9,0xDA,0xF9,0xA4,0x2B,0x76, + 0x1C,0x71,0xDB,0x00,0xBC,0xFD,0x0C,0x6C,0xA5,0x47,0xF7,0xF6,0x00,0x79,0x4A,0x11 +}; + +uint8_t static_mask(int offset) { + int off = offset > 0x7FFF ? offset % 0x7FFF : offset; + int idx = (off * off + 27) & 0xFF; + return S_BOX[idx]; +} + +void static_decrypt(uint8_t *buf, size_t len, int offset) { + for (size_t i = 0; i < len; i++) { + buf[i] ^= static_mask(offset + (int)i); + } +} diff --git a/qmc-c/src/tea_cipher.c b/qmc-c/src/tea_cipher.c new file mode 100644 index 0000000..5b7af36 --- /dev/null +++ b/qmc-c/src/tea_cipher.c @@ -0,0 +1,123 @@ +#include "tea_cipher.h" +#include +#include + +#define DELTA 0x9E3779B9u +#define SALT_LEN 2 +#define ZERO_LEN 7 + +void tea_decrypt_block(uint32_t v[2], const uint32_t key[4]) { + uint32_t sum = DELTA * 16u; /* wrapping mul: 0xE3779B90 */ + uint32_t v0 = v[0], v1 = v[1]; + + for (int i = 0; i < 16; i++) { + v1 -= ((v0 << 4) + key[2]) ^ (v0 + sum) ^ ((v0 >> 5) + key[3]); + v0 -= ((v1 << 4) + key[0]) ^ (v1 + sum) ^ ((v1 >> 5) + key[1]); + sum -= DELTA; + } + v[0] = v0; + v[1] = v1; +} + +uint32_t from_big_endian(const uint8_t *bytes, int off) { + return ((uint32_t)bytes[off] << 24) + | ((uint32_t)bytes[off + 1] << 16) + | ((uint32_t)bytes[off + 2] << 8) + | (uint32_t)bytes[off + 3]; +} + +void to_big_endian(uint32_t w, uint8_t out[4]) { + out[0] = (uint8_t)(w >> 24); + out[1] = (uint8_t)(w >> 16); + out[2] = (uint8_t)(w >> 8); + out[3] = (uint8_t)(w); +} + +/* Decrypt one 8-byte block from src to dest */ +static void decrypt_one_block(const uint8_t *src, uint8_t *dest, const uint32_t key32[4]) { + uint32_t v[2]; + v[0] = from_big_endian(src, 0); + v[1] = from_big_endian(src, 4); + tea_decrypt_block(v, key32); + to_big_endian(v[0], dest); + to_big_endian(v[1], dest + 4); +} + +/* Advance one cipher block: XOR dest_buf with cipher at pos, then TEA decrypt */ +static void crypt_block(const uint8_t *in_buf, int pos, uint8_t *dest_buf, + const uint32_t key32[4], uint8_t iv_prev[8], uint8_t iv_cur[8]) { + memcpy(iv_prev, iv_cur, 8); + memcpy(iv_cur, in_buf + pos, 8); + for (int j = 0; j < 8; j++) { + dest_buf[j] ^= in_buf[pos + j]; + } + decrypt_one_block(dest_buf, dest_buf, key32); +} + +int decrypt_tencent_tea(const uint8_t *in_buf, size_t in_len, + const uint8_t *key, uint8_t **out, size_t *out_len) { + if (in_len % 8 != 0 || in_len < 16) return -1; + + uint32_t key32[4]; + key32[0] = from_big_endian(key, 0); + key32[1] = from_big_endian(key, 4); + key32[2] = from_big_endian(key, 8); + key32[3] = from_big_endian(key, 12); + + /* Decrypt first block */ + uint8_t dest_buf[8]; + decrypt_one_block(in_buf, dest_buf, key32); + + int pad_len = dest_buf[0] & 0x07; + size_t result_len = in_len - 1 - (size_t)pad_len - SALT_LEN - ZERO_LEN; + if ((int)result_len < 0) return -1; + + uint8_t *result = malloc(result_len); + if (!result) return -1; + + uint8_t iv_prev[8] = {0}; + uint8_t iv_cur[8]; + memcpy(iv_cur, in_buf, 8); + int pos = 8; + int dest_idx = 1 + pad_len; + + /* Skip salt */ + int i = 1; + while (i <= SALT_LEN) { + if (dest_idx < 8) { + dest_idx++; + i++; + } else { + crypt_block(in_buf, pos, dest_buf, key32, iv_prev, iv_cur); + pos += 8; + dest_idx = 0; + } + } + + /* Read output */ + size_t out_pos = 0; + while (out_pos < result_len) { + if (dest_idx < 8) { + result[out_pos] = dest_buf[dest_idx] ^ iv_prev[dest_idx]; + dest_idx++; + out_pos++; + } else { + crypt_block(in_buf, pos, dest_buf, key32, iv_prev, iv_cur); + pos += 8; + dest_idx = 0; + } + } + + /* Verify zero bytes */ + for (int z = 0; z < ZERO_LEN; z++) { + if (dest_buf[dest_idx] != iv_prev[dest_idx]) { + free(result); + return -1; + } + dest_idx++; + } + + *out = result; + *out_len = result_len; + return 0; +} diff --git a/qmc-c/test/test_qmc.c b/qmc-c/test/test_qmc.c new file mode 100644 index 0000000..50b32c8 --- /dev/null +++ b/qmc-c/test/test_qmc.c @@ -0,0 +1,193 @@ +#include +#include +#include +#include +#include +#include "tea_cipher.h" +#include "static_cipher.h" +#include "map_cipher.h" +#include "rc4_cipher.h" +#include "key_derivation.h" +#include "qmc_decryptor.h" + +static int tests_passed = 0; +static int tests_failed = 0; + +#define TEST(name) do { printf(" %s ... ", name); } while(0) +#define PASS() do { puts("OK"); tests_passed++; } while(0) +#define FAIL(msg) do { puts("FAIL: " msg); tests_failed++; } while(0) + +/* ---- TEA ---- */ +static void test_tea_roundtrip(void) { + TEST("TEA roundtrip"); + uint32_t key[] = {0x01234567, 0x89ABCDEF, 0xFEDCBA98, 0x76543210}; + uint32_t v[] = {0x12345678, 0x9ABCDEF0}; + uint32_t orig[2]; memcpy(orig, v, 8); + tea_decrypt_block(v, key); + /* Can't easily verify without encrypt, just check it changed */ + if (v[0] == orig[0] && v[1] == orig[1]) { FAIL("block unchanged"); return; } + PASS(); +} + +/* ---- Static Cipher ---- */ +static void test_static_cipher(void) { + TEST("Static cipher roundtrip"); + uint8_t buf[16]; + memset(buf, 0xAB, 16); + uint8_t orig[16]; memcpy(orig, buf, 16); + static_decrypt(buf, 16, 0); + static_decrypt(buf, 16, 0); + if (memcmp(buf, orig, 16) != 0) { FAIL("not roundtrip"); return; } + PASS(); +} + +static void test_static_mask_values(void) { + TEST("Static mask values"); + /* idx = (0+27)&0xFF = 27 → S_BOX[27] = 0xC3 */ + if (static_mask(0) != 0xC3) { FAIL("mask(0)"); return; } + /* idx = (1+27)&0xFF = 28 → S_BOX[28] = 0x4A */ + if (static_mask(1) != 0x4A) { FAIL("mask(1)"); return; } + PASS(); +} + +/* ---- Map Cipher ---- */ +static void test_map_cipher(void) { + TEST("Map cipher roundtrip"); + uint8_t key_data[] = {0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0}; + uint8_t *k = malloc(8); memcpy(k, key_data, 8); + map_cipher_t c; map_cipher_init(&c, k, 8); + uint8_t buf[16]; memset(buf, 0xFF, 16); + uint8_t orig[16]; memcpy(orig, buf, 16); + map_decrypt(&c, buf, 16, 0); + map_decrypt(&c, buf, 16, 0); + if (memcmp(buf, orig, 16) != 0) { map_cipher_free(&c); FAIL("not roundtrip"); return; } + map_cipher_free(&c); + PASS(); +} + +/* ---- RC4 Cipher ---- */ +static void test_rc4_cipher(void) { + TEST("RC4 cipher roundtrip"); + uint8_t *key_data = malloc(256); + for (int i = 0; i < 256; i++) key_data[i] = (uint8_t)i; + rc4_cipher_t c; rc4_cipher_init(&c, key_data, 256); + uint8_t buf[128]; memset(buf, 0xAB, 128); + uint8_t orig[128]; memcpy(orig, buf, 128); + rc4_decrypt(&c, buf, 128, 0); + rc4_decrypt(&c, buf, 128, 0); + if (memcmp(buf, orig, 128) != 0) { rc4_cipher_free(&c); FAIL("not roundtrip"); return; } + rc4_cipher_free(&c); + PASS(); +} + +static void test_rc4_known(void) { + TEST("RC4 known key"); + uint8_t *key_data = malloc(3); + key_data[0] = 0x4B; key_data[1] = 0x65; key_data[2] = 0x79; + rc4_cipher_t c; rc4_cipher_init(&c, key_data, 3); + const char *plain = "Plaintext"; + size_t len = strlen(plain); + uint8_t *buf = malloc(len); + memcpy(buf, plain, len); + rc4_decrypt(&c, buf, len, 0); + /* RC4 is symmetric */ + rc4_cipher_t c2; uint8_t *k2 = malloc(3); memcpy(k2, key_data, 3); + rc4_cipher_init(&c2, k2, 3); + rc4_decrypt(&c2, buf, len, 0); + rc4_cipher_free(&c2); + if (memcmp(buf, plain, len) != 0) { free(buf); rc4_cipher_free(&c); FAIL("not symmetric"); return; } + free(buf); rc4_cipher_free(&c); + PASS(); +} + +static void test_rc4_segment(void) { + TEST("RC4 segment roundtrip"); + uint8_t *key_data = malloc(3); + key_data[0] = 0x4B; key_data[1] = 0x65; key_data[2] = 0x79; + rc4_cipher_t c; rc4_cipher_init(&c, key_data, 3); + const char *text = "This is a longer test string that spans multiple RC4 segments!"; + size_t len = strlen(text); + uint8_t *buf = malloc(len); + memcpy(buf, text, len); + rc4_decrypt(&c, buf, len, 0); + rc4_cipher_t c2; uint8_t *k2 = malloc(3); memcpy(k2, key_data, 3); + rc4_cipher_init(&c2, k2, 3); + rc4_decrypt(&c2, buf, len, 0); + rc4_cipher_free(&c2); + if (memcmp(buf, text, len) != 0) { free(buf); rc4_cipher_free(&c); FAIL("not symmetric"); return; } + free(buf); rc4_cipher_free(&c); + PASS(); +} + +/* ---- Key Derivation ---- */ +static void test_derive_key_short(void) { + TEST("derive_key invalid base64"); + /* "aGVs" is "hel" in base64 - too short for valid ekey */ + uint8_t *out = NULL; size_t out_len = 0; + int ret = derive_key("aGVsbG8=", &out, &out_len); + if (ret == 0) { + /* Might succeed or fail depending on input length, either is acceptable */ + if (out_len > 0) { + free(out); + } + } + PASS(); +} + +/* ---- Real ekey test ---- */ +static void test_real_ekey(void) { + TEST("Real ekey decryption"); + FILE *ekey_f = fopen("f:/qqmusic_debug/test_ekey.txt", "r"); + if (!ekey_f) { FAIL("cannot open ekey file (skip)"); return; } + char ekey[1024] = {0}; + if (!fgets(ekey, sizeof(ekey), ekey_f)) { fclose(ekey_f); FAIL("read ekey"); return; } + fclose(ekey_f); + size_t ekey_len = strlen(ekey); + while (ekey_len > 0 && (ekey[ekey_len-1] == '\n' || ekey[ekey_len-1] == '\r')) { + ekey[--ekey_len] = '\0'; + } + + FILE *data_f = fopen("f:/qqmusic_debug/test_raw.mflac", "rb"); + if (!data_f) { FAIL("cannot open data file (skip)"); return; } + fseek(data_f, 0, SEEK_END); + long data_len = ftell(data_f); + fseek(data_f, 0, SEEK_SET); + uint8_t *data = malloc((size_t)data_len); + if (fread(data, 1, (size_t)data_len, data_f) != (size_t)data_len) { + free(data); fclose(data_f); FAIL("read data"); return; + } + fclose(data_f); + + uint8_t *out = NULL; size_t out_len = 0; + int ret = decrypt_mflac(data, (size_t)data_len, ekey, &out, &out_len); + free(data); + if (ret != 0) { FAIL("decrypt_mflac failed"); return; } + if (out_len < 4) { free(out); FAIL("output too short"); return; } + + /* Check fLaC header */ + if (out[0] != 0x66 || out[1] != 0x4C || out[2] != 0x61 || out[3] != 0x43) { + free(out); FAIL("not a FLAC header"); return; + } + printf("OK (%zu bytes)", out_len); + /* Save for verification */ + FILE *out_f = fopen("f:/qqmusic_debug/decoded_c.flac", "wb"); + if (out_f) { fwrite(out, 1, out_len, out_f); fclose(out_f); } + free(out); + tests_passed++; +} + +int main(void) { + puts("QMC C Tests"); + test_tea_roundtrip(); + test_static_cipher(); + test_static_mask_values(); + test_map_cipher(); + test_rc4_cipher(); + test_rc4_known(); + test_rc4_segment(); + test_derive_key_short(); + test_real_ekey(); + + printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed > 0 ? 1 : 0; +} diff --git a/qmc-haskell/.gitignore b/qmc-haskell/.gitignore new file mode 100644 index 0000000..31249fd --- /dev/null +++ b/qmc-haskell/.gitignore @@ -0,0 +1,13 @@ +# Cabal build artifacts +dist-newstyle/ +*.hi +*.o +*.dyn_hi +*.dyn_o + +# Editor/IDE +.idea/ +.vscode/ + +# OS +.DS_Store diff --git a/qmc-haskell/app/Main.hs b/qmc-haskell/app/Main.hs new file mode 100644 index 0000000..81c602f --- /dev/null +++ b/qmc-haskell/app/Main.hs @@ -0,0 +1,27 @@ +module Main where + +import qualified Data.ByteString as BS +import Numeric (showHex) +import Data.Word (Word8) +import Qmc.QmcDecryptor (decryptMflac) + +fmtHex :: Word8 -> String +fmtHex b = let h = showHex (fromIntegral b :: Int) "" in replicate (2 - length h) '0' ++ h + +main :: IO () +main = do + putStrLn "QMC Haskell — decrypt f:/qqmusic_debug/test_raw.mflac" + ekey <- readFile "f:/qqmusic_debug/test_ekey.txt" + let ekey' = filter (`notElem` "\n\r") ekey + dataBs <- BS.readFile "f:/qqmusic_debug/test_raw.mflac" + case decryptMflac dataBs ekey' of + Left err -> putStrLn $ "ERROR: " ++ err + Right out -> do + let header = BS.take 4 out + if header == BS.pack [0x66, 0x4C, 0x61, 0x43] + then do + putStrLn $ "OK: " ++ show (BS.length out) ++ " bytes, fLaC header verified" + BS.writeFile "f:/qqmusic_debug/decoded_haskell.flac" out + else do + putStrLn $ "BAD HEADER: " ++ concatMap fmtHex (BS.unpack header) + BS.writeFile "f:/qqmusic_debug/decoded_haskell_bad.flac" out diff --git a/qmc-haskell/qmc-haskell.cabal b/qmc-haskell/qmc-haskell.cabal new file mode 100644 index 0000000..6d67c4d --- /dev/null +++ b/qmc-haskell/qmc-haskell.cabal @@ -0,0 +1,56 @@ +cabal-version: 3.14 +name: qmc-haskell +version: 0.1.0.0 +license: BSD-3-Clause +license-file: LICENSE +author: Flechazo +maintainer: 2558755403@qq.com +category: Cryptography +build-type: Simple +extra-doc-files: CHANGELOG.md + +common warnings + ghc-options: -Wall + +library + import: warnings + exposed-modules: + Qmc.TeaCipher + Qmc.KeyDerivation + Qmc.StaticCipher + Qmc.MapCipher + Qmc.Rc4Cipher + Qmc.QmcDecryptor + build-depends: + base ^>=4.18.3.0, + bytestring >=0.11, + vector >=0.13, + base64-bytestring >=1.2 + hs-source-dirs: src + default-language: GHC2021 + +executable qmc-haskell + import: warnings + main-is: Main.hs + build-depends: + base ^>=4.18.3.0, + qmc-haskell, + bytestring >=0.11, + vector >=0.13, + base64-bytestring >=1.2 + hs-source-dirs: app + default-language: GHC2021 + +test-suite qmc-haskell-test + import: warnings + type: exitcode-stdio-1.0 + main-is: Main.hs + build-depends: + base ^>=4.18.3.0, + qmc-haskell, + bytestring >=0.11, + vector >=0.13, + tasty >=1.4, + tasty-hunit >=0.10 + hs-source-dirs: test + default-language: GHC2021 diff --git a/qmc-haskell/src/Qmc/KeyDerivation.hs b/qmc-haskell/src/Qmc/KeyDerivation.hs new file mode 100644 index 0000000..98a23ae --- /dev/null +++ b/qmc-haskell/src/Qmc/KeyDerivation.hs @@ -0,0 +1,101 @@ +module Qmc.KeyDerivation (deriveKey) where + +import Data.Word (Word8) +import qualified Data.Vector.Unboxed as V +import qualified Data.ByteString as BS +import qualified Data.ByteString.Base64 as B64 +import Qmc.TeaCipher (decryptTencentTea) + +-- | Generates an 8-byte key from a salt value using +-- @abs(tan(salt + i * 0.1)) * 100@ for position @i@. +simpleMakeKey :: Word8 -> Int -> V.Vector Word8 +simpleMakeKey salt len = + V.generate len $ \i -> + let tmp = tan (fromIntegral salt + fromIntegral i * 0.1 :: Double) + in fromIntegral (truncate (abs tmp * 100.0) :: Int) + +rawKeyPrefixV2 :: V.Vector Word8 +rawKeyPrefixV2 = V.fromList + [0x51, 0x51, 0x4D, 0x75, 0x73, 0x69, 0x63, 0x20 + ,0x45, 0x6E, 0x63, 0x56, 0x32, 0x2C, 0x4B, 0x65 + ,0x79, 0x3A] + +deriveKeyV2Key1 :: V.Vector Word8 +deriveKeyV2Key1 = V.fromList + [0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40 + ,0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28] + +deriveKeyV2Key2 :: V.Vector Word8 +deriveKeyV2Key2 = V.fromList + [0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25 + ,0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54] + +-- | Performs V1 key derivation. Interleaves an 8-byte simple key +-- (generated from salt 106) with the first 8 bytes of the decoded key +-- data to form a 16-byte TEA key, then decrypts the remaining bytes. +-- +-- Failure cases: +-- - 'Left' if @rawKeyDec@ has fewer than 16 bytes. +-- - 'Left' if 'decryptTencentTea' fails. +deriveKeyV1 :: V.Vector Word8 -> Either String (V.Vector Word8) +deriveKeyV1 rawKeyDec + | V.length rawKeyDec < 16 = Left "key length is too short" + | otherwise = + let simpleKey = simpleMakeKey 106 8 + teaKey = V.generate 16 $ \i -> + if even i + then simpleKey V.! (i `div` 2) + else rawKeyDec V.! (i `div` 2) + ciphertext = V.slice 8 (V.length rawKeyDec - 8) rawKeyDec + paddedLen = ((V.length ciphertext + 7) `div` 8) * 8 + padded = if V.length ciphertext `mod` 8 /= 0 + then V.generate paddedLen $ \i -> + if i < V.length ciphertext then ciphertext V.! i else 0 + else ciphertext + in case decryptTencentTea padded teaKey of + Left err -> Left err + Right rs -> + let prefix = V.slice 0 8 rawKeyDec + in Right (prefix V.++ rs) + +-- | Performs V2 key derivation. Applies two TEA-CBC decryption layers +-- followed by base64 decoding. +-- +-- Failure cases: +-- - 'Left' if 'decryptTencentTea' fails on either layer. +-- - 'Left' if the inner base64 decoding fails. +deriveKeyV2 :: V.Vector Word8 -> Either String (V.Vector Word8) +deriveKeyV2 raw = + case decryptTencentTea raw deriveKeyV2Key1 of + Left err -> Left err + Right buf -> + case decryptTencentTea buf deriveKeyV2Key2 of + Left err -> Left err + Right buf2 -> + let bs = BS.pack (V.toList buf2) + in case B64.decode bs of + Left err -> Left $ "base64 decode failed in V2: " ++ show err + Right decoded -> + Right (V.fromList (BS.unpack decoded)) + +-- | Derives the cipher key from a base64-encoded ekey string. +-- Automatically detects V2 format (prefix @QQMusic EncV2,Key:@) and +-- falls back to V1 derivation. +-- +-- Failure cases: +-- - 'Left' if base64 decoding of the raw ekey fails. +-- - 'Left' if V2 or V1 derivation fails. +deriveKey :: String -> Either String (V.Vector Word8) +deriveKey rawKey = + let bs = BS.pack (map (fromIntegral . fromEnum) rawKey) + in case B64.decode bs of + Left err -> Left $ "base64 decode: " ++ show err + Right rawKeyBs -> + let rawKeyDec = V.fromList (BS.unpack rawKeyBs) + prefixLen = V.length rawKeyPrefixV2 + in if V.length rawKeyDec >= prefixLen + && V.take prefixLen rawKeyDec == rawKeyPrefixV2 + then case deriveKeyV2 (V.drop prefixLen rawKeyDec) of + Left err -> Left $ "deriveKeyV2: " ++ err + Right keyV2 -> deriveKeyV1 keyV2 + else deriveKeyV1 rawKeyDec diff --git a/qmc-haskell/src/Qmc/MapCipher.hs b/qmc-haskell/src/Qmc/MapCipher.hs new file mode 100644 index 0000000..6b31e8e --- /dev/null +++ b/qmc-haskell/src/Qmc/MapCipher.hs @@ -0,0 +1,34 @@ +module Qmc.MapCipher (MapCipher(..), mapDecrypt) where + +import Data.Bits (xor, shiftL, shiftR, (.&.), (.|.)) +import Data.Word (Word8) +import qualified Data.Vector.Unboxed as V + +-- | Wraps the key vector for the map cipher. +newtype MapCipher = MapCipher { mapKey :: V.Vector Word8 } + +-- | Rotates a byte value by @(bits + 4) `mod` 8@ positions. +mapRotate :: Word8 -> Int -> Word8 +mapRotate value bits = + let rot = (bits + 4) `mod` 8 + v = fromIntegral value :: Int + in fromIntegral ((v `shiftL` rot) .|. (v `shiftR` rot)) :: Word8 + +-- | Computes the mask byte for the given file offset. +-- Looks up a key byte at @(offset^2 + 71214) `mod` keyLen@ and applies +-- 'mapRotate'. +-- +-- Offsets above @0x7FFF@ are reduced modulo @0x7FFF@. +mapMask :: MapCipher -> Int -> Word8 +mapMask (MapCipher key) offset = + let off = if offset > 0x7FFF then offset `mod` 0x7FFF else offset + sz = V.length key + idx = (off * off + 71214) `mod` sz + keyByte = key V.! idx + in mapRotate keyByte (idx .&. 0x07) + +-- | XOR-decrypts a buffer using the map cipher. Each byte is XORed with +-- the mask byte computed for its file position. +mapDecrypt :: MapCipher -> V.Vector Word8 -> Int -> V.Vector Word8 +mapDecrypt cipher buf offset = + V.imap (\i b -> b `xor` mapMask cipher (offset + i)) buf diff --git a/qmc-haskell/src/Qmc/QmcDecryptor.hs b/qmc-haskell/src/Qmc/QmcDecryptor.hs new file mode 100644 index 0000000..63dc353 --- /dev/null +++ b/qmc-haskell/src/Qmc/QmcDecryptor.hs @@ -0,0 +1,89 @@ +module Qmc.QmcDecryptor (CipherType(..), createCipher, decryptMflac, trimFlacTrailing) where + +import Data.Bits ((.&.)) +import Data.Word (Word8) +import qualified Data.Vector.Unboxed as V +import qualified Data.ByteString as BS +import Qmc.KeyDerivation (deriveKey) +import Qmc.StaticCipher (staticDecrypt) +import Qmc.MapCipher (MapCipher(..), mapDecrypt) +import Qmc.Rc4Cipher (Rc4Cipher(..), newRc4Cipher, rc4Decrypt) + +-- | Selects the cipher type based on derived key length: +-- +-- * @> 300@ -> 'CipherRc4' +-- * @> 0@ -> 'CipherMap' +-- * otherwise -> 'CipherStatic' +data CipherType + = CipherStatic + | CipherMap MapCipher + | CipherRc4 Rc4Cipher + +-- | Dispatches to the appropriate cipher decryption based on the +-- 'CipherType'. +cipherDecrypt :: CipherType -> V.Vector Word8 -> Int -> V.Vector Word8 +cipherDecrypt CipherStatic buf off = staticDecrypt buf off +cipherDecrypt (CipherMap c) buf off = mapDecrypt c buf off +cipherDecrypt (CipherRc4 c) buf off = rc4Decrypt c buf off + +-- | Creates a 'CipherType' from the derived key bytes. +createCipher :: V.Vector Word8 -> CipherType +createCipher key + | V.length key > 300 = CipherRc4 (newRc4Cipher key) + | V.length key > 0 = CipherMap (MapCipher key) + | otherwise = CipherStatic + +-- | Decrypts MFLAC data using the specified ekey. Automatically removes +-- trailing non-audio data appended by QQMusic. +-- +-- Failure cases: +-- - 'Left' if 'deriveKey' fails. +decryptMflac :: BS.ByteString -> String -> Either String BS.ByteString +decryptMflac dataBs ekey = + case deriveKey ekey of + Left err -> Left err + Right derivedKey -> + let cipher = createCipher derivedKey + inputVec = V.fromList (BS.unpack dataBs) + decryptedVec = cipherDecrypt cipher inputVec 0 + decryptedBs = BS.pack (V.toList (trimFlacTrailing decryptedVec)) + in Right decryptedBs + +-- | Removes trailing bytes that do not form a complete FLAC audio frame. +-- Scans for FLAC frame sync markers (@0xFF@ followed by +-- @(byte & 0xFC) == 0xF8@) and discards data from the last sync marker +-- that has fewer than 1000 bytes of trailing data. +-- +-- Returns the original input unchanged when: +-- - The input is shorter than 42 bytes. +-- - The input does not begin with the FLAC magic bytes @fLaC@. +-- - No truncation is needed. +trimFlacTrailing :: V.Vector Word8 -> V.Vector Word8 +trimFlacTrailing dataVec + | V.length dataVec < 42 = dataVec + | V.take 4 dataVec /= V.fromList [0x66, 0x4C, 0x61, 0x43] = dataVec + | otherwise = + let lastSync = findLastSync 4 (-1) (0 :: Int) + in if lastSync <= 0 then dataVec + else + let trailing = V.length dataVec - lastSync + in if trailing < 1000 || (not (hasLaterSync lastSync) && trailing < 10000) + then V.take lastSync dataVec + else dataVec + where + findLastSync i best count + | i >= V.length dataVec - 1 = best + | dataVec V.! i == 0xFF && (dataVec V.! (i+1) .&. 0xFC) == 0xF8 = + let remaining = V.length dataVec - i + newBest = if remaining >= 1000 && (best < 0 || i - best > 1000) then i else best + in findLastSync (i + 1) newBest (count + 1) + | otherwise = findLastSync (i + 1) best count + + hasLaterSync after = go (after + 1000) + where + go i + | i >= V.length dataVec - 1 = False + | dataVec V.! i == 0xFF && (dataVec V.! (i+1) .&. 0xFC) == 0xF8 = + let remaining = V.length dataVec - i + in remaining >= 1000 + | otherwise = go (i + 1) diff --git a/qmc-haskell/src/Qmc/Rc4Cipher.hs b/qmc-haskell/src/Qmc/Rc4Cipher.hs new file mode 100644 index 0000000..2a6afb3 --- /dev/null +++ b/qmc-haskell/src/Qmc/Rc4Cipher.hs @@ -0,0 +1,169 @@ +module Qmc.Rc4Cipher (Rc4Cipher(..), newRc4Cipher, rc4Decrypt) where + +import Control.Monad (replicateM_, forM_) +import Control.Monad.ST (ST, runST) +import Data.Bits (xor) +import Data.STRef (STRef, newSTRef, readSTRef, writeSTRef) +import Data.Int (Int64) +import Data.Word (Word8, Word32) +import qualified Data.Vector.Unboxed as V +import qualified Data.Vector.Unboxed.Mutable as MV + +-- | Represents the state of the segmented RC4 cipher. +-- | Invariant: 'rc4Key' and 'rc4Box' contain exactly 'rc4N' elements when +-- | 'rc4N' is greater than 0. +data Rc4Cipher = Rc4Cipher + { rc4Key :: !(V.Vector Word8) + , rc4N :: !Int + , rc4Hash :: !Word32 + , rc4Box :: !(V.Vector Word8) + } + +firstSegmentSize, segmentSize :: Int +firstSegmentSize = 128 +segmentSize = 5120 + +-- | Performs the Key Scheduling Algorithm. +-- Initializes the box with @i `mod` 256@ and shuffles using the key bytes. +ksa :: V.Vector Word8 -> V.Vector Word8 +ksa key = runST $ do + let n = V.length key + box <- V.thaw (V.generate n fromIntegral) + let go j i + | i >= n = pure () + | otherwise = do + bi <- MV.unsafeRead box i + let ki = key V.! (i `mod` n) + let j' = (j + fromIntegral bi + fromIntegral ki) `mod` n + MV.unsafeSwap box i j' + go j' (i + 1) + go 0 0 + V.unsafeFreeze box + +-- | Computes a multiplicative hash of the key bytes. +-- Starts at 1 and multiplies by each non-zero key byte, stopping when +-- the product wraps to zero or does not increase in unsigned comparison. +calcHash :: V.Vector Word8 -> Word32 +calcHash key = go 1 0 + where + go hash i + | i >= V.length key = hash + | otherwise = + let v = fromIntegral (key V.! i) :: Word32 + in if v == 0 then go hash (i + 1) + else let nextHash = hash * v + in if nextHash == 0 || nextHash <= hash + then hash + else go nextHash (i + 1) + +-- | Computes the number of PRNG iterations to skip for the given segment. +-- Uses @floor(hash / ((id+1) * seed) * 100) `mod` n@. +-- A zero seed produces 'maxBound' to match the reference implementation. +segmentSkip :: V.Vector Word8 -> Word32 -> Int -> Int +segmentSkip key hash segId + | V.null key = 0 + | otherwise = + let n = V.length key + seed = fromIntegral (key V.! (segId `mod` n)) :: Word8 + in if seed == 0 + then fromIntegral ((maxBound :: Int64) `rem` fromIntegral n) + else let d = (realToFrac hash :: Double) + / ((realToFrac (segId + 1) :: Double) * (realToFrac seed :: Double)) + * 100.0 + idx = truncate d :: Int64 + in fromIntegral (idx `rem` fromIntegral n) + +-- | Advances the PRNG state by one step inside the 'ST' monad. +prngStep :: MV.MVector s Word8 -> STRef s Int -> STRef s Int -> Int -> ST s () +prngStep boxM jRef kRef n = do + j <- readSTRef jRef + let j' = (j + 1) `mod` n + k <- readSTRef kRef + bj <- MV.unsafeRead boxM j' + let k' = (fromIntegral bj + k) `mod` n + MV.unsafeSwap boxM j' k' + writeSTRef jRef j' + writeSTRef kRef k' + +-- | Processes one cipher segment in-place using a cloned KSA box. +-- The PRNG runs for @skipLen@ iterations before producing XOR masks. +processSegment :: MV.MVector s Word8 -> V.Vector Word8 -> V.Vector Word8 + -> Word32 -> Int -> ST s () +processSegment bufM key box hash offset = do + boxM <- V.thaw box + jRef <- newSTRef 0 + kRef <- newSTRef 0 + let n = V.length key + skipLen = (offset `mod` segmentSize) + segmentSkip key hash (offset `div` segmentSize) + total = MV.length bufM + + replicateM_ skipLen (prngStep boxM jRef kRef n) + + forM_ [0..total-1] $ \i -> do + prngStep boxM jRef kRef n + j <- readSTRef jRef + k <- readSTRef kRef + bj <- MV.unsafeRead boxM j + bk <- MV.unsafeRead boxM k + let idx = (fromIntegral bj + fromIntegral bk) `mod` n + mask <- MV.unsafeRead boxM idx + orig <- MV.unsafeRead bufM i + MV.unsafeWrite bufM i (orig `xor` mask) + +-- | Processes the first segment (0 through 127 bytes) using direct key +-- lookup. No PRNG state is involved. +processFirstSegment :: MV.MVector s Word8 -> V.Vector Word8 -> Word32 -> Int -> Int -> ST s () +processFirstSegment bufM key hash offset len = do + let n = V.length key + forM_ [0..len-1] $ \i -> do + let pos = offset + i + mask = key V.! (segmentSkip key hash pos `mod` n) + orig <- MV.unsafeRead bufM i + MV.unsafeWrite bufM i (orig `xor` mask) + +-- | Constructs an 'Rc4Cipher' from the given key bytes. +-- The input must not be empty for meaningful cipher operation. +newRc4Cipher :: V.Vector Word8 -> Rc4Cipher +newRc4Cipher key + | V.null key = Rc4Cipher (V.singleton 0) 1 0 (V.singleton 0) + | otherwise = + let hash = calcHash key + box = ksa key + in Rc4Cipher key (V.length key) hash box + +-- | Decrypts a vector of bytes at the given file offset using the +-- segmented RC4 cipher. Processes the first 128-byte segment with direct +-- key lookup, then aligns to 5120-byte segment boundaries for the +-- PRNG-based remainder. +rc4Decrypt :: Rc4Cipher -> V.Vector Word8 -> Int -> V.Vector Word8 +rc4Decrypt (Rc4Cipher key _n hash box) src offset = runST $ do + bufM <- V.thaw src + let toProcess = V.length src + go off processed + | processed >= toProcess = pure () + | otherwise = + let remaining = toProcess - processed + in if off < firstSegmentSize + then do + let blockSize = min remaining (firstSegmentSize - off) + chunk = MV.slice processed blockSize bufM + processFirstSegment chunk key hash off blockSize + go (off + blockSize) (processed + blockSize) + else if off `mod` segmentSize /= 0 + then do + let remInSeg = segmentSize - (off `mod` segmentSize) + blockSize = min remaining remInSeg + chunk = MV.slice processed blockSize bufM + processSegment chunk key box hash off + go (off + blockSize) (processed + blockSize) + else if remaining > segmentSize + then do + let chunk = MV.slice processed segmentSize bufM + processSegment chunk key box hash off + go (off + segmentSize) (processed + segmentSize) + else do + let chunk = MV.slice processed remaining bufM + processSegment chunk key box hash off + go (off + remaining) (processed + remaining) + go offset 0 + V.unsafeFreeze bufM diff --git a/qmc-haskell/src/Qmc/StaticCipher.hs b/qmc-haskell/src/Qmc/StaticCipher.hs new file mode 100644 index 0000000..2c93496 --- /dev/null +++ b/qmc-haskell/src/Qmc/StaticCipher.hs @@ -0,0 +1,39 @@ +module Qmc.StaticCipher (staticDecrypt, staticMask) where + +import Data.Word (Word8) +import Data.Bits (xor, (.&.)) +import qualified Data.Vector.Unboxed as V + +-- | Fixed 256-byte substitution box for the static cipher. +sBox :: V.Vector Word8 +sBox = V.fromList + [0x77,0x48,0x32,0x73,0xDE,0xF2,0xC0,0xC8,0x95,0xEC,0x30,0xB2,0x51,0xC3,0xE1,0xA0 + ,0x9E,0xE6,0x9D,0xCF,0xFA,0x7F,0x14,0xD1,0xCE,0xB8,0xDC,0xC3,0x4A,0x67,0x93,0xD6 + ,0x28,0xC2,0x91,0x70,0xCA,0x8D,0xA2,0xA4,0xF0,0x08,0x61,0x90,0x7E,0x6F,0xA2,0xE0 + ,0xEB,0xAE,0x3E,0xB6,0x67,0xC7,0x92,0xF4,0x91,0xB5,0xF6,0x6C,0x5E,0x84,0x40,0xF7 + ,0xF3,0x1B,0x02,0x7F,0xD5,0xAB,0x41,0x89,0x28,0xF4,0x25,0xCC,0x52,0x11,0xAD,0x43 + ,0x68,0xA6,0x41,0x8B,0x84,0xB5,0xFF,0x2C,0x92,0x4A,0x26,0xD8,0x47,0x6A,0x7C,0x95 + ,0x61,0xCC,0xE6,0xCB,0xBB,0x3F,0x47,0x58,0x89,0x75,0xC3,0x75,0xA1,0xD9,0xAF,0xCC + ,0x08,0x73,0x17,0xDC,0xAA,0x9A,0xA2,0x16,0x41,0xD8,0xA2,0x06,0xC6,0x8B,0xFC,0x66 + ,0x34,0x9F,0xCF,0x18,0x23,0xA0,0x0A,0x74,0xE7,0x2B,0x27,0x70,0x92,0xE9,0xAF,0x37 + ,0xE6,0x8C,0xA7,0xBC,0x62,0x65,0x9C,0xC2,0x08,0xC9,0x88,0xB3,0xF3,0x43,0xAC,0x74 + ,0x2C,0x0F,0xD4,0xAF,0xA1,0xC3,0x01,0x64,0x95,0x4E,0x48,0x9F,0xF4,0x35,0x78,0x95 + ,0x7A,0x39,0xD6,0x6A,0xA0,0x6D,0x40,0xE8,0x4F,0xA8,0xEF,0x11,0x1D,0xF3,0x1B,0x3F + ,0x3F,0x07,0xDD,0x6F,0x5B,0x19,0x30,0x19,0xFB,0xEF,0x0E,0x37,0xF0,0x0E,0xCD,0x16 + ,0x49,0xFE,0x53,0x47,0x13,0x1A,0xBD,0xA4,0xF1,0x40,0x19,0x60,0x0E,0xED,0x68,0x09 + ,0x06,0x5F,0x4D,0xCF,0x3D,0x1A,0xFE,0x20,0x77,0xE4,0xD9,0xDA,0xF9,0xA4,0x2B,0x76 + ,0x1C,0x71,0xDB,0x00,0xBC,0xFD,0x0C,0x6C,0xA5,0x47,0xF7,0xF6,0x00,0x79,0x4A,0x11] + +-- | Computes the static cipher mask byte for the given file offset. +-- Offsets above @0x7FFF@ are reduced modulo @0x7FFF@. +staticMask :: Int -> Word8 +staticMask offset = + let off = if offset > 0x7FFF then offset `mod` 0x7FFF else offset + idx = (off * off + 27) .&. 0xFF + in sBox V.! idx + +-- | XOR-decrypts a buffer using the static cipher. Each byte position is +-- XORed with the mask byte computed for that file offset. +staticDecrypt :: V.Vector Word8 -> Int -> V.Vector Word8 +staticDecrypt buf offset = + V.imap (\i b -> b `xor` staticMask (offset + i)) buf diff --git a/qmc-haskell/src/Qmc/TeaCipher.hs b/qmc-haskell/src/Qmc/TeaCipher.hs new file mode 100644 index 0000000..21cc97a --- /dev/null +++ b/qmc-haskell/src/Qmc/TeaCipher.hs @@ -0,0 +1,151 @@ +module Qmc.TeaCipher (teaDecryptBlock, fromBigEndian, toBigEndian, decryptTencentTea) where + +import Control.Monad.ST (runST) +import Data.Bits (xor, shiftL, shiftR, (.&.), (.|.)) +import Data.List (foldl') +import qualified Data.Vector.Unboxed.Mutable as MV +import Data.Word (Word32, Word8) +import qualified Data.Vector.Unboxed as V + +-- | QQMusic TEA delta constant. +delta :: Word32 +delta = 0x9E37_79B9 + +-- | Decrypts a single 64-bit block using the QQMusic TEA variant. +-- Both block halves share the same sum value within each iteration. +-- +-- Failure cases: +-- - Index out of bounds if @key@ or @v@ have fewer than 4 and 2 elements +-- respectively. +teaDecryptBlock :: V.Vector Word32 -> V.Vector Word32 -> V.Vector Word32 +teaDecryptBlock key v = + let sum0 = delta * 16 + (v0, v1) = (v V.! 0, v V.! 1) + (v0', v1', _) = foldl' (\(a, b, s) _ -> + let b' = b - (((a `shiftL` 4) + (key V.! 2)) `xor` (a + s) `xor` ((a `shiftR` 5) + (key V.! 3))) + a' = a - (((b' `shiftL` 4) + (key V.! 0)) `xor` (b' + s) `xor` ((b' `shiftR` 5) + (key V.! 1))) + s' = s - delta + in (a', b', s') + ) (v0, v1, sum0) [1..16 :: Int] + in V.fromList [v0', v1'] + +-- | Reads a 32-bit unsigned integer from a byte vector in big-endian byte +-- order. The four bytes starting at @off@ are combined, with the byte at +-- @off@ becoming the most significant byte. +-- +-- Failure cases: +-- - Index out of bounds if @off@ is at least @V.length bytes - 3@. +fromBigEndian :: V.Vector Word8 -> Int -> Word32 +fromBigEndian bytes off = + (fromIntegral (bytes V.! off) `shiftL` 24) + .|. (fromIntegral (bytes V.! (off + 1)) `shiftL` 16) + .|. (fromIntegral (bytes V.! (off + 2)) `shiftL` 8) + .|. fromIntegral (bytes V.! (off + 3)) + +-- | Converts a 32-bit unsigned integer to a 4-element byte vector in +-- big-endian byte order. The most significant byte occupies index 0. +toBigEndian :: Word32 -> V.Vector Word8 +toBigEndian w = V.fromList + [ fromIntegral (w `shiftR` 24) + , fromIntegral (w `shiftR` 16) + , fromIntegral (w `shiftR` 8) + , fromIntegral w + ] + +-- | Performs QQMusic-specific TEA-CBC decryption with custom padding. +-- The input buffer must be a multiple of the block size (8 bytes). +-- Decrypted output layout per block: [padding(1)] [salt(2)] [plaintext] +-- [zero-check(7)]. +-- +-- Decrypts the first block, extracts a padding length from the least +-- significant 3 bits of the first byte, skips two salt bytes, reads the +-- plaintext using IV XOR, and verifies the trailing zero-check region. +-- +-- Failure cases: +-- - 'Left' if @inBuf@ length is not a multiple of 8 or is less than 16. +-- - 'Left' if the computed output length is negative. +-- - 'Left' if the zero-check verification fails. +decryptTencentTea :: V.Vector Word8 -> V.Vector Word8 -> Either String (V.Vector Word8) +decryptTencentTea inBuf key + | V.length inBuf `mod` 8 /= 0 = Left "inBuf size not a multiple of block size" + | V.length inBuf < 16 = Left "inBuf size too small" + | otherwise = + let key32 = V.fromList + [ fromBigEndian key 0 + , fromBigEndian key 4 + , fromBigEndian key 8 + , fromBigEndian key 12 + ] + + v0 = fromBigEndian inBuf 0 + v1 = fromBigEndian inBuf 4 + firstBlock = teaDecryptBlock key32 (V.fromList [v0, v1]) + destBuf0 = V.concat [toBigEndian (firstBlock V.! 0), toBigEndian (firstBlock V.! 1)] + + padLen = fromIntegral (destBuf0 V.! 0) .&. 0x07 + saltLen = 2 + zeroLen = 7 + outLen = V.length inBuf - 1 - padLen - saltLen - zeroLen + + ivCur0 = V.slice 0 8 inBuf + pos0 = 8 + destIdx0 = 1 + padLen + + result = processBlocks outLen inBuf key32 destBuf0 (V.replicate 8 0) ivCur0 pos0 destIdx0 + + in if outLen < 0 + then Left $ "invalid outLen from padding: " ++ show outLen + else result + +data BlockState = BlockState + { bsDestBuf :: !(V.Vector Word8) + , bsIvPrev :: !(V.Vector Word8) + , bsIvCur :: !(V.Vector Word8) + , bsPos :: !Int + , bsDestIdx :: !Int + } + +cryptBlock :: V.Vector Word8 -> V.Vector Word32 -> BlockState -> BlockState +cryptBlock inBuf key32 st = + let ivPrev' = bsIvCur st + ivCur' = V.slice (bsPos st) 8 inBuf + xored = V.zipWith xor (bsDestBuf st) ivCur' + decrypted = teaDecryptBlock key32 (V.fromList [fromBigEndian xored 0, fromBigEndian xored 4]) + destBuf' = V.concat [toBigEndian (decrypted V.! 0), toBigEndian (decrypted V.! 1)] + in BlockState destBuf' ivPrev' ivCur' (bsPos st + 8) 0 + +processBlocks :: Int -> V.Vector Word8 -> V.Vector Word32 + -> V.Vector Word8 -> V.Vector Word8 -> V.Vector Word8 + -> Int -> Int -> Either String (V.Vector Word8) +processBlocks outLen inBuf key32 destBuf ivPrev ivCur pos destIdx = + let saltLen = 2 :: Int + zeroLen = 7 :: Int + + st0 = BlockState destBuf ivPrev ivCur pos destIdx + skipSalt st i + | i > saltLen = st + | bsDestIdx st < 8 = skipSalt (st{ bsDestIdx = bsDestIdx st + 1 }) (i + 1) + | otherwise = skipSalt (cryptBlock inBuf key32 st) i + st1 = skipSalt st0 1 + + (finalSt, output) = runST $ do + outM <- MV.replicate outLen 0 + let go st outPos + | outPos >= outLen = do + outV <- V.unsafeFreeze outM + pure (st, outV) + | bsDestIdx st < 8 = do + let b = (bsDestBuf st V.! bsDestIdx st) `xor` + (bsIvPrev st V.! bsDestIdx st) + MV.write outM outPos b + go st{ bsDestIdx = bsDestIdx st + 1 } (outPos + 1) + | otherwise = go (cryptBlock inBuf key32 st) outPos + go st1 0 + + checkZeros st i + | i > zeroLen = Right output + | bsDestBuf st V.! bsDestIdx st /= bsIvPrev st V.! bsDestIdx st = + Left "zero check failed" + | otherwise = checkZeros st{ bsDestIdx = bsDestIdx st + 1 } (i + 1) + + in checkZeros finalSt (1 :: Int) diff --git a/qmc-haskell/test/Main.hs b/qmc-haskell/test/Main.hs new file mode 100644 index 0000000..c0853e1 --- /dev/null +++ b/qmc-haskell/test/Main.hs @@ -0,0 +1,75 @@ +module Main where + +import Test.Tasty +import Test.Tasty.HUnit +import qualified Data.Vector.Unboxed as V +import Data.Word (Word8) +import Qmc.TeaCipher (teaDecryptBlock) +import Qmc.StaticCipher (staticDecrypt) +import Qmc.MapCipher (MapCipher(..), mapDecrypt) +import Qmc.Rc4Cipher (Rc4Cipher(..), newRc4Cipher, rc4Decrypt) + +main :: IO () +main = defaultMain tests + +tests :: TestTree +tests = testGroup "QMC Tests" + [ testTeaRoundtrip + , testStaticCipher + , testMapCipher + , testRc4Cipher + , testRc4Known + , testRc4Segment + ] + +testTeaRoundtrip :: TestTree +testTeaRoundtrip = testCase "TEA block smoke" $ do + let key = V.fromList [0x01234567, 0x89ABCDEF, 0xFEDCBA98, 0x76543210] + v = V.fromList [0x12345678, 0x9ABCDEF0] + result = teaDecryptBlock key v + -- Just verify it produces two Word32 outputs + V.length result @?= 2 + +testStaticCipher :: TestTree +testStaticCipher = testCase "Static cipher roundtrip" $ do + let buf = V.replicate 16 0xAB + dec1 = staticDecrypt buf 0 + dec2 = staticDecrypt dec1 0 + dec2 @?= buf + +testMapCipher :: TestTree +testMapCipher = testCase "Map cipher roundtrip" $ do + let key = V.fromList [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0] + cipher = MapCipher key + buf = V.replicate 16 0xFF + dec1 = mapDecrypt cipher buf 0 + dec2 = mapDecrypt cipher dec1 0 + dec2 @?= buf + +testRc4Cipher :: TestTree +testRc4Cipher = testCase "RC4 cipher roundtrip" $ do + let key = V.generate 256 fromIntegral + cipher = newRc4Cipher key + buf = V.replicate 128 0xAB + dec1 = rc4Decrypt cipher buf 0 + dec2 = rc4Decrypt cipher dec1 0 + dec2 @?= buf + +testRc4Known :: TestTree +testRc4Known = testCase "RC4 known key" $ do + let key = V.fromList [0x4B, 0x65, 0x79] -- "Key" + dataVec = V.fromList (map (fromIntegral . fromEnum) "Plaintext") + cipher = newRc4Cipher key + enc1 = rc4Decrypt cipher dataVec 0 + enc2 = rc4Decrypt cipher enc1 0 + enc2 @?= dataVec + assertBool "RC4 should change the data" (enc1 /= dataVec) + +testRc4Segment :: TestTree +testRc4Segment = testCase "RC4 segment roundtrip" $ do + let key = V.fromList [0x4B, 0x65, 0x79] + dataVec = V.fromList (map (fromIntegral . fromEnum) "This is a longer test string that spans multiple RC4 segments!") + cipher = newRc4Cipher key + enc1 = rc4Decrypt cipher dataVec 0 + enc2 = rc4Decrypt cipher enc1 0 + enc2 @?= dataVec diff --git a/qmc-java/.gitignore b/qmc-java/.gitignore new file mode 100644 index 0000000..d03ad91 --- /dev/null +++ b/qmc-java/.gitignore @@ -0,0 +1,40 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.kotlin +.idea/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/qmc-java/pom.xml b/qmc-java/pom.xml new file mode 100644 index 0000000..738fc4d --- /dev/null +++ b/qmc-java/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + com.flechazo + qmc-java + 1.0-SNAPSHOT + + + 25 + 25 + UTF-8 + + + + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + diff --git a/qmc-java/src/main/java/qmc/KeyDerivation.java b/qmc-java/src/main/java/qmc/KeyDerivation.java new file mode 100644 index 0000000..a2e225f --- /dev/null +++ b/qmc-java/src/main/java/qmc/KeyDerivation.java @@ -0,0 +1,135 @@ +package qmc; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; + +/** + * Derives the cipher key from a base64-encoded QQMusic ekey string. + * The derivation supports two key formats: V1 (direct) and V2 + * (two-layer TEA-wrapped with an inner base64 encoding). The V2 path is + * selected when the decoded ekey starts with the + * {@code QQMusic EncV2,Key:} prefix. + * + *

Key derivation flow: + *

    + *
  1. Base64-decode the ekey string.
  2. + *
  3. If the decoded data starts with the V2 prefix, strip the prefix, + * apply two QQMusic TEA-CBC decryption layers, then base64-decode + * the result.
  4. + *
  5. Apply the V1 derivation: interleave a {@code tan}-derived simple + * key with the first 8 bytes of the key data to form a 16-byte TEA + * key, then decrypt the remaining bytes.
  6. + *
+ */ +public class KeyDerivation { + + private static final String RAW_KEY_PREFIX_V2 = "QQMusic EncV2,Key:"; + + /** + * Generates an 8-byte key from a salt value using the function + * {@code abs(tan(salt + i * 0.1)) * 100} for position {@code i}. + * + * @param salt the salt byte + * @param length the number of bytes to generate + * @return the generated key bytes + */ + public static byte[] simpleMakeKey(byte salt, int length) { + byte[] key = new byte[length]; + for (int i = 0; i < length; i++) { + double tmp = Math.tan(salt + i * 0.1); + key[i] = (byte) ((int) (Math.abs(tmp) * 100.0) & 0xFF); + } + return key; + } + + /** + * Performs V1 key derivation. An 8-byte simple key (generated from + * salt 106) is interleaved with the first 8 bytes of the decoded key + * data to form a 16-byte TEA key. The remaining bytes are then + * decrypted using QQMusic TEA-CBC. + * + * @param rawKeyDec the base64-decoded key data (must be at least 16 + * bytes long) + * @return the derived cipher key + * @throws QmcException if the key data is too short + */ + public static byte[] deriveKeyV1(byte[] rawKeyDec) { + if (rawKeyDec.length < 16) { + throw new QmcException("key length is too short"); + } + + byte[] simpleKey = simpleMakeKey((byte) 106, 8); + byte[] teaKey = new byte[16]; + for (int i = 0; i < 8; i++) { + teaKey[i << 1] = simpleKey[i]; + teaKey[(i << 1) + 1] = rawKeyDec[i]; + } + + byte[] ciphertext = Arrays.copyOfRange(rawKeyDec, 8, rawKeyDec.length); + byte[] rs; + if (ciphertext.length % 8 != 0) { + int paddedLen = ((ciphertext.length + 7) / 8) * 8; + byte[] padded = Arrays.copyOf(ciphertext, paddedLen); + rs = TeaCipher.decryptTencentTea(padded, teaKey); + } else { + rs = TeaCipher.decryptTencentTea(ciphertext, teaKey); + } + + byte[] result = Arrays.copyOf(rawKeyDec, 8); + byte[] combined = new byte[8 + rs.length]; + System.arraycopy(result, 0, combined, 0, 8); + System.arraycopy(rs, 0, combined, 8, rs.length); + return combined; + } + + private static final byte[] DERIVE_V2_KEY1 = { + 0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40, + 0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28 + }; + + private static final byte[] DERIVE_V2_KEY2 = { + 0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25, + 0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54 + }; + + /** + * Performs V2 key derivation. The input data is first decrypted with + * TEA key 1, then decrypted with TEA key 2, and the resulting bytes + * are base64-decoded to produce the inner key material. + * + * @param raw the V2 payload bytes (after stripping the + * {@code QQMusic EncV2,Key:} prefix) + * @return the base64-decoded inner key material + * @throws QmcException if any TEA decryption or base64 decoding fails + */ + public static byte[] deriveKeyV2(byte[] raw) { + byte[] buf = TeaCipher.decryptTencentTea(raw, DERIVE_V2_KEY1); + buf = TeaCipher.decryptTencentTea(buf, DERIVE_V2_KEY2); + return Base64.getDecoder().decode(buf); + } + + /** + * Derives the cipher key from a base64-encoded ekey string. + * Detects and handles both V1 and V2 key formats automatically. + * + * @param rawKey the base64-encoded ekey string + * @return the derived cipher key bytes + * @throws IllegalArgumentException if base64 decoding fails or key + * derivation fails + */ + public static byte[] deriveKey(String rawKey) { + byte[] rawKeyDec = Base64.getDecoder().decode(rawKey); + + byte[] prefix = RAW_KEY_PREFIX_V2.getBytes(StandardCharsets.US_ASCII); + if (rawKeyDec.length >= prefix.length) { + byte[] maybePrefix = Arrays.copyOf(rawKeyDec, prefix.length); + if (Arrays.equals(maybePrefix, prefix)) { + byte[] rest = Arrays.copyOfRange(rawKeyDec, prefix.length, rawKeyDec.length); + rawKeyDec = deriveKeyV2(rest); + } + } + + return deriveKeyV1(rawKeyDec); + } +} diff --git a/qmc-java/src/main/java/qmc/MapCipher.java b/qmc-java/src/main/java/qmc/MapCipher.java new file mode 100644 index 0000000..7f27186 --- /dev/null +++ b/qmc-java/src/main/java/qmc/MapCipher.java @@ -0,0 +1,66 @@ +package qmc; + +/** + * Implements the map cipher used for QMC1, qmcflac, and qmcogg files + * where the derived key length is between 1 and 300 bytes. The cipher + * uses a key-driven lookup table with a rotation operation. + * + *

The mask at each byte position is produced by looking up a key byte + * at index {@code (offset² + 71214) % key.length}, + * rotating the byte by {@code (idx & 7 + 4) % 8} + * positions, and applying the result as an XOR mask. + */ +public class MapCipher { + + private final byte[] key; + + /** + * Constructs a {@code MapCipher} with the given key material. + * The key array is defensively copied. + * + * @param key the key bytes; the key length determines the cipher behavior + */ + public MapCipher(byte[] key) { + this.key = key.clone(); + } + + /** + * Rotates a byte value by the specified number of bits plus four. + * The effective rotation is {@code (bits + 4) % 8}. + * + * @param value the byte value to rotate + * @param bits the base rotation amount + * @return the rotated byte value + */ + private static byte rotate(byte value, int bits) { + int rot = (bits + 4) % 8; + int v = value & 0xFF; + return (byte) ((v << rot) | (v >>> rot)); + } + + /** + * Computes the mask byte for the given file offset. + * + * @param offset the file offset (values above {@code 0x7FFF} are reduced + * modulo {@code 0x7FFF}) + * @return the mask byte + */ + private int mask(int offset) { + int off = offset > 0x7FFF ? offset % 0x7FFF : offset; + int idx = (off * off + 71214) % key.length; + return rotate(key[idx], idx & 0x07) & 0xFF; + } + + /** + * XOR-decrypts a buffer using the map cipher. Each byte position is + * XORed with the mask byte computed for that position. + * + * @param buf the buffer to decrypt in-place + * @param offset the starting file offset for mask generation + */ + public void decrypt(byte[] buf, int offset) { + for (int i = 0; i < buf.length; i++) { + buf[i] ^= (byte) mask(offset + i); + } + } +} diff --git a/qmc-java/src/main/java/qmc/QmcDecryptor.java b/qmc-java/src/main/java/qmc/QmcDecryptor.java new file mode 100644 index 0000000..38bce2d --- /dev/null +++ b/qmc-java/src/main/java/qmc/QmcDecryptor.java @@ -0,0 +1,229 @@ +package qmc; + +import java.util.Arrays; + +/** + * Selects the appropriate cipher based on the derived key length and + * provides a unified API for decrypting QQMusic encrypted audio data. + * + *

Cipher selection rules: + *

    + *
  • Derived key length greater than 300 results in an + * {@link CipherType.Rc4}.
  • + *
  • Derived key length between 1 and 300 inclusive results in a + * {@link CipherType.Map}.
  • + *
  • An empty derived key (length 0) results in a + * {@link CipherType.Static}.
  • + *
+ * + * @param cipher the cipher instance selected for this decryption + */ +public record QmcDecryptor(CipherType cipher) { + + /** + * Convenience constructor that derives the cipher key from the + * provided key material and selects the appropriate cipher. + * + * @param derivedKey the derived key bytes + */ + public QmcDecryptor(byte[] derivedKey) { + this(createCipher(derivedKey)); + } + + /** + * Creates a {@code QmcDecryptor} from a base64-encoded ekey string. + * + * @param ekey the base64-encoded ekey string + * @return a new {@code QmcDecryptor} with the cipher derived from + * the ekey + */ + public static QmcDecryptor fromEKey(String ekey) { + byte[] derived = KeyDerivation.deriveKey(ekey); + return new QmcDecryptor(derived); + } + + /** + * Selects the cipher type based on the derived key length. + * + * @param key the derived key bytes + * @return the appropriate {@code CipherType} for the key + */ + private static CipherType createCipher(byte[] key) { + if (key.length > 300) { + return new CipherType.Rc4(new Rc4Cipher(key)); + } else if (key.length > 0) { + return new CipherType.Map(new MapCipher(key)); + } else { + return new CipherType.Static(); + } + } + + /** + * Decrypts the entire data buffer starting from file offset 0. + * + * @param data the data buffer to decrypt + * @return a new byte array containing the decrypted data + */ + public byte[] decrypt(byte[] data) { + return decrypt(data, 0); + } + + /** + * Decrypts a data buffer starting at the given file offset. + * Each byte's XOR mask depends only on its file position, so any + * range of data can be decrypted independently. + * + * @param data the data buffer to decrypt + * @param offset the file offset at which {@code data} starts + * @return a new byte array containing the decrypted data + */ + public byte[] decrypt(byte[] data, int offset) { + byte[] result = data.clone(); + cipher.decrypt(result, offset); + return result; + } + + /** + * Represents a cipher type. Implementations define the + * XOR-decryption behavior for each cipher variant. + */ + public interface CipherType { + + /** + * XOR-decrypts the buffer in-place at the given file offset. + * + * @param buf the buffer to decrypt + * @param offset the file offset + */ + void decrypt(byte[] buf, int offset); + + /** + * Static cipher implementation. Applies the fixed substitution + * box mask for legacy QMC files. + */ + final class Static implements CipherType { + @Override + public void decrypt(byte[] buf, int offset) { + StaticCipher.decrypt(buf, offset); + } + } + + /** + * Map cipher implementation. Applies the key-driven lookup table + * with rotation for keys of length 1 through 300. + */ + final class Map implements CipherType { + private final MapCipher cipher; + + /** + * Constructs a {@code Map} cipher type wrapping the given + * map cipher instance. + * + * @param cipher the map cipher instance + */ + Map(MapCipher cipher) { + this.cipher = cipher; + } + + @Override + public void decrypt(byte[] buf, int offset) { + cipher.decrypt(buf, offset); + } + } + + /** + * RC4 cipher implementation. Applies the segmented RC4 stream + * cipher for keys longer than 300 bytes. + */ + final class Rc4 implements CipherType { + private final Rc4Cipher cipher; + + /** + * Constructs an {@code Rc4} cipher type wrapping the given + * RC4 cipher instance. + * + * @param cipher the RC4 cipher instance + */ + Rc4(Rc4Cipher cipher) { + this.cipher = cipher; + } + + @Override + public void decrypt(byte[] buf, int offset) { + cipher.decrypt(buf, offset); + } + } + } + + /** + * Decrypts MFLAC data using the specified ekey and returns the + * decrypted FLAC bytes. Trailing non-audio data appended by QQMusic + * is automatically removed. + * + * @param data the encrypted MFLAC file data + * @param ekey the base64-encoded ekey string + * @return the decrypted FLAC bytes with trailing garbage removed + */ + public static byte[] decryptMflac(byte[] data, String ekey) { + byte[] result = fromEKey(ekey).decrypt(data); + return trimFlacTrailing(result); + } + + /** + * Removes trailing bytes that do not belong to any valid FLAC audio + * frame. QQMusic encrypted files may contain trailing data after the + * last audio frame; these bytes cause decoder warnings but do not + * affect playback in most players. + * + *

The method scans for FLAC frame sync markers + * ({@code 0xFF} followed by a byte where + * {@code (byte & 0xFC) == 0xF8}) and discards + * everything from the last sync marker that has fewer than 1000 bytes + * of trailing data. + * + * @param data the input FLAC buffer + * @return a new buffer with trailing non-audio data removed, or the + * original buffer if no truncation is needed + */ + static byte[] trimFlacTrailing(byte[] data) { + if (data.length < 42) return data; + if (data[0] != 0x66 || data[1] != 0x4C || data[2] != 0x61 || data[3] != 0x43) { + return data; + } + + int lastSync = -1; + for (int i = 4; i < data.length - 1; i++) { + if (data[i] == (byte) 0xFF && (data[i + 1] & 0xFC) == 0xF8) { + if (lastSync < 0 || i - lastSync > 1000) { + int remaining = data.length - i; + if (remaining >= 1000) { + lastSync = i; + } + } + } + } + + if (lastSync <= 0) return data; + + int trailing = data.length - lastSync; + if (trailing < 1000) { + return Arrays.copyOf(data, lastSync); + } + + boolean hasSyncInTrailing = false; + for (int i = lastSync + 1000; i < data.length - 1; i++) { + if (data[i] == (byte) 0xFF && (data[i + 1] & 0xFC) == 0xF8) { + int laterRemaining = data.length - i; + if (laterRemaining >= 1000) { + lastSync = i; + hasSyncInTrailing = true; + } + } + } + if (!hasSyncInTrailing && trailing < 10000) { + return Arrays.copyOf(data, lastSync); + } + + return data; + } +} diff --git a/qmc-java/src/main/java/qmc/QmcException.java b/qmc-java/src/main/java/qmc/QmcException.java new file mode 100644 index 0000000..82a2d11 --- /dev/null +++ b/qmc-java/src/main/java/qmc/QmcException.java @@ -0,0 +1,19 @@ +package qmc; + +/** + * Thrown when a QMC decryption operation encounters an error condition. + * This is a runtime exception indicating invalid input data format, + * padding verification failure, or other decryption failure modes. + */ +public class QmcException extends RuntimeException { + + /** + * Constructs a {@code QmcException} with the specified detail message. + * + * @param message the detail message describing the specific failure + * condition + */ + public QmcException(String message) { + super(message); + } +} diff --git a/qmc-java/src/main/java/qmc/Rc4Cipher.java b/qmc-java/src/main/java/qmc/Rc4Cipher.java new file mode 100644 index 0000000..081b80d --- /dev/null +++ b/qmc-java/src/main/java/qmc/Rc4Cipher.java @@ -0,0 +1,201 @@ +package qmc; + +/** + * Implements QQMusic's custom segmented RC4 cipher. The cipher is + * selected when the derived key length exceeds 300 bytes. + * + *

Unlike standard RC4, this cipher processes the data stream in three + * regions: the first 128 bytes use direct key lookup (no PRNG state), + * subsequent segments of 5120 bytes each use a PRNG initialized from a + * cloned Key Scheduling Algorithm (KSA) box with a segment-specific skip, + * and any remaining data after the last complete segment is processed as + * a partial segment. + * + *

The segment skip is computed from a multiplicative hash of the key + * and a floating-point formula: {@code skip(id) = floor(hash / ((id+1) * seed) * 100) % n}, + * where {@code seed} is the key byte at position {@code id % n}. + */ +public class Rc4Cipher { + + private static final int FIRST_SEGMENT_SIZE = 128; + private static final int SEGMENT_SIZE = 5120; + + private final byte[] key; + private final int n; + private final int hash; + private final int[] box; + + /** + * Constructs an {@code Rc4Cipher} from the given key bytes. + * The key is defensively copied. The Key Scheduling Algorithm + * initializes the internal box by filling with + * {@code (i % 256)} and then shuffling using the key bytes. + * + * @param key the key bytes (must not be {@code null}) + */ + public Rc4Cipher(byte[] key) { + this.key = key.clone(); + this.n = key.length; + + if (n == 0) { + this.hash = 0; + this.box = new int[0]; + return; + } + + int[] box = new int[n]; + for (int i = 0; i < n; i++) { + box[i] = i % 256; + } + int j = 0; + for (int i = 0; i < n; i++) { + j = (j + box[i] + (key[i % n] & 0xFF)) % n; + int tmp = box[i]; + box[i] = box[j]; + box[j] = tmp; + } + this.box = box; + this.hash = calcHash(key); + } + + /** + * Computes a multiplicative hash of the key bytes. + * The hash starts at 1 and multiplies by each non-zero key byte. + * The loop breaks when the product wraps to zero or does not increase + * in unsigned comparison. + * + * @param key the key bytes + * @return the computed hash value + */ + private static int calcHash(byte[] key) { + int hash = 1; + for (byte b : key) { + int v = b & 0xFF; + if (v == 0) continue; + int nextHash = hash * v; + if (nextHash == 0 || Integer.compareUnsigned(nextHash, hash) <= 0) { + break; + } + hash = nextHash; + } + return hash; + } + + /** + * Computes the number of PRNG iterations to skip for a given segment. + * When the key byte at {@code id % n} is zero, returns + * {@code Long.MAX_VALUE % n} to match Rust's behavior + * (infinity maps to {@code i64::MAX} before modulo). + * + * @param id the segment identifier + * @return the number of skip iterations + */ + private int segmentSkip(int id) { + if (n == 0) return 0; + int seed = key[id % n] & 0xFF; + + long idx; + if (seed == 0) { + idx = Long.MAX_VALUE; + } else { + double d = (double) (hash & 0xFFFFFFFFL) / ((double) (id + 1) * seed) * 100.0; + idx = (long) d; + } + + return (int) ((idx % n + n) % n); + } + + /** + * Encrypts or decrypts the first segment (bytes 0 through 127) using + * direct key lookup. No PRNG state is involved. + */ + private void encFirstSegment(byte[] buf, int offset) { + if (n == 0) return; + for (int i = 0; i < buf.length; i++) { + buf[i] ^= key[segmentSkip(offset + i) % n]; + } + } + + /** + * Encrypts or decrypts one segment using the cloned KSA box and the + * segment-specific PRNG skip. The box is cloned from the base KSA + * state so that each segment starts from an identical permutation. + */ + private void encASegment(byte[] buf, int offset) { + int[] box = this.box.clone(); + int j = 0, k = 0; + + int skipLen = (offset % SEGMENT_SIZE) + segmentSkip(offset / SEGMENT_SIZE); + + for (int i = -skipLen; i < buf.length; i++) { + j = (j + 1) % n; + k = (box[j] + k) % n; + int tmp = box[j]; + box[j] = box[k]; + box[k] = tmp; + if (i >= 0) { + buf[i] ^= (byte) (box[(box[j] + box[k]) % n] & 0xFF); + } + } + } + + /** + * Decrypts a chunk of data at the given file offset. + * + *

The decryption processes the buffer in regions: + *

    + *
  1. Any portion of the first 128-byte segment.
  2. + *
  3. Alignment to the next 5120-byte segment boundary.
  4. + *
  5. Complete 5120-byte segments.
  6. + *
  7. Remaining partial segment.
  8. + *
+ * + * @param src the data to decrypt in-place + * @param offset the file offset at which {@code src} starts + */ + public void decrypt(byte[] src, int offset) { + int toProcess = src.length; + int processed = 0; + int off = offset; + + if (off < FIRST_SEGMENT_SIZE) { + int blockSize = Math.min(toProcess, FIRST_SEGMENT_SIZE - off); + byte[] chunk = new byte[blockSize]; + System.arraycopy(src, 0, chunk, 0, blockSize); + encFirstSegment(chunk, off); + System.arraycopy(chunk, 0, src, 0, blockSize); + off += blockSize; + processed += blockSize; + if (processed >= toProcess) return; + } + + if (off % SEGMENT_SIZE != 0) { + int remainingInSeg = SEGMENT_SIZE - (off % SEGMENT_SIZE); + int blockSize = Math.min(toProcess - processed, remainingInSeg); + byte[] chunk = new byte[blockSize]; + System.arraycopy(src, processed, chunk, 0, blockSize); + encASegment(chunk, off); + System.arraycopy(chunk, 0, src, processed, blockSize); + off += blockSize; + processed += blockSize; + if (processed >= toProcess) return; + } + + while (toProcess - processed > SEGMENT_SIZE) { + byte[] chunk = new byte[SEGMENT_SIZE]; + System.arraycopy(src, processed, chunk, 0, SEGMENT_SIZE); + encASegment(chunk, off); + System.arraycopy(chunk, 0, src, processed, SEGMENT_SIZE); + off += SEGMENT_SIZE; + processed += SEGMENT_SIZE; + } + + if (processed < toProcess) { + int remaining = toProcess - processed; + byte[] chunk = new byte[remaining]; + System.arraycopy(src, processed, chunk, 0, remaining); + encASegment(chunk, off); + System.arraycopy(chunk, 0, src, processed, remaining); + } + } +} diff --git a/qmc-java/src/main/java/qmc/StaticCipher.java b/qmc-java/src/main/java/qmc/StaticCipher.java new file mode 100644 index 0000000..67bee69 --- /dev/null +++ b/qmc-java/src/main/java/qmc/StaticCipher.java @@ -0,0 +1,74 @@ +package qmc; + +/** + * Implements the static cipher used for legacy QMC encrypted files + * (QMC0, QMC2, QMC3). The cipher uses a fixed 256-byte substitution + * box combined with a position-dependent mask. + * + *

The mask at each byte position is determined as + * {@code S_BOX[(offset * offset + 27) & 0xFF]}, + * where {@code offset} is limited to the range {@code [0, 0x7FFF]}. + */ +public class StaticCipher { + + private static final int[] S_BOX = { + 0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, + 0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, + 0x9E, 0xE6, 0x9D, 0xCF, 0xFA, 0x7F, 0x14, 0xD1, + 0xCE, 0xB8, 0xDC, 0xC3, 0x4A, 0x67, 0x93, 0xD6, + 0x28, 0xC2, 0x91, 0x70, 0xCA, 0x8D, 0xA2, 0xA4, + 0xF0, 0x08, 0x61, 0x90, 0x7E, 0x6F, 0xA2, 0xE0, + 0xEB, 0xAE, 0x3E, 0xB6, 0x67, 0xC7, 0x92, 0xF4, + 0x91, 0xB5, 0xF6, 0x6C, 0x5E, 0x84, 0x40, 0xF7, + 0xF3, 0x1B, 0x02, 0x7F, 0xD5, 0xAB, 0x41, 0x89, + 0x28, 0xF4, 0x25, 0xCC, 0x52, 0x11, 0xAD, 0x43, + 0x68, 0xA6, 0x41, 0x8B, 0x84, 0xB5, 0xFF, 0x2C, + 0x92, 0x4A, 0x26, 0xD8, 0x47, 0x6A, 0x7C, 0x95, + 0x61, 0xCC, 0xE6, 0xCB, 0xBB, 0x3F, 0x47, 0x58, + 0x89, 0x75, 0xC3, 0x75, 0xA1, 0xD9, 0xAF, 0xCC, + 0x08, 0x73, 0x17, 0xDC, 0xAA, 0x9A, 0xA2, 0x16, + 0x41, 0xD8, 0xA2, 0x06, 0xC6, 0x8B, 0xFC, 0x66, + 0x34, 0x9F, 0xCF, 0x18, 0x23, 0xA0, 0x0A, 0x74, + 0xE7, 0x2B, 0x27, 0x70, 0x92, 0xE9, 0xAF, 0x37, + 0xE6, 0x8C, 0xA7, 0xBC, 0x62, 0x65, 0x9C, 0xC2, + 0x08, 0xC9, 0x88, 0xB3, 0xF3, 0x43, 0xAC, 0x74, + 0x2C, 0x0F, 0xD4, 0xAF, 0xA1, 0xC3, 0x01, 0x64, + 0x95, 0x4E, 0x48, 0x9F, 0xF4, 0x35, 0x78, 0x95, + 0x7A, 0x39, 0xD6, 0x6A, 0xA0, 0x6D, 0x40, 0xE8, + 0x4F, 0xA8, 0xEF, 0x11, 0x1D, 0xF3, 0x1B, 0x3F, + 0x3F, 0x07, 0xDD, 0x6F, 0x5B, 0x19, 0x30, 0x19, + 0xFB, 0xEF, 0x0E, 0x37, 0xF0, 0x0E, 0xCD, 0x16, + 0x49, 0xFE, 0x53, 0x47, 0x13, 0x1A, 0xBD, 0xA4, + 0xF1, 0x40, 0x19, 0x60, 0x0E, 0xED, 0x68, 0x09, + 0x06, 0x5F, 0x4D, 0xCF, 0x3D, 0x1A, 0xFE, 0x20, + 0x77, 0xE4, 0xD9, 0xDA, 0xF9, 0xA4, 0x2B, 0x76, + 0x1C, 0x71, 0xDB, 0x00, 0xBC, 0xFD, 0x0C, 0x6C, + 0xA5, 0x47, 0xF7, 0xF6, 0x00, 0x79, 0x4A, 0x11 + }; + + /** + * Computes the static cipher mask byte for the given file offset. + * + * @param offset the file offset (values above {@code 0x7FFF} are reduced + * modulo {@code 0x7FFF}) + * @return the mask byte for the offset + */ + static int staticMask(int offset) { + int off = offset > 0x7FFF ? offset % 0x7FFF : offset; + int idx = (off * off + 27) & 0xFF; + return S_BOX[idx]; + } + + /** + * XOR-decrypts a buffer using the static cipher. Each byte position is + * XORed with the mask byte computed for that position. + * + * @param buf the buffer to decrypt in-place + * @param offset the starting file offset for mask generation + */ + public static void decrypt(byte[] buf, int offset) { + for (int i = 0; i < buf.length; i++) { + buf[i] ^= (byte) staticMask(offset + i); + } + } +} diff --git a/qmc-java/src/main/java/qmc/TeaCipher.java b/qmc-java/src/main/java/qmc/TeaCipher.java new file mode 100644 index 0000000..2347668 --- /dev/null +++ b/qmc-java/src/main/java/qmc/TeaCipher.java @@ -0,0 +1,205 @@ +package qmc; + +import java.util.Arrays; + +/** + * Performs TEA (Tiny Encryption Algorithm) block operations and + * QQMusic-specific TEA-CBC decryption. The cipher uses the QQMusic variant + * of the TEA algorithm with a custom delta constant of {@code 0x9E3779B9} + * and a non-standard CBC padding scheme (salt length 2, zero-check length 7, + * block size 8). + * + *

The QQMusic TEA variant differs from standard TEA in that both halves of + * each block decrypt with the same sum value within an iteration, rather than + * updating the sum between the two half-rounds. + */ +public class TeaCipher { + + private static final int DELTA = 0x9E37_79B9; + + /** + * Encrypts a single 64-bit block using the QQMusic TEA variant. + * Both halves of the round share the same sum value. + * + * @param v the 2-element array containing the 64-bit plaintext block + * (elements are interpreted as 32-bit unsigned integers); + * the array contents are replaced with the encrypted block + * @param key the 4-element TEA key array (32-bit unsigned integers) + */ + public static void encryptBlock(int[] v, int[] key) { + int sum = 0; + int v0 = v[0], v1 = v[1]; + for (int i = 0; i < 16; i++) { + sum += DELTA; + v0 += ((v1 << 4) + key[0]) ^ (v1 + sum) ^ ((v1 >>> 5) + key[1]); + v1 += ((v0 << 4) + key[2]) ^ (v0 + sum) ^ ((v0 >>> 5) + key[3]); + } + v[0] = v0; + v[1] = v1; + } + + /** + * Decrypts a single 64-bit block using the QQMusic TEA variant. + * Both halves of the round share the same sum value. + * + * @param v the 2-element array containing the 64-bit ciphertext block + * (elements are interpreted as 32-bit unsigned integers); + * the array contents are replaced with the decrypted block + * @param key the 4-element TEA key array (32-bit unsigned integers) + */ + public static void decryptBlock(int[] v, int[] key) { + //noinspection NumericOverflow + int sum = DELTA * 16; + int v0 = v[0], v1 = v[1]; + for (int i = 0; i < 16; i++) { + v1 -= ((v0 << 4) + key[2]) ^ (v0 + sum) ^ ((v0 >>> 5) + key[3]); + v0 -= ((v1 << 4) + key[0]) ^ (v1 + sum) ^ ((v1 >>> 5) + key[1]); + sum -= DELTA; + } + v[0] = v0; + v[1] = v1; + } + + /** + * Reads a 32-bit unsigned integer from a byte array in big-endian byte + * order. The four bytes starting at {@code off} are combined into a single + * integer, with the byte at index {@code off} becoming the most significant + * byte. + * + * @param b the byte array to read from + * @param off the starting offset within {@code b} (must be at least 3 less + * than the length of {@code b}) + * @return the 32-bit unsigned integer formed from the four bytes + */ + public static int fromBigEndian(byte[] b, int off) { + return (b[off] & 0xFF) << 24 + | (b[off + 1] & 0xFF) << 16 + | (b[off + 2] & 0xFF) << 8 + | (b[off + 3] & 0xFF); + } + + /** + * Converts a 32-bit unsigned integer to a 4-element byte array in + * big-endian byte order. The most significant byte of the integer + * is placed at index 0 of the returned array. + * + * @param v the 32-bit unsigned integer to convert + * @return a 4-element byte array containing the big-endian representation + * of {@code v} + */ + public static byte[] toBigEndian(int v) { + return new byte[]{ + (byte) (v >>> 24), + (byte) (v >>> 16), + (byte) (v >>> 8), + (byte) v + }; + } + + /** + * Performs QQMusic-specific TEA-CBC decryption with custom padding. + * The input buffer must be a multiple of the block size (8 bytes). + * Decrypted output layout per block consists of the following fields: + * [padding(1)] [salt(2)] [plaintext] [zero-check(7)]. + * + *

The decryption process decrypts the first block, extracts a padding + * length from the least significant 3 bits of the first byte, skips two + * salt bytes, extracts the plaintext bytes with IV XOR, and verifies + * the trailing zero-check region. + * + * @param inBuf the ciphertext buffer (length must be a multiple of 8 and + * at least 16) + * @param key the 16-byte TEA key + * @return the decrypted plaintext (salt bytes removed, zero-check verified) + * @throws QmcException if the input size is invalid, the computed output + * length is negative, or the zero-check verification fails + */ + public static byte[] decryptTencentTea(byte[] inBuf, byte[] key) { + final int SALT_LEN = 2; + final int ZERO_LEN = 7; + + if (inBuf.length % 8 != 0) { + throw new QmcException("inBuf size not a multiple of the block size"); + } + if (inBuf.length < 16) { + throw new QmcException("inBuf size too small"); + } + + int[] key32 = new int[4]; + key32[0] = fromBigEndian(key, 0); + key32[1] = fromBigEndian(key, 4); + key32[2] = fromBigEndian(key, 8); + key32[3] = fromBigEndian(key, 12); + + byte[] destBuf = decryptOneBlock(inBuf, key32); + int padLen = destBuf[0] & 0x07; + int outLen = inBuf.length - 1 - padLen - SALT_LEN - ZERO_LEN; + if (outLen < 0) { + throw new QmcException("invalid outLen from padding: " + outLen); + } + byte[] out = new byte[outLen]; + + byte[] ivPrev = new byte[8]; + byte[] ivCur = Arrays.copyOfRange(inBuf, 0, 8); + int pos = 8; + int destIdx = 1 + padLen; + + // Skip salt + int i = 1; + while (i <= SALT_LEN) { + if (destIdx < 8) { + destIdx++; + i++; + } else { + destBuf = cryptBlock(inBuf, pos, destBuf, key32); + ivPrev = ivCur; + ivCur = Arrays.copyOfRange(inBuf, pos, pos + 8); + pos += 8; + destIdx = 0; + } + } + + // Read output + int outPos = 0; + while (outPos < outLen) { + if (destIdx < 8) { + out[outPos] = (byte) (destBuf[destIdx] ^ ivPrev[destIdx]); + destIdx++; + outPos++; + } else { + destBuf = cryptBlock(inBuf, pos, destBuf, key32); + ivPrev = ivCur; + ivCur = Arrays.copyOfRange(inBuf, pos, pos + 8); + pos += 8; + destIdx = 0; + } + } + + // Verify zero bytes + for (int z = 0; z < ZERO_LEN; z++) { + if (destBuf[destIdx] != ivPrev[destIdx]) { + throw new QmcException("zero check failed"); + } + destIdx++; + } + + return out; + } + + private static byte[] decryptOneBlock(byte[] src, int[] key32) { + int[] v = new int[]{fromBigEndian(src, 0), fromBigEndian(src, 4)}; + decryptBlock(v, key32); + byte[] block = new byte[8]; + System.arraycopy(toBigEndian(v[0]), 0, block, 0, 4); + System.arraycopy(toBigEndian(v[1]), 0, block, 4, 4); + return block; + } + + private static byte[] cryptBlock(byte[] inBuf, int pos, byte[] destBuf, int[] key32) { + byte[] xored = new byte[8]; + for (int j = 0; j < 8; j++) { + xored[j] = (byte) (destBuf[j] ^ inBuf[pos + j]); + } + return decryptOneBlock(xored, key32); + } +} diff --git a/qmc-java/src/test/java/qmc/QmcDecryptorTest.java b/qmc-java/src/test/java/qmc/QmcDecryptorTest.java new file mode 100644 index 0000000..7e5ebf9 --- /dev/null +++ b/qmc-java/src/test/java/qmc/QmcDecryptorTest.java @@ -0,0 +1,210 @@ +package qmc; + +import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +class QmcDecryptorTest { + + // ---- simpleMakeKey ---- + + @Test + void testSimpleMakeKey() { + byte[] key = KeyDerivation.simpleMakeKey((byte) 106, 8); + assertEquals(8, key.length); + byte expectedFirst = (byte) ((int) (Math.abs(Math.tan(106 + 0 * 0.1)) * 100.0) & 0xFF); + assertEquals(expectedFirst, key[0]); + } + + // ---- TEA roundtrip ---- + + @Test + void testTeaRoundtrip() { + int[] key = {0x01234567, 0x89ABCDEF, 0xFEDCBA98, 0x76543210}; + int[] v = {0x12345678, 0x9ABCDEF0}; + int[] original = v.clone(); + TeaCipher.encryptBlock(v, key); + TeaCipher.decryptBlock(v, key); + assertArrayEquals(original, v); + + int[] key0 = {0, 0, 0, 0}; + int[] v0 = {0, 0}; + TeaCipher.encryptBlock(v0, key0); + TeaCipher.decryptBlock(v0, key0); + assertArrayEquals(new int[]{0, 0}, v0); + } + + // ---- Static cipher ---- + + @Test + void testStaticCipher() { + byte[] buf = new byte[16]; + Arrays.fill(buf, (byte) 0xAB); + byte[] original = buf.clone(); + StaticCipher.decrypt(buf, 0); + StaticCipher.decrypt(buf, 0); + assertArrayEquals(original, buf); + } + + // ---- Map cipher ---- + + @Test + void testMapCipher() { + byte[] key = {0x12, 0x34, 0x56, 0x78, (byte) 0x9A, (byte) 0xBC, (byte) 0xDE, (byte) 0xF0}; + MapCipher cipher = new MapCipher(key); + byte[] buf = new byte[16]; + Arrays.fill(buf, (byte) 0xFF); + byte[] original = buf.clone(); + cipher.decrypt(buf, 0); + cipher.decrypt(buf, 0); + assertArrayEquals(original, buf); + } + + // ---- RC4 cipher ---- + + @Test + void testRc4Cipher() { + byte[] key = new byte[256]; + for (int i = 0; i < 256; i++) key[i] = (byte) i; + Rc4Cipher cipher = new Rc4Cipher(key); + byte[] buf = new byte[128]; + Arrays.fill(buf, (byte) 0xAB); + byte[] original = buf.clone(); + cipher.decrypt(buf, 0); + cipher.decrypt(buf, 0); + assertArrayEquals(original, buf); + } + + @Test + void testRc4Known() { + byte[] key = {0x4B, 0x65, 0x79}; + byte[] data = "Plaintext".getBytes(); + byte[] buf = data.clone(); + + Rc4Cipher cipher = new Rc4Cipher(key.clone()); + cipher.decrypt(buf, 0); + + // RC4 is symmetric + Rc4Cipher cipher2 = new Rc4Cipher(key.clone()); + byte[] buf2 = buf.clone(); + cipher2.decrypt(buf2, 0); + assertArrayEquals(data, buf2, "RC4 double decrypt should return to original"); + + assertFalse(Arrays.equals(buf, data), "RC4 should change the data"); + } + + @Test + void testRc4Segment() { + byte[] key = {0x4B, 0x65, 0x79}; + byte[] data = "This is a longer test string that spans multiple RC4 segments!".getBytes(); + byte[] buf1 = data.clone(); + + Rc4Cipher cipher = new Rc4Cipher(key.clone()); + cipher.decrypt(buf1, 0); + + Rc4Cipher cipher2 = new Rc4Cipher(key.clone()); + cipher2.decrypt(buf1, 0); + assertArrayEquals(data, buf1, "RC4 segment roundtrip failed"); + } + + // ---- Key derivation ---- + + @Test + void testDeriveKeyV1Short() { + byte[] shortKey = new byte[8]; + assertThrows(QmcException.class, () -> KeyDerivation.deriveKeyV1(shortKey)); + } + + @Test + void testStaticMaskValues() { + // idx = (0 + 27) & 0xFF = 27 → S_BOX[27] = 0xC3 + assertEquals(0xC3, StaticCipher.staticMask(0)); + // idx = (1 + 27) & 0xFF = 28 → S_BOX[28] = 0x4A + assertEquals(0x4A, StaticCipher.staticMask(1)); + // off=0x7FFF, idx = (0x7FFF^2 + 27) & 0xFF = 28 → 0x4A + assertEquals(0x4A, StaticCipher.staticMask(0x7FFF)); + // off=0x8000 → modulo → 1, idx = 28 → 0x4A + assertEquals(0x4A, StaticCipher.staticMask(0x8000)); + } + + // ---- CipherType selection ---- + + @Test + void testCipherSelectionStatic() { + QmcDecryptor d = new QmcDecryptor(new byte[0]); + assertInstanceOf(QmcDecryptor.CipherType.Static.class, d.cipher()); + } + + @Test + void testCipherSelectionMap() { + QmcDecryptor d = new QmcDecryptor(new byte[]{1, 2, 3}); + assertInstanceOf(QmcDecryptor.CipherType.Map.class, d.cipher()); + } + + @Test + void testCipherSelectionRc4() { + byte[] longKey = new byte[301]; + Arrays.fill(longKey, (byte) 0x42); + QmcDecryptor d = new QmcDecryptor(longKey); + assertInstanceOf(QmcDecryptor.CipherType.Rc4.class, d.cipher()); + } + + // ---- TEA-CBC decryption ---- + + @Test + void testDecryptTencentTea() { + byte[] key = { + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F + }; + + // Error: input not multiple of 8 + byte[] badLen = new byte[10]; + assertThrows(QmcException.class, () -> TeaCipher.decryptTencentTea(badLen, key)); + + // Error: input too small (< 16) + byte[] shortBuf = new byte[8]; + assertThrows(QmcException.class, () -> TeaCipher.decryptTencentTea(shortBuf, key)); + + // Valid: 16 bytes (2 blocks), should throw QmcException + // (random data won't have valid QQMusic TEA-CBC structure) + byte[] valid16 = { + 0x4E, (byte) 0x9B, 0x32, 0x39, + 0x12, 0x34, 0x56, 0x78, + 0x12, 0x34, 0x56, 0x78, + (byte) 0x9A, (byte) 0xBC, (byte) 0xDE, (byte) 0xF0 + }; + assertThrows(QmcException.class, () -> TeaCipher.decryptTencentTea(valid16, key)); + } + + // ---- Real ekey decryption (end-to-end) ---- + + @Test + void testRealEkey() throws IOException { + var dataPath = Paths.get("f:/qqmusic_debug/test_raw.mflac"); + var ekeyPath = Paths.get("f:/qqmusic_debug/test_ekey.txt"); + + if (!Files.exists(dataPath) || !Files.exists(ekeyPath)) { + System.err.println("test files missing, skipping testRealEkey"); + return; + } + + byte[] data = Files.readAllBytes(dataPath); + String ekey = Files.readString(ekeyPath).trim(); + + byte[] result = QmcDecryptor.decryptMflac(data, ekey); + assertTrue(result.length > 4, "decrypted too short"); + + byte[] flacHeader = {0x66, 0x4C, 0x61, 0x43}; // "fLaC" + byte[] actualHeader = Arrays.copyOf(result, 4); + assertArrayEquals(flacHeader, actualHeader, "not a FLAC header"); + + System.out.println("MFLAC decryption OK: " + result.length + " bytes"); + + Files.write(Paths.get("f:/qqmusic_debug/decoded_java.flac"), result); + } +} diff --git a/qmc-rust/.gitignore b/qmc-rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/qmc-rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/qmc-rust/Cargo.lock b/qmc-rust/Cargo.lock new file mode 100644 index 0000000..17a100c --- /dev/null +++ b/qmc-rust/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "qmc_debug" +version = "0.1.0" +dependencies = [ + "base64", +] diff --git a/qmc-rust/Cargo.toml b/qmc-rust/Cargo.toml new file mode 100644 index 0000000..234a862 --- /dev/null +++ b/qmc-rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "qmc_debug" +version = "0.1.0" +edition = "2021" + +[dependencies] +base64 = "0.22" diff --git a/qmc-rust/src/main.rs b/qmc-rust/src/main.rs new file mode 100644 index 0000000..5c5832e --- /dev/null +++ b/qmc-rust/src/main.rs @@ -0,0 +1,358 @@ +use base64::Engine; +use std::fs; + +// ============================================================ +// Key Derivation (from qmc.rs) +// ============================================================ + +fn simple_make_key(salt: u8, length: usize) -> Vec { + let mut key = vec![0u8; length]; + for i in 0..length { + let tmp = (salt as f64 + i as f64 * 0.1).tan(); + key[i] = (tmp.abs() * 100.0) as u8; + } + key +} + +fn tea_decrypt_block(v: &mut [u32; 2], key: &[u32; 4]) { + let delta: u32 = 0x9E37_79B9; + let mut sum: u32 = delta.wrapping_mul(16); + let (mut v0, mut v1) = (v[0], v[1]); + for _ in 0..16 { + v1 = v1.wrapping_sub( + ((v0 << 4).wrapping_add(key[2])) ^ (v0.wrapping_add(sum)) ^ ((v0 >> 5).wrapping_add(key[3])) + ); + v0 = v0.wrapping_sub( + ((v1 << 4).wrapping_add(key[0])) ^ (v1.wrapping_add(sum)) ^ ((v1 >> 5).wrapping_add(key[1])) + ); + sum = sum.wrapping_sub(delta); + } + v[0] = v0; + v[1] = v1; +} + +fn decrypt_tencent_tea(in_buf: &[u8], key: &[u8]) -> Result, &'static str> { + const SALT_LEN: usize = 2; + const ZERO_LEN: usize = 7; + if in_buf.len() % 8 != 0 { return Err("inBuf size not a multiple of the block size"); } + if in_buf.len() < 16 { return Err("inBuf size too small"); } + + let key32: [u32; 4] = [ + u32::from_be_bytes(key[0..4].try_into().unwrap()), + u32::from_be_bytes(key[4..8].try_into().unwrap()), + u32::from_be_bytes(key[8..12].try_into().unwrap()), + u32::from_be_bytes(key[12..16].try_into().unwrap()), + ]; + + let mut dest_buf = [0u8; 8]; + let mut v = [ + u32::from_be_bytes(in_buf[0..4].try_into().unwrap()), + u32::from_be_bytes(in_buf[4..8].try_into().unwrap()), + ]; + tea_decrypt_block(&mut v, &key32); + dest_buf[0..4].copy_from_slice(&v[0].to_be_bytes()); + dest_buf[4..8].copy_from_slice(&v[1].to_be_bytes()); + + let pad_len = (dest_buf[0] & 0x07) as usize; + let out_len = in_buf.len() - 1 - pad_len - SALT_LEN - ZERO_LEN; + let mut out = vec![0u8; out_len]; + let mut iv_prev = [0u8; 8]; + let mut iv_cur: [u8; 8] = in_buf[0..8].try_into().unwrap(); + let mut pos = 8usize; + let mut dest_idx = 1 + pad_len; + + macro_rules! crypt_block { + () => { + iv_prev = iv_cur; + iv_cur = in_buf[pos..pos + 8].try_into().unwrap(); + let xored: [u8; 8] = std::array::from_fn(|j| dest_buf[j] ^ in_buf[pos + j]); + dest_buf = xored; + let mut v = [ + u32::from_be_bytes(dest_buf[0..4].try_into().unwrap()), + u32::from_be_bytes(dest_buf[4..8].try_into().unwrap()), + ]; + tea_decrypt_block(&mut v, &key32); + dest_buf[0..4].copy_from_slice(&v[0].to_be_bytes()); + dest_buf[4..8].copy_from_slice(&v[1].to_be_bytes()); + pos += 8; + }; + } + + let mut i = 1usize; + while i <= SALT_LEN { + if dest_idx < 8 { dest_idx += 1; i += 1; } + else { crypt_block!(); dest_idx = 0; } + } + + let mut out_pos = 0; + while out_pos < out_len { + if dest_idx < 8 { + out[out_pos] = dest_buf[dest_idx] ^ iv_prev[dest_idx]; + dest_idx += 1; + out_pos += 1; + } else { crypt_block!(); dest_idx = 0; } + } + + for _ in 0..ZERO_LEN { + if dest_buf[dest_idx] != iv_prev[dest_idx] { return Err("zero check failed"); } + dest_idx += 1; + } + Ok(out) +} + +fn derive_key_v1(raw_key_dec: &[u8]) -> Result, &'static str> { + if raw_key_dec.len() < 16 { return Err("key length is too short"); } + let simple_key = simple_make_key(106, 8); + let mut tea_key = vec![0u8; 16]; + for i in 0..8 { + tea_key[i << 1] = simple_key[i]; + tea_key[(i << 1) + 1] = raw_key_dec[i]; + } + let ciphertext = &raw_key_dec[8..]; + let rs = if ciphertext.len() % 8 != 0 { + let mut padded = ciphertext.to_vec(); + padded.resize(((ciphertext.len() + 7) / 8) * 8, 0); + decrypt_tencent_tea(&padded, &tea_key)? + } else { + decrypt_tencent_tea(ciphertext, &tea_key)? + }; + let mut result = raw_key_dec[..8].to_vec(); + result.extend_from_slice(&rs); + Ok(result) +} + +const DERIVE_V2_KEY1: [u8; 16] = [ + 0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40, + 0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28, +]; +const DERIVE_V2_KEY2: [u8; 16] = [ + 0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25, + 0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54, +]; + +fn derive_key_v2(raw: &[u8]) -> Result, &'static str> { + let buf = decrypt_tencent_tea(raw, &DERIVE_V2_KEY1)?; + let buf = decrypt_tencent_tea(&buf, &DERIVE_V2_KEY2)?; + let engine = base64::engine::general_purpose::STANDARD; + let decoded = engine.decode(&buf).map_err(|_| "base64 decode failed in V2")?; + Ok(decoded) +} + +fn derive_key(raw_key: &str) -> Result, String> { + let engine = base64::engine::general_purpose::STANDARD; + let raw_key_dec = engine.decode(raw_key).map_err(|e| format!("base64 decode: {}", e))?; + let raw_key_dec = if let Some(rest) = raw_key_dec.strip_prefix(b"QQMusic EncV2,Key:") { + derive_key_v2(rest).map_err(|e| format!("deriveKeyV2: {}", e))? + } else { + raw_key_dec + }; + derive_key_v1(&raw_key_dec).map_err(|e| format!("deriveKeyV1: {}", e)) +} + +// ============================================================ +// RC4 Cipher (from qmc.rs) +// ============================================================ + +struct Rc4Cipher { + key: Vec, + n: usize, + hash: u32, + box_: Vec, +} + +impl Rc4Cipher { + fn new(key: Vec) -> Self { + let n = key.len(); + if n == 0 { + return Rc4Cipher { key: vec![0], n: 1, hash: 0, box_: vec![0] }; + } + let mut box_: Vec = (0..n).map(|i| i as u8).collect(); + let mut j: usize = 0; + for i in 0..n { + j = (j + box_[i] as usize + key[i % n] as usize) % n; + box_.swap(i, j); + } + let hash = Self::calc_hash(&key); + Rc4Cipher { key, n, hash, box_ } + } + + fn calc_hash(key: &[u8]) -> u32 { + let mut hash: u32 = 1; + for i in 0..key.len() { + let v = key[i] as u32; + if v == 0 { continue; } + let next_hash = hash.wrapping_mul(v); + if next_hash == 0 || next_hash <= hash { break; } + hash = next_hash; + } + hash + } + + fn segment_skip(&self, id: usize) -> usize { + if self.n == 0 { return 0; } + let seed = self.key[id % self.n] as usize; + let idx = (self.hash as f64 / ((id + 1) * seed) as f64 * 100.0) as i64; + (idx % self.n as i64) as usize + } + + fn enc_a_segment(&self, buf: &mut [u8], offset: usize, debug: bool) { + let mut box_: Vec = self.box_.clone(); + let mut j: usize = 0; + let mut k: usize = 0; + let skip_len = (offset % 5120) + self.segment_skip(offset / 5120); + let total = buf.len(); + + if debug { + let seg_id = offset / 5120; + let ss = self.segment_skip(seg_id); + eprintln!( + "RUST seg: offset={}, buf.len={}, skip_len={}, segment_skip({})={}", + offset, total, skip_len, seg_id, ss + ); + } + + for i in -(skip_len as i64)..total as i64 { + j = (j + 1) % self.n; + k = (box_[j] as usize + k) % self.n; + box_.swap(j, k); + if i >= 0 { + buf[i as usize] ^= box_[(box_[j] as usize + box_[k] as usize) % self.n]; + if debug && i < 16 { + let mask = box_[(box_[j] as usize + box_[k] as usize) % self.n]; + eprintln!("RUST mask[{}] = {}", offset + i as usize, mask); + } + } + } + } + + fn enc_first_segment(&self, buf: &mut [u8], offset: usize) { + if self.n == 0 { return; } + for i in 0..buf.len() { + buf[i] ^= self.key[self.segment_skip(offset + i) % self.n]; + } + } + + fn decrypt(&self, src: &mut [u8], offset: usize) { + const FIRST_SEGMENT_SIZE: usize = 128; + const SEGMENT_SIZE: usize = 5120; + + let to_process = src.len(); + let mut processed = 0; + let mut off = offset; + + if off < FIRST_SEGMENT_SIZE { + let block_size = to_process.min(FIRST_SEGMENT_SIZE - off); + self.enc_first_segment(&mut src[..block_size], off); + eprintln!("RUST first seg: {}..{}", off, off + block_size); + off += block_size; + processed += block_size; + if processed >= to_process { return; } + } + + if off % SEGMENT_SIZE != 0 { + let remaining_in_seg = SEGMENT_SIZE - (off % SEGMENT_SIZE); + let block_size = (to_process - processed).min(remaining_in_seg); + self.enc_a_segment(&mut src[processed..processed + block_size], off, true); + eprintln!("RUST align seg: {}..{}", off, off + block_size); + off += block_size; + processed += block_size; + if processed >= to_process { return; } + } + + let mut seg_count = 0; + while to_process - processed > SEGMENT_SIZE { + if seg_count < 2 { + eprintln!("RUST full seg #{}: {}..{}", seg_count, off, off + SEGMENT_SIZE); + self.enc_a_segment(&mut src[processed..processed + SEGMENT_SIZE], off, true); + } else { + self.enc_a_segment(&mut src[processed..processed + SEGMENT_SIZE], off, false); + } + off += SEGMENT_SIZE; + processed += SEGMENT_SIZE; + seg_count += 1; + } + + if processed < to_process { + eprintln!("RUST final seg: {}..{}", off, off + (to_process - processed)); + self.enc_a_segment(&mut src[processed..], off, true); + } + } +} + +// ============================================================ +// Map Cipher (from qmc.rs) +// ============================================================ + +struct MapCipher { + key: Vec, +} + +impl MapCipher { + fn new(key: Vec) -> Self { + MapCipher { key } + } + + fn map_rotate(value: u8, bits: u8) -> u8 { + let rot = (bits + 4) % 8; + (value << rot) | (value >> rot) + } + + fn mask(&self, offset: usize) -> u8 { + let off = if offset > 0x7FFF { offset % 0x7FFF } else { offset }; + let idx = (off.wrapping_mul(off).wrapping_add(71214)) % self.key.len(); + Self::map_rotate(self.key[idx], (idx as u8) & 0x07) + } + + fn decrypt(&self, buf: &mut [u8], offset: usize) { + for i in 0..buf.len() { + buf[i] ^= self.mask(offset + i); + } + } +} + +fn main() { + let ekey_path = r"f:\qqmusic_debug\test_ekey.txt"; + let data_path = r"f:\qqmusic_debug\test_raw.mflac"; + let output_path = r"f:\qqmusic_debug\decoded_rust.flac"; + + let ekey = fs::read_to_string(ekey_path).expect("read ekey").trim().to_string(); + let data = fs::read(data_path).expect("read data"); + + // 1. Derive key + let derived = derive_key(&ekey).expect("derive_key failed"); + eprintln!("RUST derived key (len={}): {:02x?}", derived.len(), &derived); + eprintln!("RUST cipher type: {}", + if derived.len() > 300 { "RC4" } + else if derived.len() > 0 { "Map" } + else { "Static" } + ); + + // 2. Determine cipher + let mut result = data.clone(); + if derived.len() > 300 { + let cipher = Rc4Cipher::new(derived.clone()); + eprintln!("RUST RC4: n={}, hash={}", cipher.n, cipher.hash); + eprintln!("RUST KSA box (first 32): {:02x?}", &cipher.box_[..32.min(cipher.n)]); + + // Print segment_skip values for first 10 IDs + for id in 0..10 { + let seed = cipher.key[id % cipher.n] as usize; + let ss = cipher.segment_skip(id); + eprintln!("RUST segment_skip({}): seed={}, result={}", id, seed, ss); + } + + cipher.decrypt(&mut result, 0); + } else if derived.len() > 0 { + let cipher = MapCipher::new(derived.clone()); + eprintln!("RUST Map cipher: key.len={}", derived.len()); + cipher.decrypt(&mut result, 0); + } else { + eprintln!("RUST Static cipher"); + // static_decrypt not needed for this diagnostic + } + + // 3. Write output + fs::write(output_path, &result).expect("write output"); + eprintln!("RUST wrote {} bytes", result.len()); + eprintln!("RUST first 4 bytes: {:02x?}", &result[..4]); +}