Compare commits

...

10 Commits

31 changed files with 1104 additions and 33 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ target/
pkg/
pkg-*/
node_modules/
*.local

View File

@ -6,9 +6,11 @@
<sourceFolder url="file://$MODULE_DIR$/um_wasm/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/um_crypto/kuwo/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/um_crypto/qmc/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/um_cli/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/um_crypto/utils/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
</module>

253
Cargo.lock generated
View File

@ -2,6 +2,55 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "anstream"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1"
[[package]]
name = "anstyle-parse"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8"
dependencies = [
"anstyle",
"windows-sys 0.52.0",
]
[[package]]
name = "anyhow"
version = "1.0.86"
@ -20,6 +69,12 @@ version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.1.15"
@ -35,6 +90,52 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clap"
version = "4.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97"
[[package]]
name = "colorchoice"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
@ -51,6 +152,29 @@ version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
[[package]]
name = "getrandom"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.13.0"
@ -69,6 +193,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.158"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
[[package]]
name = "log"
version = "0.4.22"
@ -91,6 +221,15 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "ppv-lite86"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.86"
@ -109,6 +248,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "same-file"
version = "1.0.6"
@ -130,6 +299,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.77"
@ -141,6 +316,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tc_tea"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab26285e70ee5cbec8582c76b8124dfe65b564b43dad14b9caca6fd127d66d2"
dependencies = [
"rand",
"rand_chacha",
]
[[package]]
name = "thiserror"
version = "1.0.63"
@ -161,6 +346,17 @@ dependencies = [
"syn",
]
[[package]]
name = "um_cli"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"umc_kuwo",
"umc_qmc",
"umc_utils",
]
[[package]]
name = "um_wasm"
version = "0.1.0"
@ -178,9 +374,9 @@ name = "umc_kuwo"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"itertools",
"thiserror",
"umc_utils",
]
[[package]]
@ -188,9 +384,18 @@ name = "umc_qmc"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"byteorder",
"itertools",
"tc_tea",
"thiserror",
"umc_utils",
]
[[package]]
name = "umc_utils"
version = "0.1.0"
dependencies = [
"base64",
]
[[package]]
@ -199,6 +404,12 @@ version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "walkdir"
version = "2.5.0"
@ -209,6 +420,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
version = "0.2.93"
@ -318,7 +535,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
@ -393,3 +619,24 @@ name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zerocopy"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"byteorder",
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View File

@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["um_crypto/*", "um_wasm"]
members = ["um_crypto/*", "um_wasm", "um_cli"]
[profile.release.package.um_wasm]
# Tell `rustc` to optimize for small code size.

11
um_cli/Cargo.toml Normal file
View File

@ -0,0 +1,11 @@
[package]
name = "um_cli"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.86"
clap = { version = "4.5.17", features = ["derive"] }
umc_kuwo = { path = "../um_crypto/kuwo" }
umc_qmc = { path = "../um_crypto/qmc" }
umc_utils = { path = "../um_crypto/utils" }

12
um_cli/src/cmd/mod.rs Normal file
View File

@ -0,0 +1,12 @@
use clap::Subcommand;
pub mod qmc1;
pub mod qmc2;
#[derive(Subcommand)]
pub enum Commands {
#[command(name = "qmc1")]
QMCv1(qmc1::ArgsQMCv1),
#[command(name = "qmc2")]
QMCv2(qmc2::ArgsQMCv2),
}

39
um_cli/src/cmd/qmc1.rs Normal file
View File

@ -0,0 +1,39 @@
use crate::Cli;
use anyhow::Result;
use clap::Args;
use std::fs::File;
use std::io::{Read, Write};
use std::path::PathBuf;
/// Decrypt a QMCv1 file
#[derive(Args)]
pub struct ArgsQMCv1 {
/// Path to output file, e.g. /export/Music/song.flac
#[clap(short, long)]
output: PathBuf,
/// Path to input file, e.g. /export/Music/song.qmcflac
#[arg(name = "input")]
input: PathBuf,
}
impl ArgsQMCv1 {
pub fn run(&self, cli: &Cli) -> Result<i32> {
let mut file_input = File::open(&self.input)?;
let mut file_output = File::create(&self.output)?;
let mut offset = 0usize;
let mut buffer = vec![0u8; cli.buffer_size].into_boxed_slice();
while let Ok(n) = file_input.read(&mut buffer) {
if n == 0 {
break;
}
umc_qmc::v1::decrypt(&mut buffer[..n], offset);
file_output.write_all(&buffer[..n])?;
offset += n;
}
Ok(0)
}
}

