From 8f00373dbf73b2da20d5d7777523b509cab0639e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E6=A0=91=E4=BA=BA?= Date: Fri, 20 Sep 2024 00:43:06 +0100 Subject: [PATCH] [qtfm] feat #4: implement QingTingFM decipher --- .idea/lib_um_crypto.iml | 1 + Cargo.lock | 23 ++++++++ um_cli/Cargo.toml | 2 + um_cli/src/cmd/mod.rs | 3 + um_cli/src/cmd/qtfm.rs | 100 ++++++++++++++++++++++++++++++++++ um_cli/src/main.rs | 1 + um_crypto/qtfm/Cargo.toml | 12 ++++ um_crypto/qtfm/src/lib.rs | 30 ++++++++++ um_crypto/qtfm/src/nonce.rs | 58 ++++++++++++++++++++ um_crypto/qtfm/src/secret.rs | 53 ++++++++++++++++++ um_crypto/utils/src/base64.rs | 20 +++++++ 11 files changed, 303 insertions(+) create mode 100644 um_cli/src/cmd/qtfm.rs create mode 100644 um_crypto/qtfm/Cargo.toml create mode 100644 um_crypto/qtfm/src/lib.rs create mode 100644 um_crypto/qtfm/src/nonce.rs create mode 100644 um_crypto/qtfm/src/secret.rs diff --git a/.idea/lib_um_crypto.iml b/.idea/lib_um_crypto.iml index 6af76e9..1ce6676 100644 --- a/.idea/lib_um_crypto.iml +++ b/.idea/lib_um_crypto.iml @@ -14,6 +14,7 @@ + diff --git a/Cargo.lock b/Cargo.lock index 50d30f9..bfa5fdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,6 +228,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "diff" version = "0.1.13" @@ -522,11 +531,13 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "hex", "umc_joox", "umc_kgm", "umc_kuwo", "umc_ncm", "umc_qmc", + "umc_qtfm", "umc_utils", "umc_xiami", "umc_xmly", @@ -616,6 +627,18 @@ dependencies = [ "umc_utils", ] +[[package]] +name = "umc_qtfm" +version = "0.1.0" +dependencies = [ + "aes", + "byteorder", + "cbc", + "ctr", + "thiserror", + "umc_utils", +] + [[package]] name = "umc_utils" version = "0.1.0" diff --git a/um_cli/Cargo.toml b/um_cli/Cargo.toml index a92d285..3c58c41 100644 --- a/um_cli/Cargo.toml +++ b/um_cli/Cargo.toml @@ -11,6 +11,8 @@ 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_qtfm = { path = "../um_crypto/qtfm" } umc_xiami = { path = "../um_crypto/xiami" } umc_xmly = { path = "../um_crypto/xmly" } umc_utils = { path = "../um_crypto/utils" } +hex = "0.4.3" diff --git a/um_cli/src/cmd/mod.rs b/um_cli/src/cmd/mod.rs index 3ae580b..26ccd1d 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; +mod qtfm; pub mod xiami; pub mod xmly; @@ -24,4 +25,6 @@ pub enum Commands { XMLY(xmly::ArgsXimalaya), #[command(name = "xiami")] Xiami(xiami::ArgsXiami), + #[command(name = "qtfm")] + QTFM(qtfm::ArgsQingTingFM), } diff --git a/um_cli/src/cmd/qtfm.rs b/um_cli/src/cmd/qtfm.rs new file mode 100644 index 0000000..bcbc763 --- /dev/null +++ b/um_cli/src/cmd/qtfm.rs @@ -0,0 +1,100 @@ +use crate::Cli; +use anyhow::{bail, Result}; +use clap::Args; +use std::fs::File; +use std::io::{BufReader, BufWriter, Read, Write}; +use std::path::PathBuf; +use umc_qtfm::nonce::make_decipher_iv; +use umc_qtfm::Decipher; + +/// Decrypt a qta file (QingTingFM) +#[derive(Args)] +pub struct ArgsQingTingFM { + /// Path to output file, e.g. /export/Music/song.flac + #[clap(short, long)] + output: PathBuf, + + /// Path to input file, e.g. /export/Music/song.xm + #[arg(name = "input")] + input: PathBuf, + + /// Device info (CSV), in the order of "product,device,manufacturer,brand,board,model". + #[clap(short, long = "device")] + device: Option, + + /// Device key, in hex. + #[clap(short = 'k', long = "device-key")] + device_key: Option, + + /// Override name. default to file name. + #[clap(short, long)] + name: Option, +} + +impl ArgsQingTingFM { + pub fn run(&self, cli: &Cli) -> Result { + let mut reader = BufReader::with_capacity(cli.buffer_size, File::open(&self.input)?); + let mut writer = BufWriter::with_capacity(cli.buffer_size, File::create(&self.output)?); + + let file_name = match &self.name { + Some(x) => x.clone(), + None => match self.input.file_name() { + Some(x) => x.to_string_lossy().to_string(), + None => bail!("failed to get file name"), + }, + }; + + let device_key = self.make_device_key()?; + let iv = make_decipher_iv(file_name)?; + + let decipher = Decipher::new(&device_key, &iv); + + let mut offset = 0; + let mut buffer = vec![0u8; cli.buffer_size]; + loop { + let n = reader.read(&mut buffer[..])?; + if n == 0 { + break; + } + decipher.decrypt(&mut buffer[..n], offset); + writer.write_all(&buffer[..n])?; + offset += n; + } + + Ok(0) + } + + fn make_device_key(&self) -> Result<[u8; 0x10]> { + let key = match &self.device_key { + Some(key) => { + let mut result = [0u8; 0x10]; + hex::decode_to_slice(key, &mut result)?; + result + } + None => match &self.device { + Some(device) => { + let mut device_parts = vec![]; + for item in device.split(',') { + device_parts.push(item); + } + if device_parts.len() != 6 { + bail!( + "device needs 6 parts. current: {} part(s)", + device_parts.len() + ); + } + umc_qtfm::secret::make_device_secret( + device_parts[0], + device_parts[1], + device_parts[2], + device_parts[3], + device_parts[4], + device_parts[5], + ) + } + None => bail!("one of device/device-key is required."), + }, + }; + Ok(key) + } +} diff --git a/um_cli/src/main.rs b/um_cli/src/main.rs index 75716b9..1a37c2e 100644 --- a/um_cli/src/main.rs +++ b/um_cli/src/main.rs @@ -34,6 +34,7 @@ fn run_command(cli: &Cli) -> Result { Some(Commands::JOOX(cmd)) => cmd.run(&cli), Some(Commands::XMLY(cmd)) => cmd.run(&cli), Some(Commands::Xiami(cmd)) => cmd.run(&cli), + Some(Commands::QTFM(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/qtfm/Cargo.toml b/um_crypto/qtfm/Cargo.toml new file mode 100644 index 0000000..24a7eee --- /dev/null +++ b/um_crypto/qtfm/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "umc_qtfm" +version = "0.1.0" +edition = "2021" + +[dependencies] +aes = "0.8.4" +byteorder = "1.5.0" +cbc = "0.1.2" +ctr = "0.9.2" +thiserror = "1.0.63" +umc_utils = { path = "../utils" } diff --git a/um_crypto/qtfm/src/lib.rs b/um_crypto/qtfm/src/lib.rs new file mode 100644 index 0000000..bab25b6 --- /dev/null +++ b/um_crypto/qtfm/src/lib.rs @@ -0,0 +1,30 @@ +use aes::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; +use thiserror::Error; +use umc_utils::base64::DecodeError; + +pub mod nonce; +pub mod secret; + +#[derive(Error, Debug, Clone)] +pub enum QingTingFMError { + #[error("Failed to decode file name.")] + DecodeFileNameFailed(DecodeError), + + #[error("File name does not start with known prefix.")] + MissingPrefix, +} + +type Aes128Ctr64BE = ctr::Ctr64BE; +pub struct Decipher(Aes128Ctr64BE); + +impl Decipher { + pub fn new(device_key: &[u8; 0x10], iv: &[u8; 0x10]) -> Self { + Decipher(Aes128Ctr64BE::new(device_key.into(), iv.into())) + } + + pub fn decrypt(&self, buffer: &mut [u8], offset: usize) { + let mut aes_engine = self.0.clone(); + aes_engine.seek(offset); + aes_engine.apply_keystream(&mut buffer[..]); + } +} diff --git a/um_crypto/qtfm/src/nonce.rs b/um_crypto/qtfm/src/nonce.rs new file mode 100644 index 0000000..71156be --- /dev/null +++ b/um_crypto/qtfm/src/nonce.rs @@ -0,0 +1,58 @@ +use crate::QingTingFMError; +use byteorder::{ByteOrder, BE}; +use umc_utils::base64; + +fn hash_resource_id(resource_id: &[u8]) -> i64 { + resource_id.iter().fold(0, |sum, &chr| { + let outer_sum = sum ^ (chr as i64); + + [0, 1, 4, 5, 7, 8, 40] + .iter() + .fold(0, |sum, &shl| sum.wrapping_add(outer_sum.wrapping_shl(shl))) + }) +} + +pub fn make_decipher_iv>(file_path_or_name: S) -> Result<[u8; 16], QingTingFMError> { + let path = file_path_or_name.as_ref(); + let name = match path.iter().rposition(|&b| b == b'\\' || b == b'/') { + Some(n) => &path[n..], + None => path, + }; + let name = name.strip_suffix(b".qta").unwrap_or(name); + + let resource_info = if let Some(x) = name.strip_prefix(b".p!") { + base64::decode(x).map_err(QingTingFMError::DecodeFileNameFailed)? + } else if let Some(x) = name.strip_prefix(b".p~!") { + base64::decode_url_safe(x).map_err(QingTingFMError::DecodeFileNameFailed)? + } else { + Err(QingTingFMError::MissingPrefix)? + }; + + // We only need the resource id part. + let resource_id = match resource_info.iter().position(|&b| b == b'@') { + None => &resource_info[..], + Some(n) => &resource_info[..n], + }; + + let hash = hash_resource_id(resource_id); + let mut iv = [0u8; 0x10]; + BE::write_i64(&mut iv[..8], hash); + Ok(iv) +} + +#[test] +fn test_make_nonce_key() -> Result<(), QingTingFMError> { + let actual1 = make_decipher_iv(".p!MTIzNDU2.qta")?; // "123456" + let expected1 = [0x4c, 0x43, 0x18, 0xd9, 0x98, 0xe6, 0xef, 0x57]; + assert_eq!(&actual1[..8], &expected1); + + let actual2 = make_decipher_iv(".p!OTg3NjU0MzIx.qta")?; // "987654321" + let expected2 = [0x32, 0xef, 0xa8, 0xef, 0x16, 0xc4, 0x98, 0x33]; + assert_eq!(&actual2[..8], &expected2); + + let actual3 = make_decipher_iv(".p~!MTIzNED_-w==.qta")?; // "1234@\xff\xfb" + let expected3 = [0x2e, 0x08, 0x09, 0x99, 0x62, 0x7a, 0xea, 0xac]; + assert_eq!(&actual3[..8], &expected3); + + Ok(()) +} diff --git a/um_crypto/qtfm/src/secret.rs b/um_crypto/qtfm/src/secret.rs new file mode 100644 index 0000000..e64d569 --- /dev/null +++ b/um_crypto/qtfm/src/secret.rs @@ -0,0 +1,53 @@ +fn java_string_hash_code>(s: T) -> u32 { + let mut hash = 0u32; + + for &chr in s.as_ref() { + hash = hash.wrapping_mul(31).wrapping_add(chr as u32); + } + + hash +} + +const DEVICE_KEY_SALT: [u8; 0x10] = [ + 0x26, 0x2b, 0x2b, 0x12, 0x11, 0x12, 0x14, 0x0a, 0x08, 0x00, 0x08, 0x0a, 0x14, 0x12, 0x11, 0x12, +]; +pub fn make_device_secret>( + product: S, + device: S, + manufacturer: S, + brand: S, + board: S, + model: S, +) -> [u8; 0x10] { + let device_id_hash_code = [product, device, manufacturer, brand, board, model] + .iter() + .fold(0u32, |sum, value| { + sum.wrapping_add(java_string_hash_code(value)) + }); + let device_id_hash_code_hex = format!("{:x}", device_id_hash_code); + let device_id_hash_code_hex = device_id_hash_code_hex.as_bytes(); + + let mut device_key = [0u8; 0x10]; + device_key[..device_id_hash_code_hex.len()].copy_from_slice(&device_id_hash_code_hex); + for (key, salt) in device_key.iter_mut().zip(DEVICE_KEY_SALT) { + *key = salt.wrapping_add(*key); + } + device_key +} + +#[test] +fn test_secret_generation() { + let actual = make_device_secret( + "product", + "device", + "manufacturer", + "brand", + "board", + "model", + ); + let expected = [ + 0x59, 0x64, 0x91, 0x77, 0x45, 0x46, 0x75, 0x6d, 0x08, 0x00, 0x08, 0x0a, 0x14, 0x12, 0x11, + 0x12, + ]; + assert_eq!(actual, expected); +} diff --git a/um_crypto/utils/src/base64.rs b/um_crypto/utils/src/base64.rs index 50d2e70..b14ba5e 100644 --- a/um_crypto/utils/src/base64.rs +++ b/um_crypto/utils/src/base64.rs @@ -9,6 +9,12 @@ pub const ENGINE: Base64Engine = Base64Engine::new( GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent), ); +/// Don't add padding when encoding, and require no padding when decoding. +pub const ENGINE_URL_SAFE: Base64Engine = Base64Engine::new( + &alphabet::URL_SAFE, + GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent), +); + pub fn encode(data: T) -> String where T: AsRef<[u8]>, @@ -33,3 +39,17 @@ where data[..len].copy_from_slice(&decoded); Ok(&data[..len]) } + +pub fn encode_url_safe(data: T) -> String +where + T: AsRef<[u8]>, +{ + ENGINE_URL_SAFE.encode(data) +} + +pub fn decode_url_safe(data: T) -> Result, DecodeError> +where + T: AsRef<[u8]>, +{ + ENGINE_URL_SAFE.decode(data) +}