Skip to content

Commit

Permalink
feat: support for ximalaya-pc v4.0.2 generated files
Browse files Browse the repository at this point in the history
  • Loading branch information
jixunmoe committed Feb 21, 2024
1 parent 9942906 commit f742dd2
Show file tree
Hide file tree
Showing 11 changed files with 321 additions and 1 deletion.
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
11 changes: 10 additions & 1 deletion src/bin/cli/cli_error.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -53,3 +56,9 @@ impl From<kuwo::InitCipherError> for ParakeetCliError {
Self::KuwoCipherInitError(error)
}
}

impl From<ximalaya_pc::Error> for ParakeetCliError {
fn from(error: ximalaya_pc::Error) -> Self {
Self::XimalayaPcError(error)
}
}
75 changes: 75 additions & 0 deletions src/bin/cli/cli_handle_ximalaya_pc.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
1 change: 1 addition & 0 deletions src/bin/cli/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
2 changes: 2 additions & 0 deletions src/bin/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/crypto/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ pub mod kugou;
pub mod kuwo;
pub mod tencent;
pub mod ximalaya_android;
pub mod ximalaya_pc;
59 changes: 59 additions & 0 deletions src/crypto/ximalaya_pc/cipher.rs
Original file line number Diff line number Diff line change
@@ -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<aes::Aes192>;
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;

const STAGE_1_KEY: &[u8; 32] = include_bytes!("data/stage_1.bin");

fn decode_deciphered_content<T: AsRef<[u8]>>(buf: T) -> Result<Vec<u8>, DecodeError> {
Base64.decode(buf)
}

fn stage_1_decipher<T: AsRef<[u8]>>(buf: T, iv: &[u8; 16]) -> Result<Vec<u8>, 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::<Pkcs7>(&mut temp)
.map_err(Error::Stage1PadError)?;

decode_deciphered_content(buf).map_err(Error::Stage1CipherDecodeError)
}

fn stage_2_decipher<T: AsRef<[u8]>>(buf: T, key_iv: &[u8; 24]) -> Result<Vec<u8>, 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::<Pkcs7>(&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<Vec<u8>, 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)
}
1 change: 1 addition & 0 deletions src/crypto/ximalaya_pc/data/stage_1.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ximalayaximalayaximalayaximalaya
123 changes: 123 additions & 0 deletions src/crypto/ximalaya_pc/header.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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<T: AsRef<[u8]>>(data: T) -> Result<Self, super::Error> {
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)
}
}
46 changes: 46 additions & 0 deletions src/crypto/ximalaya_pc/mod.rs
Original file line number Diff line number Diff line change
@@ -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),
}

0 comments on commit f742dd2

Please sign in to comment.