91
um_cli/src/cmd/qmc2.rs Normal file
View File

@ -0,0 +1,91 @@
use crate::Cli;
use anyhow::{bail, Result};
use clap::Args;
use std::fs::File;
use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
use std::path::PathBuf;
use umc_qmc::{footer, QMCv2Cipher};
use umc_utils::base64;
/// Decrypt a QMCv1 file
#[derive(Args)]
pub struct ArgsQMCv2 {
/// Path to output file, e.g. /export/Music/song.flac
#[arg(short, long)]
output: Option<PathBuf>,
/// Path to input file, e.g. /export/Music/song.qmcflac
#[arg(name = "input")]
input: PathBuf,
/// Override EKey for this file.
/// Prefix with "decrypted:" to use base64 encoded raw key.
#[arg(short = 'K', long = "ekey")]
ekey: Option<String>,
/// Print info about this file, and do not perform decryption.
#[arg(short = 'I', long, action=clap::ArgAction::SetTrue, default_value_t=false)]
info_only: bool,
}
impl ArgsQMCv2 {
pub fn run(&self, cli: &Cli) -> Result<i32> {
let mut file_input = File::open(&self.input)?;
let mut footer_detection_buffer = vec![0u8; footer::INITIAL_DETECTION_LEN];
file_input.seek(SeekFrom::End(-(footer::INITIAL_DETECTION_LEN as i64)))?;
file_input.read_exact(&mut footer_detection_buffer)?;
let input_size = file_input.stream_position()?;
file_input.seek(SeekFrom::Start(0))?;
let (footer_len, ekey) = match footer::from_byte_slice(&footer_detection_buffer) {
Ok(Some(metadata)) => {
if self.info_only || cli.verbose {
println!("metadata: {:?}", metadata);
}
(metadata.size, metadata.ekey.or_else(|| self.ekey.clone()))
}
Ok(None) => {
eprintln!("could not find any qmc metadata.");
(0usize, self.ekey.clone())
}
Err(err) => {
eprintln!("failed to parse qmc metadata: {}", err);
(0usize, self.ekey.clone())
}
};
if self.info_only {
return Ok(0);
}
let key = match ekey {
None => bail!("--ekey is required when embedded ekey is not present."),
Some(ekey) => match ekey.strip_suffix("decrypted:") {
Some(decrypted) => base64::decode(decrypted)?.into_boxed_slice(),
None => umc_qmc::ekey::decrypt(ekey)?,
},
};
let cipher = QMCv2Cipher::new(key)?;
let mut file_output = match &self.output {
None => bail!("--output is required"),
Some(output) => BufWriter::new(File::create(output)?),
};
let mut buffer = vec![0u8; cli.buffer_size];
let reader = BufReader::with_capacity(cli.buffer_size, file_input);
let mut reader = reader.take(input_size - footer_len as u64);
let mut offset = 0usize;
while let Ok(n) = reader.read(&mut buffer) {
if n == 0 {
break;
}
cipher.decrypt(&mut buffer[..n], offset);
file_output.write_all(&buffer[..n])?;
offset += n;
}
Ok(0)
}
}

51
um_cli/src/main.rs Normal file
View File

