From f742dd2b88dbe18422866e8789c6b4d0f19c5bbe Mon Sep 17 00:00:00 2001 From: Jixun Wu Date: Wed, 21 Feb 2024 01:45:44 +0000 Subject: [PATCH] feat: support for ximalaya-pc v4.0.2 generated files --- Cargo.toml | 2 + README.md | 1 + src/bin/cli/cli_error.rs | 11 ++- src/bin/cli/cli_handle_ximalaya_pc.rs | 75 +++++++++++++++ src/bin/cli/commands.rs | 1 + src/bin/cli/mod.rs | 2 + src/crypto/mod.rs | 1 + src/crypto/ximalaya_pc/cipher.rs | 59 ++++++++++++ src/crypto/ximalaya_pc/data/stage_1.bin | 1 + src/crypto/ximalaya_pc/header.rs | 123 ++++++++++++++++++++++++ src/crypto/ximalaya_pc/mod.rs | 46 +++++++++ 11 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 src/bin/cli/cli_handle_ximalaya_pc.rs create mode 100644 src/crypto/ximalaya_pc/cipher.rs create mode 100644 src/crypto/ximalaya_pc/data/stage_1.bin create mode 100644 src/crypto/ximalaya_pc/header.rs create mode 100644 src/crypto/ximalaya_pc/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 3a82d9a..bd4ff03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ debug = 2 strip = false [dependencies] +aes = "0.8.4" +cbc = "0.1.2" argh = "0.1.12" base64 = "0.21.7" bincode = "1.3.3" diff --git a/README.md b/README.md index cc98b09..9dbfada 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - `kwm` / AI 升频 `mflac` [^kuwo_mflac] - 喜马拉雅 - 安卓客户端 `x2m` / `x3m` [^x3m] + - PC 客户端 `xm` [^qm_mflac]: 安卓需要提取密钥数据库;PC 端需要提供密钥数据库以及解密密钥。 [^kuwo_mflac]: 需要在有特权的安卓设备提取密钥文件: `/data/data/cn.kuwo.player/files/mmkv/cn.kuwo.player.mmkv.defaultconfig` diff --git a/src/bin/cli/cli_error.rs b/src/bin/cli/cli_error.rs index 3969536..86dafd7 100644 --- a/src/bin/cli/cli_error.rs +++ b/src/bin/cli/cli_error.rs @@ -1,6 +1,6 @@ use thiserror::Error; -use parakeet_crypto::crypto::{kugou, kuwo, tencent}; +use parakeet_crypto::crypto::{kugou, kuwo, tencent, ximalaya_pc}; #[derive(Debug, Error)] pub enum ParakeetCliError { @@ -31,6 +31,9 @@ pub enum ParakeetCliError { #[error("Failed to init kuwo cipher: {0}")] KuwoCipherInitError(kuwo::InitCipherError), + #[error("Cipher error: {0}")] + XimalayaPcError(ximalaya_pc::Error), + #[error("Unspecified error (placeholder)")] #[allow(dead_code)] UnspecifiedError, @@ -53,3 +56,9 @@ impl From for ParakeetCliError { Self::KuwoCipherInitError(error) } } + +impl From for ParakeetCliError { + fn from(error: ximalaya_pc::Error) -> Self { + Self::XimalayaPcError(error) + } +} diff --git a/src/bin/cli/cli_handle_ximalaya_pc.rs b/src/bin/cli/cli_handle_ximalaya_pc.rs new file mode 100644 index 0000000..50c2b45 --- /dev/null +++ b/src/bin/cli/cli_handle_ximalaya_pc.rs @@ -0,0 +1,75 @@ +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; + +use argh::FromArgs; + +use parakeet_crypto::crypto::ximalaya_pc; + +use crate::cli::cli_error::ParakeetCliError; +use crate::cli::logger::CliLogger; +use crate::cli::utils::CliFilePath; + +/// Handle Ximalaya PC encryption/decryption. +#[derive(Debug, Eq, PartialEq, FromArgs)] +#[argh(subcommand, name = "ximalaya-pc")] +pub struct Options { + /// input file name/path + #[argh(option, short = 'i', long = "input")] + input_file: CliFilePath, + + /// output file name/path + #[argh(option, short = 'o', long = "output")] + output_file: CliFilePath, +} + +pub fn handle(args: Options) -> Result<(), ParakeetCliError> { + let log = CliLogger::new("Kugou"); + + let mut src = File::open(args.input_file.path).map_err(ParakeetCliError::SourceIoError)?; + let mut dst = + File::create(args.output_file.path).map_err(ParakeetCliError::DestinationIoError)?; + + let mut buffer = vec![0u8; 1024]; + src.read_exact(&mut buffer) + .map_err(ParakeetCliError::SourceIoError)?; + + let hdr = match ximalaya_pc::Header::from_bytes(&buffer) { + // in case our buffer was too small... + Err(ximalaya_pc::Error::InputTooSmall(n, _)) => { + buffer.resize(n, 0); + + src.seek(SeekFrom::Start(0)) + .and_then(|_| src.read_exact(&mut buffer)) + .map_err(ParakeetCliError::SourceIoError)?; + ximalaya_pc::Header::from_bytes(&buffer)? + } + res => res?, + }; + + log.debug(format!( + "cipher: len(stolen_bytes)={}, cipher_len={}, data_start={}", + hdr.stolen_header_bytes.len(), + hdr.encrypted_header_len, + hdr.data_start_offset, + )); + + // read encrypted part 2 data, and decrypt it + buffer.resize(hdr.encrypted_header_len, 0); + src.seek(SeekFrom::Start(hdr.data_start_offset as u64)) + .and_then(|_| src.read_exact(&mut buffer)) + .map_err(ParakeetCliError::SourceIoError)?; + let decrypted_part_2 = ximalaya_pc::decipher_part_2(&hdr, &buffer)?; + + // write all parts to dst. + dst.write_all(&hdr.stolen_header_bytes) + .and_then(|_| dst.write_all(&decrypted_part_2)) + .and_then(|_| std::io::copy(&mut src, &mut dst)) + .map_err(ParakeetCliError::DestinationIoError)?; + + let bytes_written = dst + .stream_position() + .map_err(ParakeetCliError::DestinationIoError)?; + log.info(format!("decrypt: done, written {} bytes", bytes_written)); + + Ok(()) +} diff --git a/src/bin/cli/commands.rs b/src/bin/cli/commands.rs index b2f2a90..a9ca4a8 100644 --- a/src/bin/cli/commands.rs +++ b/src/bin/cli/commands.rs @@ -17,4 +17,5 @@ pub enum Command { Kugou(cli_handle_kugou::Options), Kuwo(cli_handle_kuwo::Options), XimalayaAndroid(cli_handle_ximalaya_android::Options), + XimalayaPc(cli_handle_ximalaya_pc::Options), } diff --git a/src/bin/cli/mod.rs b/src/bin/cli/mod.rs index 5e43fd4..4982b7d 100644 --- a/src/bin/cli/mod.rs +++ b/src/bin/cli/mod.rs @@ -10,6 +10,7 @@ mod cli_handle_kuwo; mod cli_handle_qmc1; mod cli_handle_qmc2; mod cli_handle_ximalaya_android; +mod cli_handle_ximalaya_pc; pub fn parakeet_main() { let options: commands::CliOptions = argh::from_env(); @@ -27,6 +28,7 @@ pub fn parakeet_main() { Command::Kugou(options) => cli_handle_kugou::handle(options), Command::Kuwo(options) => cli_handle_kuwo::handle(options), Command::XimalayaAndroid(options) => cli_handle_ximalaya_android::handle(options), + Command::XimalayaPc(options) => cli_handle_ximalaya_pc::handle(options), }; match cmd_result { diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index d89c416..3f429fe 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -4,3 +4,4 @@ pub mod kugou; pub mod kuwo; pub mod tencent; pub mod ximalaya_android; +pub mod ximalaya_pc; diff --git a/src/crypto/ximalaya_pc/cipher.rs b/src/crypto/ximalaya_pc/cipher.rs new file mode 100644 index 0000000..cada46f --- /dev/null +++ b/src/crypto/ximalaya_pc/cipher.rs @@ -0,0 +1,59 @@ +use crate::crypto::ximalaya_pc::{Error, Header}; +use aes::cipher::block_padding::Pkcs7; +use aes::cipher::{BlockDecryptMut, KeyIvInit}; +use base64::{engine::general_purpose::STANDARD as Base64, DecodeError, Engine as _}; + +type Aes192CbcDec = cbc::Decryptor; +type Aes256CbcDec = cbc::Decryptor; + +const STAGE_1_KEY: &[u8; 32] = include_bytes!("data/stage_1.bin"); + +fn decode_deciphered_content>(buf: T) -> Result, DecodeError> { + Base64.decode(buf) +} + +fn stage_1_decipher>(buf: T, iv: &[u8; 16]) -> Result, Error> { + let mut temp = Vec::from(buf.as_ref()); + + // aes-256-cbc decryption + let buf = Aes256CbcDec::new(STAGE_1_KEY.into(), iv.into()) + .decrypt_padded_mut::(&mut temp) + .map_err(Error::Stage1PadError)?; + + decode_deciphered_content(buf).map_err(Error::Stage1CipherDecodeError) +} + +fn stage_2_decipher>(buf: T, key_iv: &[u8; 24]) -> Result, Error> { + let mut temp = Vec::from(buf.as_ref()); + + // aes-192-cbc decryption + let buf = Aes192CbcDec::new(key_iv.into(), key_iv[..16].into()) + .decrypt_padded_mut::(&mut temp) + .map_err(Error::Stage2PadError)?; + + decode_deciphered_content(buf).map_err(Error::Stage2CipherDecodeError) +} + +/// Decrypt header +/// `part_2_data` should contain at least `hdr.encrypted_header_len` bytes. +/// Note: +/// - Read & parse header from file +/// - Seek to `hdr.data_start_offset`, read `hdr.encrypted_header_len`. +/// - call `decipher_header` to decrypt. +/// - build the final file: +/// - File header - `hdr.stolen_header_bytes` +/// - Decrypted `part_2_data` after calling this method +/// - Seek to `hdr.data_start_offset + hdr.encrypted_header_len`, copy till EOF. +pub fn decipher_part_2(hdr: &Header, part_2_data: &[u8]) -> Result, Error> { + if part_2_data.len() < hdr.encrypted_header_len { + Err(Error::InputTooSmall( + hdr.encrypted_header_len, + part_2_data.len(), + ))?; + } + + let buf = &part_2_data[..hdr.encrypted_header_len]; + let buf = stage_1_decipher(buf, &hdr.stage_1_iv)?; + let buf = stage_2_decipher(buf, &hdr.stage_2_key)?; + Ok(buf) +} diff --git a/src/crypto/ximalaya_pc/data/stage_1.bin b/src/crypto/ximalaya_pc/data/stage_1.bin new file mode 100644 index 0000000..04793d8 --- /dev/null +++ b/src/crypto/ximalaya_pc/data/stage_1.bin @@ -0,0 +1 @@ +ximalayaximalayaximalayaximalaya \ No newline at end of file diff --git a/src/crypto/ximalaya_pc/header.rs b/src/crypto/ximalaya_pc/header.rs new file mode 100644 index 0000000..a8795e7 --- /dev/null +++ b/src/crypto/ximalaya_pc/header.rs @@ -0,0 +1,123 @@ +use base64::{engine::general_purpose::STANDARD as Base64, Engine as _}; +use byteorder::{ByteOrder, BE}; +use std::str::FromStr; + +fn parse_safe_sync_u32(v: u32) -> u32 { + let a = v & 0x00_00_00_7f; + let b = (v & 0x00_00_7f_00) >> 1; + let c = (v & 0x00_7f_00_00) >> 2; + let d = (v & 0x7f_00_00_00) >> 3; + a | b | c | d +} + +fn from_utf16_le(data: &[u8]) -> Vec { + data.chunks(2) + .map_while(|chunk| match chunk[0] { + 0 => None, + v => Some(v), + }) + .collect() +} + +pub struct Header { + pub data_start_offset: usize, + pub encrypted_header_len: usize, + pub stage_1_iv: [u8; 16], + /// aes-192, key length = 24-bytes (first 16 byte is also re-used as its iv) + pub stage_2_key: [u8; 24], + pub stolen_header_bytes: Box<[u8]>, +} + +const MAGIC_ID3: [u8; 3] = *b"ID3"; + +impl Header { + pub fn from_bytes>(data: T) -> Result { + let data = data.as_ref(); + if data.len() < 10 { + Err(super::Error::InputTooSmall(10, data.len()))?; + } + if !data.starts_with(&MAGIC_ID3) { + Err(super::Error::InvalidId3Header)?; + } + let hdr_size = parse_safe_sync_u32(BE::read_u32(&data[6..])); + let data_start_offset = hdr_size as usize + 10; + if data.len() < data_start_offset { + Err(super::Error::InputTooSmall(data_start_offset, data.len()))?; + } + + let mut offset = 10usize; + + let mut result = Self { + data_start_offset, + encrypted_header_len: 0, + stage_1_iv: [0u8; 16], + stage_2_key: [0u8; 24], + stolen_header_bytes: Box::new([]), + }; + + while offset < data_start_offset { + if offset + 10 >= data_start_offset { + Err(super::Error::UnexpectedHeaderEof(offset))?; + } + + let tag_name = &data[offset..offset + 4]; + offset += 4; + let tag_size = BE::read_u32(&data[offset..]) as usize; + offset += 4; + + offset += 2; // flags - not used/ignored + + if offset + tag_size > data_start_offset { + Err(super::Error::UnexpectedHeaderEof(offset))?; + } + + // 01 ff fe ignored - those are encoding marks. All fields are in unicode anyway... + // src: https://web.archive.org/web/2020/https://id3.org/id3v2.3.0#ID3v2_frame_overview + // > If ISO-8859-1 is used this byte should be $00, if Unicode is used it should be $01. + // > Unicode strings must begin with the Unicode BOM ($FF FE or $FE FF) to identify the byte order. + let tag_data = &data[offset + 3..offset + tag_size]; + offset += tag_size; + + match tag_name { + b"TSIZ" => { + let tag_len_str = from_utf16_le(tag_data); + let tag_len_str = String::from_utf8_lossy(tag_len_str.as_slice()); + let header_len = u32::from_str(&tag_len_str) + .map_err(super::Error::DeserializeHeaderValueInt)?; + result.encrypted_header_len = header_len as usize; + } + b"TSRC" | b"TENC" => { + let tag_data = from_utf16_le(tag_data); + let buf_stage1_key = + hex::decode(tag_data).map_err(super::Error::DeserializeHeaderValueHex)?; + if buf_stage1_key.len() != result.stage_1_iv.len() { + Err(super::Error::InvalidData(offset))?; + } + result.stage_1_iv.copy_from_slice(&buf_stage1_key); + } + b"TSSE" => { + let tag_data = from_utf16_le(tag_data); + let stolen_header = Base64 + .decode(tag_data) + .map_err(super::Error::DeserializeHeaderValueBase64)?; + result.stolen_header_bytes = stolen_header.into_boxed_slice(); + } + b"TRCK" => { + let mut tag_data = from_utf16_le(tag_data); + let mut key = *b"123456781234567812345678"; + if tag_data.len() > 24 { + tag_data.drain(..24 - tag_data.len()); + } + let left = key.len() - tag_data.len(); + key[left..].copy_from_slice(&tag_data); + result.stage_2_key = key; + } + _ => { + // ignored + } + } + } + + Ok(result) + } +} diff --git a/src/crypto/ximalaya_pc/mod.rs b/src/crypto/ximalaya_pc/mod.rs new file mode 100644 index 0000000..928dd87 --- /dev/null +++ b/src/crypto/ximalaya_pc/mod.rs @@ -0,0 +1,46 @@ +use aes::cipher::block_padding::UnpadError; +use hex::FromHexError; +use std::num::ParseIntError; +use thiserror::Error; + +mod cipher; +mod header; + +pub use cipher::decipher_part_2; +pub use header::Header; + +#[derive(Debug, Error)] +pub enum Error { + #[error("file does not begin with a valid ID3 header")] + InvalidId3Header, + + #[error("Input buffer too small. Expected at least {0} bytes, got {0} bytes.")] + InputTooSmall(usize, usize), + + #[error("Unexpected EOF while parsing header at offset {0}")] + UnexpectedHeaderEof(usize), + + #[error("Could not deserialize an integer: {0}")] + DeserializeHeaderValueInt(ParseIntError), + + #[error("Could not deserialize a hex str to vec: {0}")] + DeserializeHeaderValueHex(FromHexError), + + #[error("Could not deserialize a base64 str to vec: {0}")] + DeserializeHeaderValueBase64(base64::DecodeError), + + #[error("Failed to parse at offset: {0}")] + InvalidData(usize), + + #[error("Failed to decrypt data (stage 1, pkcs#7 padding error): {0}")] + Stage1PadError(UnpadError), + + #[error("Failed to decrypt data (stage 1, b64 decode)")] + Stage1CipherDecodeError(base64::DecodeError), + + #[error("Failed to decrypt data (stage 2, pkcs#7 padding error): {0}")] + Stage2PadError(UnpadError), + + #[error("Failed to decrypt data (stage 2, b64 decode)")] + Stage2CipherDecodeError(base64::DecodeError), +}