diff --git a/.idea/lib_um_crypto.iml b/.idea/lib_um_crypto.iml index 9e64d5e..0148a35 100644 --- a/.idea/lib_um_crypto.iml +++ b/.idea/lib_um_crypto.iml @@ -12,6 +12,7 @@ + diff --git a/Cargo.lock b/Cargo.lock index c686f80..df15492 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,15 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.1.15" @@ -271,6 +280,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.12.1" @@ -513,6 +528,7 @@ dependencies = [ "umc_ncm", "umc_qmc", "umc_utils", + "umc_xmly", ] [[package]] @@ -606,6 +622,20 @@ dependencies = [ "md-5", ] +[[package]] +name = "umc_xmly" +version = "0.1.0" +dependencies = [ + "aes", + "byteorder", + "cbc", + "cipher", + "hex", + "lazy_static", + "thiserror", + "umc_utils", +] + [[package]] name = "unicode-ident" version = "1.0.12" diff --git a/um_cli/Cargo.toml b/um_cli/Cargo.toml index f90cafd..a4ca510 100644 --- a/um_cli/Cargo.toml +++ b/um_cli/Cargo.toml @@ -11,4 +11,5 @@ umc_kgm = { path = "../um_crypto/kgm" } umc_kuwo = { path = "../um_crypto/kuwo" } umc_ncm = { path = "../um_crypto/ncm" } umc_qmc = { path = "../um_crypto/qmc" } +umc_xmly = { path = "../um_crypto/xmly" } umc_utils = { path = "../um_crypto/utils" } diff --git a/um_cli/src/cmd/mod.rs b/um_cli/src/cmd/mod.rs index f467d2d..1b19bfa 100644 --- a/um_cli/src/cmd/mod.rs +++ b/um_cli/src/cmd/mod.rs @@ -5,6 +5,7 @@ pub mod kgm; pub mod ncm; pub mod qmc1; pub mod qmc2; +pub mod xmly; #[derive(Subcommand)] pub enum Commands { @@ -18,4 +19,6 @@ pub enum Commands { KGM(kgm::ArgsKGM), #[command(name = "joox")] JOOX(joox::ArgsJoox), + #[command(name = "xmly")] + XMLY(xmly::ArgsXimalaya), } diff --git a/um_cli/src/cmd/xmly.rs b/um_cli/src/cmd/xmly.rs new file mode 100644 index 0000000..02286ed --- /dev/null +++ b/um_cli/src/cmd/xmly.rs @@ -0,0 +1,94 @@ +use crate::Cli; +use anyhow::{bail, Result}; +use clap::Args; +use std::ffi::OsStr; +use std::fs::File; +use std::io; +use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write}; +use std::path::PathBuf; + +#[derive(Debug, Clone, clap::ValueEnum)] +enum XimalayaType { + /// Android: *.x2m + X2M, + /// Android: *.x3m + X3M, + /// PC: *.xm + XM, +} + +/// Decrypt a X2M/X3M/XM file (Ximalaya) +#[derive(Args)] +pub struct ArgsXimalaya { + /// Path to output file, e.g. /export/Music/song.flac + #[clap(short, long)] + output: PathBuf, + + /// Path to input file, e.g. /export/Music/song.x3m + #[arg(name = "input")] + input: PathBuf, + + #[clap(short = 't', long = "type")] + file_type: Option, +} + +impl ArgsXimalaya { + pub fn run(&self, cli: &Cli) -> Result { + let file_type = match &self.file_type { + Some(x) => x.clone(), + None => match self.input.extension().and_then(|ext: &OsStr| ext.to_str()) { + Some("x2m") => XimalayaType::X2M, + Some("x3m") => XimalayaType::X3M, + Some("xm") => XimalayaType::XM, + Some(ext) => bail!("invalid ext: {ext}"), + _ => bail!("ext not found"), + }, + }; + let mut read_stream = BufReader::with_capacity(cli.buffer_size, File::open(&self.input)?); + let mut write_stream = + BufWriter::with_capacity(cli.buffer_size, File::create(&self.output)?); + + match file_type { + XimalayaType::X2M | XimalayaType::X3M => { + let android_type = match file_type { + XimalayaType::X2M => umc_xmly::android::FileType::X2M, + XimalayaType::X3M => umc_xmly::android::FileType::X3M, + _ => bail!("this should not happen"), + }; + let mut header = [0u8; 0x400]; + read_stream.read_exact(&mut header)?; + umc_xmly::android::decrypt_android(android_type, &mut header); + write_stream.write_all(&header)?; + io::copy(&mut read_stream, &mut write_stream)?; + } + XimalayaType::XM => { + let mut header = vec![0u8; 1024]; + read_stream.read_exact(&mut header)?; + let xm_file = match umc_xmly::pc::Header::from_buffer(&header) { + Ok(hdr) => hdr, + Err(umc_xmly::XmlyError::MetadataTooSmall(n)) => { + let old_size = header.len(); + header.resize(n, 0); + read_stream.read_exact(&mut header[old_size..])?; + umc_xmly::pc::Header::from_buffer(&header)? + } + Err(err) => bail!("failed to parse file: {err}"), + }; + + // Copy header + write_stream.write_all(xm_file.copy_m4a_header().as_slice())?; + + // Process encrypted data + read_stream.seek(SeekFrom::Start(xm_file.data_start_offset as u64))?; + let mut header = vec![0u8; xm_file.encrypted_header_size]; + read_stream.read_exact(&mut header[..])?; + write_stream.write_all(xm_file.decrypt(&mut header[..])?)?; + + // Copy rest of the file + io::copy(&mut read_stream, &mut write_stream)?; + } + } + + Ok(0) + } +} diff --git a/um_cli/src/main.rs b/um_cli/src/main.rs index 14d9c0e..1f0caed 100644 --- a/um_cli/src/main.rs +++ b/um_cli/src/main.rs @@ -32,6 +32,7 @@ fn run_command(cli: &Cli) -> Result { Some(Commands::NCM(cmd)) => cmd.run(&cli), Some(Commands::KGM(cmd)) => cmd.run(&cli), Some(Commands::JOOX(cmd)) => cmd.run(&cli), + Some(Commands::XMLY(cmd)) => cmd.run(&cli), None => { // https://github.com/clap-rs/clap/issues/3857#issuecomment-1161796261 todo!("implement a sensible default command, similar to um/cli"); diff --git a/um_crypto/utils/src/base64.rs b/um_crypto/utils/src/base64.rs index c49fa0c..50d2e70 100644 --- a/um_crypto/utils/src/base64.rs +++ b/um_crypto/utils/src/base64.rs @@ -22,3 +22,14 @@ where { ENGINE.decode(data) } + +pub fn decode_overwrite(data: &mut T) -> Result<&[u8], DecodeError> +where + T: AsMut<[u8]> + ?Sized, +{ + let data = data.as_mut(); + let decoded = decode(&mut data[..])?; + let len = decoded.len(); + data[..len].copy_from_slice(&decoded); + Ok(&data[..len]) +} diff --git a/um_crypto/xmly/Cargo.toml b/um_crypto/xmly/Cargo.toml new file mode 100644 index 0000000..f52aac4 --- /dev/null +++ b/um_crypto/xmly/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "umc_xmly" +version = "0.1.0" +edition = "2021" + +[dependencies] +aes = "0.8.4" +byteorder = "1.5.0" +cbc = "0.1.2" +cipher = "0.4.4" +hex = "0.4.3" +lazy_static = "1.5.0" +thiserror = "1.0.63" +umc_utils = { path = "../utils" } diff --git a/um_crypto/xmly/src/__fixture__/.gitignore b/um_crypto/xmly/src/__fixture__/.gitignore new file mode 100644 index 0000000..d4abf2e --- /dev/null +++ b/um_crypto/xmly/src/__fixture__/.gitignore @@ -0,0 +1,2 @@ +/sample.xm +*.m4a diff --git a/um_crypto/xmly/src/__fixture__/x2m_hdr.bin b/um_crypto/xmly/src/__fixture__/x2m_hdr.bin new file mode 100644 index 0000000..eadfe03 Binary files /dev/null and b/um_crypto/xmly/src/__fixture__/x2m_hdr.bin differ diff --git a/um_crypto/xmly/src/__fixture__/x2m_hdr_plain.bin b/um_crypto/xmly/src/__fixture__/x2m_hdr_plain.bin new file mode 100644 index 0000000..ae4f076 Binary files /dev/null and b/um_crypto/xmly/src/__fixture__/x2m_hdr_plain.bin differ diff --git a/um_crypto/xmly/src/__fixture__/x3m_hdr.bin b/um_crypto/xmly/src/__fixture__/x3m_hdr.bin new file mode 100644 index 0000000..efa91db Binary files /dev/null and b/um_crypto/xmly/src/__fixture__/x3m_hdr.bin differ diff --git a/um_crypto/xmly/src/__fixture__/x3m_hdr_plain.bin b/um_crypto/xmly/src/__fixture__/x3m_hdr_plain.bin new file mode 100644 index 0000000..4ee6e71 Binary files /dev/null and b/um_crypto/xmly/src/__fixture__/x3m_hdr_plain.bin differ diff --git a/um_crypto/xmly/src/android.rs b/um_crypto/xmly/src/android.rs new file mode 100644 index 0000000..5c4a114 --- /dev/null +++ b/um_crypto/xmly/src/android.rs @@ -0,0 +1,75 @@ +use lazy_static::lazy_static; + +pub fn derive_table(init: f64, step: f64) -> [usize; N] { + debug_assert!(step > 0.0); + debug_assert!(init > 0.0); + + let mut result = [0usize; N]; + + let mut temp = init; + let mut data = [0f64; N]; + for datum in data.iter_mut() { + *datum = temp; + temp = temp * step * (1.0 - temp); + } + + let mut sorted = data; + sorted.sort_unstable_by(|a, b| a.total_cmp(b)); + + for (item, needle) in result.iter_mut().zip(data) { + let idx = sorted + .iter() + .position(|&x| x == needle) + .expect("could not find item"); + *item = idx; + sorted[idx] = -f64::NAN; // values can not be negative, so... + } + + result +} + +lazy_static! { + static ref TABLE_X2M: [usize; 0x400] = derive_table(0.615243, 3.837465); + static ref TABLE_X3M: [usize; 0x400] = derive_table(0.726354, 3.948576); +} + +pub enum FileType { + X2M, + X3M, +} + +/// Decrypt the first 0x400 bytes. +pub fn decrypt_android(version: FileType, buffer: &mut [u8; 0x400]) { + let (content_key, scramble_table) = match version { + FileType::X2M => (*b"xmlyxmlyxmlyxmlyxmlyxmlyxmlyxmly", &*TABLE_X2M), + FileType::X3M => (*b"3989d111aad5613940f4fc44b639b292", &*TABLE_X3M), + }; + + let src = *buffer; + for (i, hdr) in buffer.iter_mut().enumerate() { + *hdr = src[scramble_table[i % scramble_table.len()]] ^ content_key[i % content_key.len()]; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_x2m() { + let mut buffer = *include_bytes!("__fixture__/x2m_hdr.bin"); + let expected = *include_bytes!("__fixture__/x2m_hdr_plain.bin"); + let buffer_slice: &mut [u8] = &mut buffer; + decrypt_android(FileType::X2M, buffer_slice.try_into().unwrap()); + assert_eq!(expected, buffer); + } + + #[test] + fn test_x3m() { + let mut buffer = *include_bytes!("__fixture__/x3m_hdr.bin"); + let expected = *include_bytes!("__fixture__/x3m_hdr_plain.bin"); + let buffer_slice: &mut [u8] = &mut buffer; + decrypt_android(FileType::X3M, buffer_slice.try_into().unwrap()); + assert_eq!(expected, buffer); + } +} diff --git a/um_crypto/xmly/src/lib.rs b/um_crypto/xmly/src/lib.rs new file mode 100644 index 0000000..9bf8aed --- /dev/null +++ b/um_crypto/xmly/src/lib.rs @@ -0,0 +1,43 @@ +use cipher::block_padding::UnpadError; +use cipher::InvalidLength; +use thiserror::Error; +use umc_utils::base64::DecodeError; + +pub mod android; +pub mod pc; + +#[derive(Error, Debug)] +pub enum XmlyError { + #[error("Expected ID3 metadata")] + MetadataMissing, + + #[error("ID3 Metadata too small (require {0} bytes)")] + MetadataTooSmall(usize), + + #[error("Failed to extract encrypted audio segment size")] + ParseHeaderSizeError, + + #[error("Failed to extract Stage 1 IV data")] + ParseStage1IVError, + + #[error("Failed to extract Stage 2 decryption key")] + ParseStage2KeyError, + + #[error("Failed to extract audio header")] + ParseAudioHeaderError, + + #[error("Decryption stage 1 failed (padding)")] + DecryptStage1Error(UnpadError), + + #[error("Decryption stage 1 failed (b64 decode)")] + DecryptStage1B64Error(DecodeError), + + #[error("Decryption stage 2 failed (init)")] + InitStage2Error(InvalidLength), + + #[error("Decryption stage 2 failed (padding)")] + DecryptStage2Error(UnpadError), + + #[error("Decryption stage 2 failed (b64 decode)")] + DecryptStage2B64Error(DecodeError), +} diff --git a/um_crypto/xmly/src/pc.rs b/um_crypto/xmly/src/pc.rs new file mode 100644 index 0000000..e0e17b0 --- /dev/null +++ b/um_crypto/xmly/src/pc.rs @@ -0,0 +1,196 @@ +use crate::XmlyError; +use aes::cipher::generic_array::GenericArray; +use aes::cipher::BlockDecryptMut; +use aes::{Aes192Dec, Aes256Dec}; +use byteorder::{ByteOrder, BE}; +use cbc::cipher::KeyIvInit; +use cbc::Decryptor; +use cipher::block_padding::Pkcs7; +use umc_utils::base64; + +type Aes256CbcDec = Decryptor; +type Aes192CbcDec = Decryptor; + +fn parse_safe_sync_int(v: u32) -> u32 { + ((v & 0x7f00_0000) >> 3) + | ((v & 0x007f_0000) >> 2) + | ((v & 0x0000_7f00) >> 1) + | (v & 0x0000_007f) +} + +fn from_unicode(buf: &[u8]) -> String { + let data = buf + .iter() + .step_by(2) + .map_while(|&b| match b { + 0 => None, + b => Some(b), + }) + .collect::>(); + String::from_utf8_lossy(&data[..]).to_string() +} + +pub struct Header { + pub data_start_offset: usize, + pub encrypted_header_size: usize, + stage1_iv: [u8; 0x10], + stage2_key: [u8; 0x18], + m4a_header: Vec, +} + +impl Header { + pub fn from_buffer(buffer: &[u8]) -> Result { + if buffer.len() < 10 { + Err(XmlyError::MetadataTooSmall(10))?; + } + if &buffer[0..3] != b"ID3" { + Err(XmlyError::MetadataMissing)?; + } + + let header_size = parse_safe_sync_int(BE::read_u32(&buffer[6..10])); + let data_start_offset = 10 + header_size as usize; + + if buffer.len() < data_start_offset { + Err(XmlyError::MetadataTooSmall(data_start_offset))?; + } + + let mut encrypted_header_size = None; + let mut stage1_iv = None; + let mut stage2_key = None; + let mut m4a_header = None; + + let mut offset = 10; + while offset < data_start_offset { + // Safety check + if offset + 10 > buffer.len() { + break; + } + + let tag_name: &[u8; 4] = &buffer[offset..offset + 4].try_into().unwrap(); + offset += 4; + + let tag_value_size = BE::read_u32(&buffer[offset..offset + 4]) as usize; + offset += 4; + + // flags, ignore + offset += 2; + + if offset + tag_value_size > buffer.len() { + Err(XmlyError::MetadataTooSmall(offset + tag_value_size))?; + } + let data = &buffer[offset + 3..offset + tag_value_size]; + offset += tag_value_size; + + match tag_name { + b"TSIZ" => { + let data = from_unicode(data) + .parse::() + .map_err(|_| XmlyError::ParseHeaderSizeError)?; + encrypted_header_size = Some(data); + } + b"TSRC" | b"TENC" => { + let data = hex::decode(from_unicode(data)) + .map_err(|_| XmlyError::ParseStage1IVError)? + .try_into() + .map_err(|_| XmlyError::ParseStage1IVError)?; + stage1_iv = Some(data) + } + b"TSSE" => { + let data = base64::decode(from_unicode(data)) + .map_err(|_| XmlyError::ParseAudioHeaderError)?; + m4a_header = Some(data); + } + b"TRCK" => { + let data = from_unicode(data); + let tmp = data.as_bytes(); + + let mut key = *b"123456781234567812345678"; + key[24 - tmp.len()..].copy_from_slice(tmp); + stage2_key = Some(key); + } + // ignore unknown tags + _ => {} + } + } + + Ok(Self { + data_start_offset, + encrypted_header_size: encrypted_header_size.ok_or(XmlyError::ParseHeaderSizeError)?, + stage1_iv: stage1_iv.ok_or(XmlyError::ParseStage1IVError)?, + stage2_key: stage2_key.ok_or(XmlyError::ParseStage2KeyError)?, + m4a_header: m4a_header.ok_or(XmlyError::ParseAudioHeaderError)?, + }) + } + + const STAGE1_KEY: [u8; 32] = *b"ximalayaximalayaximalayaximalaya"; + fn decrypt_stage1<'a>(&self, buffer: &'a mut [u8]) -> Result<&'a [u8], XmlyError> { + let key = GenericArray::from(Self::STAGE1_KEY); + let iv = GenericArray::from(self.stage1_iv); + let aes = Aes256CbcDec::new(&key, &iv); + + let len = aes + .decrypt_padded_mut::(buffer) + .map_err(XmlyError::DecryptStage1Error)? + .len(); + base64::decode_overwrite(&mut buffer[..len]).map_err(XmlyError::DecryptStage1B64Error) + } + + fn decrypt_stage2<'a>(&self, buffer: &'a mut [u8]) -> Result<&'a [u8], XmlyError> { + let key = &self.stage2_key[..24]; + let iv = &self.stage2_key[..16]; + let aes = Aes192CbcDec::new_from_slices(key, iv).map_err(XmlyError::InitStage2Error)?; + + let len = aes + .decrypt_padded_mut::(buffer) + .map_err(XmlyError::DecryptStage2Error)? + .len(); + base64::decode_overwrite(&mut buffer[..len]).map_err(XmlyError::DecryptStage2B64Error) + } + + pub fn decrypt<'a>(&self, buffer: &'a mut [u8]) -> Result<&'a [u8], XmlyError> { + let len = self.decrypt_stage1(&mut buffer[..])?.len(); + self.decrypt_stage2(&mut buffer[..len]) + } + + pub fn copy_m4a_header(&self) -> Vec { + self.m4a_header.clone() + } +} + +#[cfg(test)] +mod tests { + use crate::pc::Header; + use crate::XmlyError; + use std::env; + use std::fs; + use std::fs::File; + use std::io::Write; + + #[test] + fn test_sample_xm() -> Result<(), XmlyError> { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is not set"); + let sample_path = format!("{}/src/__fixture__/sample.xm", manifest_dir); + let sample_out_path = format!("{}/src/__fixture__/sample.m4a", manifest_dir); + + println!("decrypt {} -> {}", sample_path, sample_out_path); + + if let Ok(mut xm) = fs::read(sample_path) { + let file = match Header::from_buffer(&xm[..1024]) { + Err(XmlyError::MetadataTooSmall(n)) => Header::from_buffer(&xm[..n])?, + Err(err) => Err(err)?, + Ok(x) => x, + }; + + let (_hdr, buffer) = xm.split_at_mut(file.data_start_offset); + let (buffer, plain) = buffer.split_at_mut(file.encrypted_header_size); + + let decrypted = file.decrypt(buffer)?; + let mut f_out = File::create(sample_out_path).expect("can't open test output file"); + f_out.write_all(&file.copy_m4a_header()).expect("header"); + f_out.write_all(decrypted).expect("decrypted part"); + f_out.write_all(plain).expect("plain part"); + } + + Ok(()) + } +}