@ -0,0 +1,51 @@
use crate::cmd::Commands;
use anyhow::Result;
use clap::Parser;
use std::process::exit;
use std::time::Instant;
mod cmd;
/// um_cli (rust ver.)
/// A cli-tool to unlock encrypted audio files.
#[derive(Parser)]
#[command(name = "um_cli")]
#[command(version = "0.1")]
pub struct Cli {
#[clap(subcommand)]
command: Option<Commands>,
/// Be more verbose about what is going on.
#[clap(long, short, action=clap::ArgAction::SetTrue, default_value_t=false)]
verbose: bool,
/// Preferred buffer size when reading file, in bytes.
/// Default to 4MiB.
#[clap(long, short = 'B', default_value_t=4*1024*1024)]
buffer_size: usize,
}
fn run_command(cli: &Cli) -> Result<i32> {
match &cli.command {
Some(Commands::QMCv1(cmd)) => cmd.run(&cli),
Some(Commands::QMCv2(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");
}
}
}
fn main() {
let cli = Cli::parse();
let start = Instant::now();
let code = run_command(&cli).unwrap_or_else(|err| {
eprintln!("failed to run command: {}", err);
-1
});
let duration = start.elapsed();
if cli.verbose {
eprintln!("time: {:?}", duration);
};
exit(code);
}

View File

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
base64 = "0.22.1"
itertools = "0.13.0"
anyhow = "1.0.86"
itertools = "0.13.0"
thiserror = "1.0.63"
umc_utils = { path = "../utils" }

View File

@ -1,22 +1,14 @@
use anyhow::Result;
use base64::alphabet;
use base64::engine::{DecodePaddingMode, GeneralPurpose as Base64Engine, GeneralPurposeConfig};
use base64::prelude::*;
mod constants;
mod core;
mod helper;
use core::{KuwoDes, Mode};
/// Don't add padding when encoding, and require no padding when decoding.
const B64: Base64Engine = Base64Engine::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
);
use umc_utils::base64;
/// Decrypt string content
pub fn decrypt_ksing(data: &str, key: &[u8; 8]) -> Result<String> {
let mut decoded = B64.decode(data)?;
let mut decoded = base64::decode(data)?;
let des = KuwoDes::new(key, Mode::Decrypt);
des.transform(&mut decoded[..])?;
@ -35,7 +27,7 @@ pub fn encrypt_ksing<T: AsRef<[u8]>>(data: T, key: &[u8; 8]) -> Result<String> {
let des = KuwoDes::new(key, Mode::Encrypt);
des.transform(&mut data[..])?;
Ok(B64.encode(data))
Ok(base64::encode(data))
}
pub fn decode_ekey(data: &str, key: &[u8; 8]) -> Result<String> {

View File

@ -4,7 +4,9 @@ version = "0.1.0"
edition = "2021"
[dependencies]
base64 = "0.22.1"
itertools = "0.13.0"
anyhow = "1.0.86"
byteorder = "1.5.0"
itertools = "0.13.0"
tc_tea = "0.1.4"
thiserror = "1.0.63"
umc_utils = { path = "../utils" }

73
um_crypto/qmc/src/ekey.rs Normal file
View File

@ -0,0 +1,73 @@
use anyhow::Result;
use itertools::Itertools;
use std::ops::Mul;
use thiserror::Error;
use umc_utils::base64;
/// Base64 encoded prefix: "QQMusic EncV2,Key:"
const EKEY_V2_PREFIX: &[u8; 24] = b"UVFNdXNpYyBFbmNWMixLZXk6";
const EKEY_V2_KEY1: [u8; 16] = [
0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40, 0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28,
];
const EKEY_V2_KEY2: [u8; 16] = [
0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25, 0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54,
];
#[derive(Debug, Clone, PartialEq, Error)]
pub enum EKeyDecryptError {
#[error("EKey is too short for decryption")]
EKeyTooShort,
#[error("Error when decrypting ekey v1")]
FailDecryptV1,
#[error("Error when decrypting ekey v2")]
FailDecryptV2,
}
fn make_simple_key<const N: usize>() -> [u8; N] {
let mut result = [0u8; N];
for (i, v) in result.iter_mut().enumerate() {
let i = i as f32;
let value = 106.0 + i * 0.1;
let value = value.tan().abs().mul(100.0);
*v = value as u8;
}
result
}
pub fn decrypt_v1(ekey: &[u8]) -> Result<Box<[u8]>> {
if ekey.len() < 12 {
Err(EKeyDecryptError::EKeyTooShort)?;
}
let ekey = base64::decode(ekey)?;
let (header, cipher) = ekey.split_at(8);
let simple_key = make_simple_key::<8>();
let tea_key = simple_key
.iter()
.zip(header)
.flat_map(|(&simple_part, &header_part)| [simple_part, header_part])
.collect::<Vec<_>>();
let plaintext = tc_tea::decrypt(cipher, tea_key).ok_or(EKeyDecryptError::FailDecryptV1)?;
Ok([header, &plaintext].concat().into())
}
pub fn decrypt_v2(ekey: &[u8]) -> Result<Box<[u8]>> {
let ekey = base64::decode(ekey)?;
let ekey = tc_tea::decrypt(ekey, EKEY_V2_KEY1).ok_or(EKeyDecryptError::FailDecryptV2)?;
let ekey = tc_tea::decrypt(ekey, EKEY_V2_KEY2).ok_or(EKeyDecryptError::FailDecryptV2)?;
let ekey = ekey.iter().take_while(|&&b| b != 0).copied().collect_vec();
decrypt_v1(&ekey)
}
pub fn decrypt<T: AsRef<[u8]>>(ekey: T) -> Result<Box<[u8]>> {
let ekey = ekey.as_ref();
match ekey.strip_prefix(EKEY_V2_PREFIX) {
Some(v2_ekey) => decrypt_v2(v2_ekey),
None => decrypt_v1(ekey),
}
}

View File

@ -0,0 +1,51 @@
use crate::footer::utils::is_base64;
use crate::footer::{Data, FooterParseError, Metadata, MetadataParser};
use byteorder::{ByteOrder, BE};
use itertools::Itertools;
#[derive(Debug, Clone, PartialEq)]
pub struct QTagMetadata {
/// The old, numeric id of the resource.
pub resource_id: u64,
}
impl MetadataParser for QTagMetadata {
fn from_byte_slice(buffer: &[u8]) -> anyhow::Result<Option<Metadata>> {
if buffer.len() < 8 {
Err(FooterParseError::BufferTooSmall(8))?;
}
if let Some(footer) = buffer.strip_suffix(b"QTag") {
let (payload, payload_len) = footer.split_at(footer.len() - 4);
let actual_payload_len = BE::read_u32(payload_len) as usize;
if payload.len() < actual_payload_len {
Err(FooterParseError::BufferTooSmall(actual_payload_len + 8))?;
}
// CSV: ekey,resource_id,version
let payload = String::from_utf8_lossy(&payload[payload.len() - actual_payload_len..]);
if let Some((ekey, resource_id, version)) = payload.split(',').collect_tuple() {
if version != "2" {
Err(FooterParseError::QTagInvalidVersion(version.to_string()))?;
}
if !resource_id.as_bytes().iter().all(|&b| b.is_ascii_digit()) {
Err(FooterParseError::QTagInvalidId(resource_id.to_string()))?;
}
if !is_base64(ekey.as_bytes()) {
Err(FooterParseError::QTagInvalidEKey(ekey.to_string()))?;
}
return Ok(Some(Metadata {
ekey: Some(ekey.into()),
size: actual_payload_len + 8,
data: Data::AndroidQTag(QTagMetadata {
resource_id: resource_id.parse()?,
}),
}));
}
Err(FooterParseError::STagInvalidCSV(payload.to_string()))?;
}
Ok(None)
}
}

View File

@ -0,0 +1,50 @@
use crate::footer::{Data, FooterParseError, Metadata, MetadataParser};
use byteorder::{ByteOrder, BE};
use itertools::Itertools;
#[derive(Debug, Clone, PartialEq)]
pub struct STagMetadata {
/// Resource identifier (aka. `file.media_mid`).
pub media_mid: String,
/// Resource id (numeric)
pub resource_id: u64,
}
impl MetadataParser for STagMetadata {
fn from_byte_slice(buffer: &[u8]) -> anyhow::Result<Option<Metadata>> {
if buffer.len() < 8 {
Err(FooterParseError::BufferTooSmall(8))?;
}
if let Some(footer) = buffer.strip_suffix(b"STag") {
let (payload, payload_len) = footer.split_at(footer.len() - 4);
let actual_payload_len = BE::read_u32(payload_len) as usize;
if payload.len() < actual_payload_len {
Err(FooterParseError::BufferTooSmall(actual_payload_len + 8))?;
}
let payload = String::from_utf8_lossy(&payload[payload.len() - actual_payload_len..]);
if let Some((id, version, media_mid)) = payload.split(',').collect_tuple() {
if version != "2" {
Err(FooterParseError::STagInvalidVersion(version.to_string()))?;
}
if !id.as_bytes().iter().all(|&b| b.is_ascii_digit()) {
Err(FooterParseError::STagInvalidId(id.to_string()))?;
}
return Ok(Some(Metadata {
ekey: None,
size: actual_payload_len + 8,
data: Data::AndroidSTag(STagMetadata {
resource_id: id.parse()?,
media_mid: media_mid.to_string(),
}),
}));
}
Err(FooterParseError::STagInvalidCSV(payload.to_string()))?;
}
Ok(None)
}
}

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,167 @@
pub mod android_qtag;
pub mod android_stag;
mod musicex_v1;
pub mod pc_v1_legacy;
pub mod pc_v2_musicex;
mod utils;
use crate::footer::{
android_qtag::QTagMetadata, android_stag::STagMetadata, pc_v1_legacy::PcV1Legacy,
pc_v2_musicex::PcV2MusicEx,
};
use anyhow::Result;
use thiserror::Error;
pub const INITIAL_DETECTION_LEN: usize = 1024;
#[derive(Error, Debug)]
pub enum FooterParseError {
#[error("Footer: Buffer too small, require at least {0} bytes")]
BufferTooSmall(usize),
#[error("PCv1/EKey: Buffer too large, might not be valid EKey (len={0})")]
PCv1EKeyTooLarge(usize),
#[error("PCv1/EKey: Found invalid EKey char")]
PCv1EKeyInvalid,
#[error("PCv2/MusicEx: Invalid metadata version {0}")]
PCv2InvalidVersion(u32),
#[error("PCv2/MusicEx: Invalid `MusicEx` size: {0}")]
PCv2MusicExUnsupportedPayloadSize(usize),
#[error("Android/STag: Invalid ID field: {0}")]
STagInvalidId(String),
#[error("Android/STag: Invalid Version: {0}")]
STagInvalidVersion(String),
#[error("Android/STag: Invalid CSV metadata: {0}")]
STagInvalidCSV(String),
#[error("Android/QTag: Invalid ID field: {0}")]
QTagInvalidId(String),
#[error("Android/QTag: Invalid Version: {0}")]
QTagInvalidVersion(String),
#[error("Android/QTag: Invalid EKey field: {0}")]
QTagInvalidEKey(String),
}
/// Footer type
#[derive(Debug, Clone, PartialEq)]
pub enum Data {
/// No extra metadata.
PCv1Legacy(pc_v1_legacy::PcV1Legacy),
/// "MusicEx" footer.
PCv2MusicEx(pc_v2_musicex::PcV2MusicEx),
/// Android "QTag", with ekey.
AndroidQTag(android_qtag::QTagMetadata),
/// Android "STag", metadata only.
AndroidSTag(android_stag::STagMetadata),
}
/// File Footer metadata
#[derive(Debug, Clone, PartialEq)]
pub struct Metadata {
/// Footer size to trim off.
pub size: usize,
/// Embedded key (not decrypted).
pub ekey: Option<String>,
/// data/type
pub data: Data,
}
pub trait MetadataParser {
fn from_byte_slice(buffer: &[u8]) -> Result<Option<Metadata>>;
}
pub fn from_byte_slice(buffer: &[u8]) -> Result<Option<Metadata>> {
if let Some(metadata) = STagMetadata::from_byte_slice(buffer)? {
return Ok(Some(metadata));
}
if let Some(metadata) = QTagMetadata::from_byte_slice(buffer)? {
return Ok(Some(metadata));
}
if let Some(metadata) = PcV2MusicEx::from_byte_slice(buffer)? {
return Ok(Some(metadata));
}
if let Some(metadata) = PcV1Legacy::from_byte_slice(buffer)? {
return Ok(Some(metadata));
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::footer::android_qtag::QTagMetadata;
use crate::footer::android_stag::STagMetadata;
use crate::footer::pc_v1_legacy::PcV1Legacy;
#[test]
fn test_qtag() {
let payload = include_bytes!("fixtures/ekey_android_qtag.bin");
let payload = from_byte_slice(payload)
.expect("Should not fail")
.expect("should parse to qtag");
assert_eq!(payload.ekey, Some("00112233aBcD+/=".into()));
assert_eq!(payload.size, 0x23);
assert_eq!(
payload.data,
Data::AndroidQTag(QTagMetadata {
resource_id: 326454301
})
)
}
#[test]
fn test_stag() {
let payload = include_bytes!("fixtures/ekey_android_stag.bin");
let payload = from_byte_slice(payload)
.expect("Should not fail")
.expect("should parse to stag");
assert_eq!(payload.ekey, None);
assert_eq!(payload.size, 0x20);
assert_eq!(
payload.data,
Data::AndroidSTag(STagMetadata {
media_mid: "001y7CaR29k6YP".into(),
resource_id: 5177785,
})
)
}
#[test]
fn test_pc_enc_v1() {
let payload = include_bytes!("fixtures/ekey_pc_enc_v1.bin");
let payload = from_byte_slice(payload)
.expect("Should not fail")
.expect("should parse pc v1");
let ekey = payload.ekey.expect("ekey should be present");
assert!(ekey.starts_with("NUZ6b0la"));
assert_eq!(payload.size, 0x2C4);
assert_eq!(payload.data, Data::PCv1Legacy(PcV1Legacy))
}
#[test]
fn test_pc_enc_v2() {
let payload = include_bytes!("fixtures/ekey_pc_enc_v2.bin");
let payload = from_byte_slice(payload)
.expect("Should not fail")
.expect("should parse pc v2");
assert_eq!(payload.ekey, None);
assert_eq!(payload.size, 0xC0);
assert_eq!(
payload.data,
Data::PCv2MusicEx(PcV2MusicEx {
mid: "AaBbCcDdEeFfGg".into(),
media_filename: "F0M000112233445566.mflac".into()
})
)
}
}

View File

@ -0,0 +1,77 @@
use std::io::{Cursor, Read};
use byteorder::{ByteOrder,ReadBytesExt, LE};
use crate::footer::{Data, FooterParseError, Metadata};
use crate::footer::pc_v2_musicex::PcV2MusicEx;
use crate::footer::utils::from_ascii_utf16;
#[derive(Debug, Clone, PartialEq)]
pub struct MusicExV1 {
/// unused & unknown
unknown_0: u32,
/// unused & unknown
unknown_1: u32,
/// unused & unknown
unknown_2: u32,
/// Media ID
mid: [u8; 30 * 2],
/// Media file name
media_filename: [u8; 50 * 2],
/// unused; uninitialized memory?
unknown_3: u32,
}
impl Default for MusicExV1 {
fn default() -> Self {
MusicExV1 {
unknown_0: 0,
unknown_1: 0,
unknown_2: 0,
mid: [0; 30 * 2],
media_filename: [0; 50 * 2],
unknown_3: 0,
}
}
}
impl MusicExV1 {
pub fn from_bytes(buffer: &[u8]) -> anyhow::Result<MusicExV1> {
assert_eq!(buffer.len(), 0xC0 - 0x10);
let mut cursor = Cursor::new(&buffer);
let mut result = MusicExV1::default();
result.unknown_0 = cursor.read_u32::<LE>()?;
result.unknown_1 = cursor.read_u32::<LE>()?;
result.unknown_2 = cursor.read_u32::<LE>()?;
cursor.read(&mut result.mid)?;
cursor.read(&mut result.media_filename)?;
result.unknown_3 = cursor.read_u32::<LE>()?;
Ok(result)
}
}
pub fn parse_v1(footer: &[u8]) -> anyhow::Result<Option<Metadata>> {
let (payload, payload_len) = footer.split_at(footer.len() - 4);
let payload_len = LE::read_u32(&payload_len) as usize;
if payload_len != 0xC0 {
Err(FooterParseError::PCv2MusicExUnsupportedPayloadSize(
payload_len,
))?;
}
let payload = &payload[payload.len() - (payload_len - 0x10)..];
let payload = MusicExV1::from_bytes(payload)?;
let mid = from_ascii_utf16(&payload.mid);
let media_filename = from_ascii_utf16(&payload.media_filename);
Ok(Some(Metadata {
ekey: None,
size: payload_len,
data: Data::PCv2MusicEx(PcV2MusicEx {
mid,
media_filename,
}),
}))
}

View File

@ -0,0 +1,44 @@
use crate::footer::utils::is_base64;
use crate::footer::{Data, FooterParseError, Metadata, MetadataParser};
use byteorder::{ByteOrder, LE};
pub const MAX_ALLOWED_EKEY_LEN: usize = 0x500;
#[derive(Debug, Clone, PartialEq)]
pub struct PcV1Legacy;
impl MetadataParser for PcV1Legacy {
fn from_byte_slice(buffer: &[u8]) -> anyhow::Result<Option<Metadata>> {
if buffer.len() < 8 {
Err(FooterParseError::BufferTooSmall(8))?;
}
let (payload, payload_len) = buffer.split_at(buffer.len() - 4);
let payload_len = LE::read_u32(payload_len) as usize;
// EKey payload is too large, probably not a valid V1 footer.
if payload_len > MAX_ALLOWED_EKEY_LEN {
Err(FooterParseError::PCv1EKeyTooLarge(payload_len))?;
}
if payload.len() < payload_len {
Err(FooterParseError::BufferTooSmall(payload_len + 4))?;
}
let payload = &payload[payload.len() - payload_len..];
let ekey = payload
.iter()
.take_while(|&&b| b != 0)
.map(|&b| b)
.collect::<Vec<_>>();
let ekey = String::from_utf8_lossy(ekey.as_slice());
if !is_base64(ekey.as_bytes()) {
Err(FooterParseError::PCv1EKeyInvalid)?;
}
Ok(Some(Metadata {
ekey: Some(ekey.into()),
size: payload_len + 4,
data: Data::PCv1Legacy(PcV1Legacy),
}))
}
}

View File

@ -0,0 +1,32 @@
use crate::footer::{musicex_v1, FooterParseError, Metadata, MetadataParser};
use anyhow::Result;
use byteorder::{ByteOrder, LE};
#[derive(Debug, Clone, PartialEq)]
pub struct PcV2MusicEx {
/// Resource identifier (`.mid`)
pub mid: String,
/// The actual file name used for `ekey` lookup (`.file.media_mid` + extension).
pub media_filename: String,
}
impl MetadataParser for PcV2MusicEx {
fn from_byte_slice(payload: &[u8]) -> Result<Option<Metadata>> {
if payload.len() < 16 {
Err(FooterParseError::BufferTooSmall(16))?;
}
if let Some(payload) = payload.strip_suffix(b"musicex\x00") {
let (payload, version) = payload.split_at(payload.len() - 4);
let version = LE::read_u32(version);
return match version {
1 => musicex_v1::parse_v1(payload),
_ => Err(FooterParseError::PCv2InvalidVersion(version))?,
};
}
Ok(None)
}
}

View File

@ -0,0 +1,17 @@
fn is_base64_chr(chr: u8) -> bool {
chr.is_ascii_alphanumeric() || (chr == b'+') || (chr == b'/') || (chr == b'=')
}
pub fn is_base64(s: &[u8]) -> bool {
s.iter().all(|&c| is_base64_chr(c))
}
/// Convert UTF-16 LE string (within ASCII char range) to UTF-8
pub fn from_ascii_utf16(data: &[u8]) -> String {
let data = data
.chunks_exact(2)
.take_while(|chunk| chunk[0] != 0 && chunk[0].is_ascii() && chunk[1] == 0)
.map(|chunk| chunk[0])
.collect::<Vec<_>>();
String::from_utf8_lossy(&data).to_string()
}

View File

@ -1,5 +1,10 @@
use crate::v2_map::QMC2Map;
use crate::v2_rc4::cipher::QMC2RC4;
use anyhow::Result;
use thiserror::Error;
pub mod ekey;
pub mod footer;
pub mod v1;
pub mod v2_map;
pub mod v2_rc4;
@ -9,3 +14,47 @@ pub enum QmcCryptoError {
#[error("QMC V2/Map Cipher: Key is empty")]
QMCV2MapKeyEmpty,
}
pub enum QMCv2Cipher {
MapL(QMC2Map),
RC4(QMC2RC4),
}
impl QMCv2Cipher {
pub fn new<T>(key: T) -> Result<Self>
where
T: AsRef<[u8]>,
{
let key = key.as_ref();
let cipher = match key.len() {
0 => Err(QmcCryptoError::QMCV2MapKeyEmpty)?,
..=300 => QMCv2Cipher::MapL(QMC2Map::new(key)?),
_ => QMCv2Cipher::RC4(QMC2RC4::new(key)),
};
Ok(cipher)
}
pub fn decrypt<T>(&self, data: &mut T, offset: usize)
where
T: AsMut<[u8]> + ?Sized,
{
match self {
QMCv2Cipher::MapL(cipher) => cipher.decrypt(data, offset),
QMCv2Cipher::RC4(cipher) => cipher.decrypt(data, offset),
}
}
}
#[cfg(test)]
mod test {
pub fn generate_key(len: usize) -> Vec<u8> {
(1..=len).map(|i| i as u8).collect()
}
#[cfg(test)]
pub fn generate_key_128() -> [u8; 128] {
generate_key(128)
.try_into()
.expect("failed to make test key")
}
}

View File

@ -11,3 +11,30 @@ pub fn qmc1_transform(key: &[u8; V1_KEY_SIZE], value: u8, offset: usize) -> u8 {
value ^ key[offset % V1_KEY_SIZE]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test::generate_key_128;
#[test]
fn test_decode_start() {
let test_key = generate_key_128();
let mut data = *b"igohj&pg{fo";
for (i, datum) in data.iter_mut().enumerate() {
*datum = qmc1_transform(&test_key, *datum, i);
}
assert_eq!(data, *b"hello world");
}
#[test]
fn test_decode_boundary() {
let test_key = generate_key_128();
let mut data = [
0x13, 0x19, 0x11, 0x12, 0x10, 0xa0, 0x75, 0x6c, 0x76, 0x69, 0x62,
];
for (i, datum) in data.iter_mut().enumerate() {
*datum = qmc1_transform(&test_key, *datum, 0x7FFA + i);
}
assert_eq!(data, *b"hello world");
}
}

View File

@ -14,7 +14,10 @@ impl QMC2Map {
Ok(Self { key })
}
pub fn decrypt<T: AsMut<[u8]>>(&self, data: &mut T, offset: usize) {
pub fn decrypt<T>(&self, data: &mut T, offset: usize)
where
T: AsMut<[u8]> + ?Sized,
{
for (i, datum) in data.as_mut().iter_mut().enumerate() {
*datum = qmc1_transform(&self.key, *datum, offset + i);
}

View File

@ -27,16 +27,16 @@ impl QMC2RC4 {
}
}
fn transform_first_segment(&mut self, offset: usize, dst: &mut [u8]) {
fn process_first_segment(&self, data: &mut [u8], offset: usize) {
let n = self.key.len();
for (value, offset) in dst.iter_mut().zip(offset..) {
for (datum, offset) in data.iter_mut().zip(offset..) {
let idx = get_segment_key(offset as u64, self.key[offset % n], self.hash) as usize;
*value ^= self.key[idx % n];
*datum ^= self.key[idx % n];
}
}
fn transform_other_segment(&mut self, offset: usize, data: &mut [u8]) {
fn process_other_segment(&self, data: &mut [u8], offset: usize) {
let n = self.key.len();
let id = offset / OTHER_SEGMENT_SIZE;
@ -52,14 +52,17 @@ impl QMC2RC4 {
}
}
pub fn transform(&mut self, start_offset: usize, data: &mut [u8]) {
let mut offset = start_offset;
let mut buffer = data;
pub fn decrypt<T>(&self, data: &mut T, offset: usize)
where
T: AsMut<[u8]> + ?Sized,
{
let mut offset = offset;
let mut buffer = data.as_mut();
if offset < FIRST_SEGMENT_SIZE {
let n = min(FIRST_SEGMENT_SIZE - offset, buffer.len());
let (block, rest) = buffer.split_at_mut(n);
buffer = rest;
self.transform_first_segment(offset, block);
self.process_first_segment(block, offset);
offset += n;
}
@ -69,7 +72,7 @@ impl QMC2RC4 {
let n = min(OTHER_SEGMENT_SIZE - excess, buffer.len());
let (block, rest) = buffer.split_at_mut(n);
buffer = rest;
self.transform_other_segment(offset, block);
self.process_other_segment(block, offset);
offset += n;
}
};
@ -78,7 +81,7 @@ impl QMC2RC4 {
let n = min(OTHER_SEGMENT_SIZE, buffer.len());
let (block, rest) = buffer.split_at_mut(n);
buffer = rest;
self.transform_other_segment(offset, block);
self.process_other_segment(block, offset);
offset += n;
}
}
@ -118,8 +121,8 @@ mod tests {
.take(512)
.collect::<Vec<u8>>();
let mut cipher = QMC2RC4::new(&key);
cipher.transform(0, &mut data);
let cipher = QMC2RC4::new(&key);
cipher.decrypt(&mut data, 0);
assert_eq!(data, [0u8; 256]);
}
}

View File

@ -0,0 +1,7 @@
[package]
name = "umc_utils"
version = "0.1.0"
edition = "2021"
[dependencies]
base64 = "0.22.1"

View File

@ -0,0 +1,22 @@
use base64::engine::{DecodePaddingMode, GeneralPurpose as Base64Engine, GeneralPurposeConfig};
use base64::{alphabet, DecodeError, Engine};
/// Don't add padding when encoding, and require no padding when decoding.
pub const ENGINE: Base64Engine = Base64Engine::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
);
pub fn encode<T>(data: T) -> String
where
T: AsRef<[u8]>,
{
ENGINE.encode(data)
}
pub fn decode<T>(data: T) -> Result<Vec<u8>, DecodeError>
where
T: AsRef<[u8]>,
{
ENGINE.decode(data)
}

View File

@ -0,0 +1 @@
pub mod base64;