init
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Build artifacts
|
||||
build/
|
||||
*.exe
|
||||
*.o
|
||||
*.obj
|
||||
*.a
|
||||
*.so
|
||||
*.dll
|
||||
|
||||
# Editor/IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
#ifndef KEY_DERIVATION_H
|
||||
#define KEY_DERIVATION_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/**
|
||||
* @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 */
|
||||
@@ -0,0 +1,51 @@
|
||||
#ifndef MAP_CIPHER_H
|
||||
#define MAP_CIPHER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/**
|
||||
* @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 */
|
||||
@@ -0,0 +1,64 @@
|
||||
#ifndef QMC_DECRYPTOR_H
|
||||
#define QMC_DECRYPTOR_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/**
|
||||
* @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 */
|
||||
@@ -0,0 +1,58 @@
|
||||
#ifndef RC4_CIPHER_H
|
||||
#define RC4_CIPHER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/**
|
||||
* @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 */
|
||||
@@ -0,0 +1,30 @@
|
||||
#ifndef STATIC_CIPHER_H
|
||||
#define STATIC_CIPHER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/**
|
||||
* @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 */
|
||||
@@ -0,0 +1,59 @@
|
||||
#ifndef TEA_CIPHER_H
|
||||
#define TEA_CIPHER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/**
|
||||
* @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 */
|
||||
@@ -0,0 +1,192 @@
|
||||
#include "key_derivation.h"
|
||||
#include "tea_cipher.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <ctype.h>
|
||||
|
||||
/* ---- 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#include "map_cipher.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#include "rc4_cipher.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "tea_cipher.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Cabal build artifacts
|
||||
dist-newstyle/
|
||||
*.hi
|
||||
*.o
|
||||
*.dyn_hi
|
||||
*.dyn_o
|
||||
|
||||
# Editor/IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.flechazo</groupId>
|
||||
<artifactId>qmc-java</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>25</maven.compiler.source>
|
||||
<maven.compiler.target>25</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Key derivation flow:
|
||||
* <ol>
|
||||
* <li>Base64-decode the ekey string.</li>
|
||||
* <li>If the decoded data starts with the V2 prefix, strip the prefix,
|
||||
* apply two QQMusic TEA-CBC decryption layers, then base64-decode
|
||||
* the result.</li>
|
||||
* <li>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.</li>
|
||||
* </ol>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Cipher selection rules:
|
||||
* <ul>
|
||||
* <li>Derived key length greater than 300 results in an
|
||||
* {@link CipherType.Rc4}.</li>
|
||||
* <li>Derived key length between 1 and 300 inclusive results in a
|
||||
* {@link CipherType.Map}.</li>
|
||||
* <li>An empty derived key (length 0) results in a
|
||||
* {@link CipherType.Static}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>The decryption processes the buffer in regions:
|
||||
* <ol>
|
||||
* <li>Any portion of the first 128-byte segment.</li>
|
||||
* <li>Alignment to the next 5120-byte segment boundary.</li>
|
||||
* <li>Complete 5120-byte segments.</li>
|
||||
* <li>Remaining partial segment.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
* <p>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)].
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+16
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "qmc_debug"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
@@ -0,0 +1,358 @@
|
||||
use base64::Engine;
|
||||
use std::fs;
|
||||
|
||||
// ============================================================
|
||||
// Key Derivation (from qmc.rs)
|
||||
// ============================================================
|
||||
|
||||
fn simple_make_key(salt: u8, length: usize) -> Vec<u8> {
|
||||
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<Vec<u8>, &'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<Vec<u8>, &'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<Vec<u8>, &'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<Vec<u8>, 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<u8>,
|
||||
n: usize,
|
||||
hash: u32,
|
||||
box_: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Rc4Cipher {
|
||||
fn new(key: Vec<u8>) -> Self {
|
||||
let n = key.len();
|
||||
if n == 0 {
|
||||
return Rc4Cipher { key: vec![0], n: 1, hash: 0, box_: vec![0] };
|
||||
}
|
||||
let mut box_: Vec<u8> = (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<u8> = 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<u8>,
|
||||
}
|
||||
|
||||
impl MapCipher {
|
||||
fn new(key: Vec<u8>) -> 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user