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)
+}