mirror of
https://github.com/beerpiss/saekawa.git
synced 2024-11-23 23:00:58 +01:00
saekawa 0.4.0: rewrite boogaloo
This commit is contained in:
parent
3a9fd14c20
commit
d352266759
@ -1,2 +0,0 @@
|
|||||||
[build]
|
|
||||||
target = "i686-pc-windows-msvc"
|
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -133,3 +133,5 @@ fabric.properties
|
|||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/intellij
|
# End of https://www.toptal.com/developers/gitignore/api/intellij
|
||||||
|
|
||||||
|
*.pfx
|
||||||
|
*.json
|
||||||
|
661
Cargo.lock
generated
661
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
50
Cargo.toml
50
Cargo.toml
@ -1,42 +1,38 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "saekawa"
|
name = "saekawa"
|
||||||
version = "0.3.4"
|
version = "0.4.0"
|
||||||
authors = ["beerpsi <lacvtg.a1.2023@gmail.com>"]
|
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "0BSD"
|
license = "0BSD"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
strip = true # Automatically strip symbols from the binary.
|
|
||||||
opt-level = "z" # Optimize for size.
|
|
||||||
lto = true
|
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
aes = "0.8.3"
|
aes = "0.8.4"
|
||||||
anyhow = "1.0.75"
|
|
||||||
binary-reader = "0.4.5"
|
|
||||||
cbc = "0.1.2"
|
cbc = "0.1.2"
|
||||||
chrono = "0.4.31"
|
chrono = "0.4.38"
|
||||||
confy = "0.6.1"
|
confy = "0.6.1"
|
||||||
crc32fast = "1.3.2"
|
|
||||||
crochet = "0.2.3"
|
crochet = "0.2.3"
|
||||||
env_logger = "0.10.2"
|
env_logger = "0.11.3"
|
||||||
faster-hex = "0.9.0"
|
faster-hex = "0.9.0"
|
||||||
flate2 = "1.0.28"
|
flate2 = "1.0.30"
|
||||||
hex-literal = "0.4.1"
|
lightningscanner = "1.0.2"
|
||||||
lazy_static = "1.4.0"
|
log = "0.4.21"
|
||||||
log = "0.4.20"
|
num_enum = "0.7.2"
|
||||||
num_enum = "0.7.1"
|
|
||||||
pbkdf2 = "0.12.2"
|
pbkdf2 = "0.12.2"
|
||||||
serde = { version = "1.0.193", features = ["derive"] }
|
rand = "0.8.5"
|
||||||
serde-aux = "4.3.1"
|
rust-ini = "0.21.0"
|
||||||
serde_json = "1.0.108"
|
serde = { version = "1.0.203", features = ["derive"] }
|
||||||
|
serde-aux = "4.5.0"
|
||||||
|
serde_json = "1.0.117"
|
||||||
sha1 = "0.10.6"
|
sha1 = "0.10.6"
|
||||||
ureq = { version = "2.9.1", features = ["json"] }
|
sha2 = "0.10.8"
|
||||||
url = "2.5.0"
|
snafu = "0.8.3"
|
||||||
widestring = "1.0.2"
|
ureq = { version = "2.9.7", features = ["json"] }
|
||||||
winapi = { version = "0.3.9", features = ["winhttp", "minwindef", "debugapi", "synchapi", "libloaderapi", "processthreadsapi"] }
|
url = { version = "2.5.2", features = ["serde"] }
|
||||||
|
widestring = "1.1.0"
|
||||||
|
winapi = { version = "0.3.9", features = ["minwindef", "winnt", "psapi", "processthreadsapi", "libloaderapi", "errhandlingapi", "winhttp", "synchapi", "debugapi", "wincon", "heapapi", "winbase", "wincrypt", "softpub", "wintrust"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
snafu = "0.8.3"
|
||||||
|
vergen = { version = "8.3.1", features = ["build", "git", "gitcl"] }
|
||||||
|
12
build.rs
Normal file
12
build.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
use snafu::{prelude::*, Whatever};
|
||||||
|
use vergen::EmitBuilder;
|
||||||
|
|
||||||
|
pub fn main() -> Result<(), Whatever> {
|
||||||
|
EmitBuilder::builder()
|
||||||
|
.build_timestamp()
|
||||||
|
.git_sha(false)
|
||||||
|
.git_branch()
|
||||||
|
.emit()
|
||||||
|
.with_whatever_context(|_| "Could not emit version information")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
16
release.ps1
Normal file
16
release.ps1
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
cargo build --target i686-pc-windows-msvc --release
|
||||||
|
|
||||||
|
if (!(Test-Path ./saekawa.pfx)) {
|
||||||
|
$cert = New-SelfSignedCertificate -Type Custom `
|
||||||
|
-Subject "CN=saekawa self-signed certificate" `
|
||||||
|
-CertStoreLocation cert:\CurrentUser\My `
|
||||||
|
-KeyUsage DigitalSignature
|
||||||
|
|
||||||
|
Export-PfxCertificate -Cert $cert `
|
||||||
|
-FilePath saekawa.pfx `
|
||||||
|
-Password (ConvertTo-SecureString -String "saekawa" -Force -AsPlainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
signtool sign -f saekawa.pfx -p "saekawa" -fd SHA256 -t http://timestamp.comodoca.com/authenticode -v target/i686-pc-windows-msvc/release/saekawa.dll
|
||||||
|
|
||||||
|
Write-Output "Remember to make the .rtext section writable for auto-updates! I have fuck all idea how to do it in Rust itself, so it's manual from here. The DLL has already been signed."
|
23
src/config/defaults.rs
Normal file
23
src/config/defaults.rs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
use std::{path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
pub(super) fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn default_false() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn default_timeout() -> u64 {
|
||||||
|
5000
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn default_tachi_url() -> Url {
|
||||||
|
Url::parse("https://kamai.tachi.ac").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn default_failed_import_dir() -> Option<PathBuf> {
|
||||||
|
PathBuf::from_str("failed_saekawa_imports").ok()
|
||||||
|
}
|
42
src/config/migrate.rs
Normal file
42
src/config/migrate.rs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::defaults::*;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||||
|
pub struct OldSaekawaConfig {
|
||||||
|
pub general: OldGeneralConfig,
|
||||||
|
pub cards: OldCardsConfig,
|
||||||
|
pub tachi: OldTachiConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||||
|
pub struct OldGeneralConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enable: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub export_class: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_false")]
|
||||||
|
pub export_pbs: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_false")]
|
||||||
|
pub fail_over_lamp: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_timeout")]
|
||||||
|
pub timeout: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct OldCardsConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub whitelist: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct OldTachiConfig {
|
||||||
|
pub base_url: String,
|
||||||
|
pub status: String,
|
||||||
|
pub import: String,
|
||||||
|
pub api_key: String,
|
||||||
|
}
|
129
src/config/mod.rs
Normal file
129
src/config/mod.rs
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
mod defaults;
|
||||||
|
mod migrate;
|
||||||
|
|
||||||
|
use std::{collections::HashMap, path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
|
use log::{info, warn};
|
||||||
|
use migrate::OldSaekawaConfig;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use snafu::{ResultExt, Snafu};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use self::defaults::*;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||||
|
pub struct SaekawaConfig {
|
||||||
|
pub general: GeneralConfig,
|
||||||
|
pub cards: HashMap<String, String>,
|
||||||
|
pub tachi: TachiConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Snafu, Debug)]
|
||||||
|
pub enum ConfigLoadError {
|
||||||
|
#[snafu(display(
|
||||||
|
"Could not load or save configuration. Is the configuration format correct?"
|
||||||
|
))]
|
||||||
|
ConfyError { source: confy::ConfyError },
|
||||||
|
|
||||||
|
#[snafu(display("Could not migrate to new configuration format: {source:#?}"))]
|
||||||
|
MigrationError { source: MigrationError },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Snafu, Debug)]
|
||||||
|
pub enum MigrationError {
|
||||||
|
#[snafu(display("Invalid Tachi base URL."))]
|
||||||
|
InvalidTachiUrl { source: url::ParseError },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SaekawaConfig {
|
||||||
|
pub fn load() -> Result<SaekawaConfig, ConfigLoadError> {
|
||||||
|
let result = confy::load_path::<SaekawaConfig>("saekawa.toml");
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => result.context(ConfySnafu),
|
||||||
|
Err(_) => {
|
||||||
|
warn!("Could not parse configuration, attempting to parse as old configuration...");
|
||||||
|
let old_config =
|
||||||
|
confy::load_path::<OldSaekawaConfig>("saekawa.toml").context(ConfySnafu)?;
|
||||||
|
|
||||||
|
info!("Successfully loaded as old configuration, migrating to new format...");
|
||||||
|
let tachi_base_url = Url::parse(&old_config.tachi.base_url)
|
||||||
|
.context(InvalidTachiUrlSnafu)
|
||||||
|
.context(MigrationSnafu)?;
|
||||||
|
let new_tachi_config = TachiConfig {
|
||||||
|
base_url: tachi_base_url,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut new_cards_config: HashMap<String, String> = HashMap::new();
|
||||||
|
if old_config.cards.whitelist.is_empty() {
|
||||||
|
new_cards_config.insert("default".to_string(), old_config.tachi.api_key);
|
||||||
|
} else {
|
||||||
|
for card in old_config.cards.whitelist {
|
||||||
|
new_cards_config.insert(card, old_config.tachi.api_key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_general_config = GeneralConfig {
|
||||||
|
export_class: old_config.general.export_class,
|
||||||
|
fail_over_lamp: old_config.general.fail_over_lamp,
|
||||||
|
timeout: old_config.general.timeout,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_config = SaekawaConfig {
|
||||||
|
general: new_general_config,
|
||||||
|
cards: new_cards_config,
|
||||||
|
tachi: new_tachi_config,
|
||||||
|
};
|
||||||
|
|
||||||
|
confy::store_path("saekawa.toml", new_config.clone()).context(ConfySnafu)?;
|
||||||
|
|
||||||
|
Ok(new_config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct GeneralConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub export_class: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_false")]
|
||||||
|
pub fail_over_lamp: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_timeout")]
|
||||||
|
pub timeout: u64,
|
||||||
|
|
||||||
|
#[serde(default = "default_false")]
|
||||||
|
pub auto_update: bool,
|
||||||
|
|
||||||
|
#[serde(default = "default_failed_import_dir")]
|
||||||
|
pub failed_import_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GeneralConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
export_class: true,
|
||||||
|
fail_over_lamp: false,
|
||||||
|
timeout: 5000,
|
||||||
|
auto_update: true,
|
||||||
|
failed_import_dir: PathBuf::from_str("failed_saekawa_imports").ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct TachiConfig {
|
||||||
|
#[serde(default = "default_tachi_url")]
|
||||||
|
pub base_url: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TachiConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: Url::parse("https://kamai.tachi.ac").unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,98 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Configuration {
|
|
||||||
pub general: GeneralConfiguration,
|
|
||||||
|
|
||||||
pub cards: CardsConfiguration,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
pub crypto: CryptoConfiguration,
|
|
||||||
|
|
||||||
pub tachi: TachiConfiguration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Configuration {
|
|
||||||
pub fn load() -> Result<Self> {
|
|
||||||
if !Path::new("saekawa.toml").exists() {
|
|
||||||
File::create("saekawa.toml")
|
|
||||||
.and_then(|mut file| file.write_all(include_bytes!("../res/saekawa.toml")))
|
|
||||||
.map_err(|err| anyhow::anyhow!("Could not create default config file: {}", err))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
confy::load_path("saekawa.toml")
|
|
||||||
.map_err(|err| anyhow::anyhow!("Could not load config: {}", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(cfg: Configuration) -> Result<()> {
|
|
||||||
confy::store_path("saekawa.toml", cfg)
|
|
||||||
.map_err(|err| anyhow::anyhow!("Could not update config: {}", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct GeneralConfiguration {
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub enable: bool,
|
|
||||||
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub export_class: bool,
|
|
||||||
|
|
||||||
#[serde(default = "default_false")]
|
|
||||||
pub export_pbs: bool,
|
|
||||||
|
|
||||||
#[serde(default = "default_false")]
|
|
||||||
pub fail_over_lamp: bool,
|
|
||||||
|
|
||||||
#[serde(default = "default_timeout")]
|
|
||||||
pub timeout: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_true() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_false() -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_timeout() -> u64 {
|
|
||||||
3000
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct CardsConfiguration {
|
|
||||||
#[serde(default)]
|
|
||||||
pub whitelist: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct CryptoConfiguration {
|
|
||||||
#[serde(with = "faster_hex::nopfx_lowercase")]
|
|
||||||
pub key: Vec<u8>,
|
|
||||||
|
|
||||||
#[serde(with = "faster_hex::nopfx_lowercase")]
|
|
||||||
pub iv: Vec<u8>,
|
|
||||||
|
|
||||||
#[serde(with = "faster_hex::nopfx_lowercase")]
|
|
||||||
pub salt: Vec<u8>,
|
|
||||||
|
|
||||||
#[serde(default = "default_iterations")]
|
|
||||||
pub iterations: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_iterations() -> u32 {
|
|
||||||
70
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct TachiConfiguration {
|
|
||||||
pub base_url: String,
|
|
||||||
pub status: String,
|
|
||||||
pub import: String,
|
|
||||||
pub api_key: String,
|
|
||||||
}
|
|
@ -1,59 +0,0 @@
|
|||||||
use std::{fmt::Debug, sync::atomic::Ordering};
|
|
||||||
|
|
||||||
use log::{debug, error, info};
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
helpers::execute_tachi_import, saekawa::GAME_MAJOR_VERSION, types::tachi::ToTachiImport,
|
|
||||||
CONFIGURATION,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn score_handler<T>(body: String, guard: impl Fn(&T) -> bool)
|
|
||||||
where
|
|
||||||
T: Debug + DeserializeOwned + ToTachiImport,
|
|
||||||
{
|
|
||||||
let data = match serde_json::from_str::<T>(body.as_ref()) {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(err) => {
|
|
||||||
error!("Could not parse request body: {:#}", err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
debug!("parsed request body: {:#?}", data);
|
|
||||||
|
|
||||||
if !guard(&data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let import = data.to_tachi_import(
|
|
||||||
GAME_MAJOR_VERSION.load(Ordering::SeqCst),
|
|
||||||
CONFIGURATION.general.export_class,
|
|
||||||
CONFIGURATION.general.fail_over_lamp,
|
|
||||||
);
|
|
||||||
|
|
||||||
if import.scores.is_empty() {
|
|
||||||
if import.classes.is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if import
|
|
||||||
.classes
|
|
||||||
.clone()
|
|
||||||
.is_some_and(|v| v.dan.is_none() && v.emblem.is_none())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Submitting {} scores from {} {}",
|
|
||||||
import.scores.len(),
|
|
||||||
data.displayed_id_type(),
|
|
||||||
data.displayed_id(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(err) = execute_tachi_import(import) {
|
|
||||||
error!("{:#}", err);
|
|
||||||
}
|
|
||||||
}
|
|
66
src/helpers/chuni_encoding.rs
Normal file
66
src/helpers/chuni_encoding.rs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
use std::io::{self, Read};
|
||||||
|
|
||||||
|
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
|
||||||
|
use flate2::read::ZlibDecoder;
|
||||||
|
use pbkdf2::pbkdf2_hmac_array;
|
||||||
|
use sha1::Sha1;
|
||||||
|
use snafu::prelude::Snafu;
|
||||||
|
|
||||||
|
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
|
||||||
|
|
||||||
|
#[derive(Snafu, Debug)]
|
||||||
|
pub enum DecryptError {
|
||||||
|
InvalidLength,
|
||||||
|
UnpadError,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MaybeDecompressError {
|
||||||
|
pub zlib_error: io::Error,
|
||||||
|
pub raw_error: io::Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_endpoint(endpoint: impl AsRef<str>, salt: impl AsRef<[u8]>, rounds: u32) -> String {
|
||||||
|
let key_bytes =
|
||||||
|
pbkdf2_hmac_array::<Sha1, 16>(endpoint.as_ref().as_bytes(), salt.as_ref(), rounds);
|
||||||
|
|
||||||
|
faster_hex::hex_string(&key_bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt_aes256_cbc(
|
||||||
|
body: &mut [u8],
|
||||||
|
key: impl AsRef<[u8]>,
|
||||||
|
iv: impl AsRef<[u8]>,
|
||||||
|
) -> Result<Vec<u8>, DecryptError> {
|
||||||
|
let cipher = Aes256CbcDec::new_from_slices(key.as_ref(), iv.as_ref())
|
||||||
|
.map_err(|_| DecryptError::InvalidLength)?;
|
||||||
|
|
||||||
|
Ok(cipher
|
||||||
|
.decrypt_padded_mut::<Pkcs7>(body)
|
||||||
|
.map_err(|_| DecryptError::UnpadError)?
|
||||||
|
.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn maybe_decompress_buffer(buf: impl AsRef<[u8]>) -> Result<String, MaybeDecompressError> {
|
||||||
|
let mut ret = String::with_capacity(buf.as_ref().len() * 2);
|
||||||
|
|
||||||
|
let mut decoder = ZlibDecoder::new(buf.as_ref());
|
||||||
|
let zlib_result = decoder.read_to_string(&mut ret);
|
||||||
|
|
||||||
|
if zlib_result.is_ok() {
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.clear();
|
||||||
|
|
||||||
|
let result = buf.as_ref().read_to_string(&mut ret);
|
||||||
|
|
||||||
|
if result.is_ok() {
|
||||||
|
return Ok(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(MaybeDecompressError {
|
||||||
|
zlib_error: zlib_result.expect_err("must be Err if reached here"),
|
||||||
|
raw_error: result.expect_err("must be Err if reached here"),
|
||||||
|
})
|
||||||
|
}
|
@ -1,16 +0,0 @@
|
|||||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
|
|
||||||
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
|
|
||||||
|
|
||||||
pub fn decrypt_aes256_cbc(
|
|
||||||
body: &mut [u8],
|
|
||||||
key: impl AsRef<[u8]>,
|
|
||||||
iv: impl AsRef<[u8]>,
|
|
||||||
) -> Result<Vec<u8>> {
|
|
||||||
let cipher = Aes256CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?;
|
|
||||||
Ok(cipher
|
|
||||||
.decrypt_padded_mut::<Pkcs7>(body)
|
|
||||||
.map_err(|err| anyhow!(err))?
|
|
||||||
.to_owned())
|
|
||||||
}
|
|
@ -1,58 +0,0 @@
|
|||||||
use log::debug;
|
|
||||||
use pbkdf2::pbkdf2_hmac_array;
|
|
||||||
use sha1::Sha1;
|
|
||||||
|
|
||||||
use crate::CONFIGURATION;
|
|
||||||
|
|
||||||
pub fn is_endpoint(
|
|
||||||
endpoint: &str,
|
|
||||||
unencrypted_variant: &str,
|
|
||||||
encrypted_variant: &Option<String>,
|
|
||||||
) -> bool {
|
|
||||||
if endpoint == unencrypted_variant {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if encrypted_variant.as_ref().is_some_and(|v| v == endpoint) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determine if it is an encrypted endpoint by checking if the endpoint
|
|
||||||
/// is exactly 32 characters long, and consists of all hex characters.
|
|
||||||
///
|
|
||||||
/// While this may trigger false positives, this should not happen as long
|
|
||||||
/// as CHUNITHM title APIs keep their `{method}{object}Api` endpoint
|
|
||||||
/// convention.
|
|
||||||
pub fn is_encrypted_endpoint(endpoint: &str) -> bool {
|
|
||||||
if endpoint.len() != 32 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lazy way to check if all digits are hexadecimal
|
|
||||||
if u128::from_str_radix(endpoint, 16).is_err() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn hash_endpoint(endpoint: &str) -> Option<String> {
|
|
||||||
if CONFIGURATION.crypto.salt.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let key_bytes = pbkdf2_hmac_array::<Sha1, 16>(
|
|
||||||
endpoint.as_bytes(),
|
|
||||||
&CONFIGURATION.crypto.salt,
|
|
||||||
CONFIGURATION.crypto.iterations,
|
|
||||||
);
|
|
||||||
|
|
||||||
let key = faster_hex::hex_string(&key_bytes);
|
|
||||||
|
|
||||||
debug!("Running with encryption support: {endpoint} maps to {key}");
|
|
||||||
|
|
||||||
Some(key)
|
|
||||||
}
|
|
@ -1,114 +0,0 @@
|
|||||||
use std::ptr;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use widestring::U16CString;
|
|
||||||
use winapi::{
|
|
||||||
ctypes::c_void,
|
|
||||||
shared::{minwindef::TRUE, winerror::ERROR_INSUFFICIENT_BUFFER},
|
|
||||||
um::{
|
|
||||||
errhandlingapi::GetLastError,
|
|
||||||
winhttp::{
|
|
||||||
WinHttpQueryHeaders, WinHttpQueryOption, HINTERNET, WINHTTP_OPTION_URL,
|
|
||||||
WINHTTP_QUERY_FLAG_REQUEST_HEADERS, WINHTTP_QUERY_USER_AGENT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Queries a HINTERNET handle for its URL, then return the result.
|
|
||||||
pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
|
|
||||||
let mut buf_length = 255;
|
|
||||||
let mut buffer = [0u16; 255];
|
|
||||||
let result = unsafe {
|
|
||||||
WinHttpQueryOption(
|
|
||||||
handle,
|
|
||||||
WINHTTP_OPTION_URL,
|
|
||||||
buffer.as_mut_ptr() as *mut c_void,
|
|
||||||
&mut buf_length,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
if result == TRUE {
|
|
||||||
let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
|
|
||||||
return url_str
|
|
||||||
.to_string()
|
|
||||||
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
|
|
||||||
}
|
|
||||||
|
|
||||||
let ec = unsafe { GetLastError() };
|
|
||||||
if ec == ERROR_INSUFFICIENT_BUFFER {
|
|
||||||
let mut buffer = vec![0u16; buf_length as usize];
|
|
||||||
let result = unsafe {
|
|
||||||
WinHttpQueryOption(
|
|
||||||
handle,
|
|
||||||
WINHTTP_OPTION_URL,
|
|
||||||
buffer.as_mut_ptr() as *mut c_void,
|
|
||||||
&mut buf_length,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
if result != TRUE {
|
|
||||||
let ec = unsafe { GetLastError() };
|
|
||||||
return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
|
|
||||||
return url_str
|
|
||||||
.to_string()
|
|
||||||
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
|
|
||||||
}
|
|
||||||
|
|
||||||
let ec = unsafe { GetLastError() };
|
|
||||||
Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_hinternet_user_agent(handle: HINTERNET) -> Result<String> {
|
|
||||||
let mut buf_length = 255;
|
|
||||||
let mut buffer = [0u16; 255];
|
|
||||||
let result = unsafe {
|
|
||||||
WinHttpQueryHeaders(
|
|
||||||
handle,
|
|
||||||
WINHTTP_QUERY_USER_AGENT | WINHTTP_QUERY_FLAG_REQUEST_HEADERS,
|
|
||||||
ptr::null(),
|
|
||||||
buffer.as_mut_ptr() as *mut c_void,
|
|
||||||
&mut buf_length,
|
|
||||||
ptr::null_mut(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
if result == TRUE {
|
|
||||||
let user_agent_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
|
|
||||||
return user_agent_str
|
|
||||||
.to_string()
|
|
||||||
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
|
|
||||||
}
|
|
||||||
|
|
||||||
let ec = unsafe { GetLastError() };
|
|
||||||
if ec == ERROR_INSUFFICIENT_BUFFER {
|
|
||||||
let mut buffer = vec![0u16; buf_length as usize];
|
|
||||||
let result = unsafe {
|
|
||||||
WinHttpQueryHeaders(
|
|
||||||
handle,
|
|
||||||
WINHTTP_QUERY_USER_AGENT | WINHTTP_QUERY_FLAG_REQUEST_HEADERS,
|
|
||||||
ptr::null(),
|
|
||||||
buffer.as_mut_ptr() as *mut c_void,
|
|
||||||
&mut buf_length,
|
|
||||||
ptr::null_mut(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
if result != TRUE {
|
|
||||||
let ec = unsafe { GetLastError() };
|
|
||||||
return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let user_agent_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]);
|
|
||||||
return user_agent_str
|
|
||||||
.to_string()
|
|
||||||
.map_err(|err| anyhow!("Could not decode wide string: {:#}", err));
|
|
||||||
}
|
|
||||||
|
|
||||||
let ec = unsafe { GetLastError() };
|
|
||||||
Err(anyhow!(
|
|
||||||
"Could not get User-Agent from HINTERNET handle: {ec}"
|
|
||||||
))
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
use std::io::Read;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use flate2::read::ZlibDecoder;
|
|
||||||
|
|
||||||
pub fn read_slice(buf: *const u8, len: usize) -> Result<Vec<u8>> {
|
|
||||||
let mut slice = unsafe { std::slice::from_raw_parts(buf, len) };
|
|
||||||
let mut ret = Vec::with_capacity(len);
|
|
||||||
|
|
||||||
slice.read_to_end(&mut ret)?;
|
|
||||||
|
|
||||||
Ok(ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_maybe_compressed_buffer(buf: impl AsRef<[u8]>) -> Result<String> {
|
|
||||||
let mut ret = String::new();
|
|
||||||
|
|
||||||
let mut decoder = ZlibDecoder::new(buf.as_ref());
|
|
||||||
let zlib_result = decoder.read_to_string(&mut ret);
|
|
||||||
if zlib_result.is_ok() {
|
|
||||||
return Ok(ret);
|
|
||||||
}
|
|
||||||
|
|
||||||
ret.clear();
|
|
||||||
let result = buf.as_ref().read_to_string(&mut ret);
|
|
||||||
if result.is_ok() {
|
|
||||||
return Ok(ret);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(anyhow!(
|
|
||||||
"Could not decode contents of buffer as both DEFLATE-compressed ({:#}) and plaintext ({:#}) UTF-8 string.",
|
|
||||||
zlib_result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
|
|
||||||
result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
|
|
||||||
))
|
|
||||||
}
|
|
@ -1,11 +1,2 @@
|
|||||||
mod crypto;
|
pub mod chuni_encoding;
|
||||||
mod endpoint;
|
pub mod winapi_ext;
|
||||||
mod hinternet;
|
|
||||||
mod io;
|
|
||||||
mod net;
|
|
||||||
|
|
||||||
pub use crypto::*;
|
|
||||||
pub use endpoint::*;
|
|
||||||
pub use hinternet::*;
|
|
||||||
pub use io::*;
|
|
||||||
pub use net::*;
|
|
||||||
|
@ -1,140 +0,0 @@
|
|||||||
use std::fmt::Debug;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use log::{debug, error, info};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
types::tachi::{Import, ImportDocument, ImportPollStatus, ImportResponse, TachiResponse},
|
|
||||||
CONFIGURATION, TACHI_IMPORT_URL,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn request_agent() -> ureq::Agent {
|
|
||||||
let timeout = CONFIGURATION.general.timeout;
|
|
||||||
let timeout = if timeout > 10000 { 10000 } else { timeout };
|
|
||||||
|
|
||||||
ureq::builder()
|
|
||||||
.timeout(std::time::Duration::from_millis(timeout))
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request<T>(
|
|
||||||
method: impl AsRef<str>,
|
|
||||||
url: impl AsRef<str>,
|
|
||||||
body: Option<T>,
|
|
||||||
) -> Result<ureq::Response>
|
|
||||||
where
|
|
||||||
T: Serialize + Debug,
|
|
||||||
{
|
|
||||||
let agent = request_agent();
|
|
||||||
|
|
||||||
let method = method.as_ref();
|
|
||||||
let url = url.as_ref();
|
|
||||||
debug!("{} request to {} with body: {:#?}", method, url, body);
|
|
||||||
|
|
||||||
let authorization = format!("Bearer {}", CONFIGURATION.tachi.api_key);
|
|
||||||
let request = agent
|
|
||||||
.request(method, url)
|
|
||||||
.set("Authorization", authorization.as_str());
|
|
||||||
let response = match body {
|
|
||||||
Some(body) => request.send_json(body),
|
|
||||||
None => request.call(),
|
|
||||||
}
|
|
||||||
.map_err(|err| anyhow::anyhow!("Could not reach Tachi API: {:#}", err))?;
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn request_tachi<T, R>(
|
|
||||||
method: impl AsRef<str>,
|
|
||||||
url: impl AsRef<str>,
|
|
||||||
body: Option<T>,
|
|
||||||
) -> Result<R>
|
|
||||||
where
|
|
||||||
T: Serialize + Debug,
|
|
||||||
R: for<'de> Deserialize<'de> + Debug,
|
|
||||||
{
|
|
||||||
let response = request(method, url, body)?;
|
|
||||||
let response = response.into_json()?;
|
|
||||||
debug!("Tachi API response: {:#?}", response);
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_import(description: &str, import: ImportDocument) {
|
|
||||||
info!(
|
|
||||||
"{description} {} scores, {} sessions, {} errors",
|
|
||||||
import.score_ids.len(),
|
|
||||||
import.created_sessions.len(),
|
|
||||||
import.errors.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
for err in import.errors {
|
|
||||||
error!("{}: {}", err.error_type, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Executes a DIRECT-MANUAL import and logs some information on success.
|
|
||||||
///
|
|
||||||
/// ## Important
|
|
||||||
/// This function blocks until import has fully finished! It is best to call this in a separate thread.
|
|
||||||
pub fn execute_tachi_import(import: Import) -> Result<()> {
|
|
||||||
let resp: TachiResponse<ImportResponse> =
|
|
||||||
match request_tachi("POST", TACHI_IMPORT_URL.as_str(), Some(import)) {
|
|
||||||
Err(err) => {
|
|
||||||
return Err(anyhow!("Could not send scores to Tachi: {:#}", err));
|
|
||||||
}
|
|
||||||
Ok(resp) => resp,
|
|
||||||
};
|
|
||||||
|
|
||||||
let (body, description) = match resp {
|
|
||||||
TachiResponse::Err(err) => {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"Tachi API returned an error: {:#}",
|
|
||||||
err.description
|
|
||||||
));
|
|
||||||
}
|
|
||||||
TachiResponse::Ok(resp) => (resp.body, resp.description),
|
|
||||||
};
|
|
||||||
|
|
||||||
let poll_url = match body {
|
|
||||||
ImportResponse::Queued { url, import_id: _ } => {
|
|
||||||
info!("Queued import for processing. Status URL: {}", url);
|
|
||||||
url
|
|
||||||
}
|
|
||||||
ImportResponse::Finished(import) => {
|
|
||||||
log_import(&description, import);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let resp: TachiResponse<ImportPollStatus> =
|
|
||||||
match request_tachi("GET", &poll_url, None::<()>) {
|
|
||||||
Ok(resp) => resp,
|
|
||||||
Err(err) => {
|
|
||||||
error!("Could not poll import status: {:#}", err);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (body, description) = match resp {
|
|
||||||
TachiResponse::Ok(resp) => (resp.body, resp.description),
|
|
||||||
TachiResponse::Err(err) => {
|
|
||||||
return Err(anyhow!("Tachi API returned an error: {}", err.description));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match body {
|
|
||||||
ImportPollStatus::Completed { import } => {
|
|
||||||
log_import(&description, import);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
99
src/helpers/winapi_ext.rs
Normal file
99
src/helpers/winapi_ext.rs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
use snafu::prelude::Snafu;
|
||||||
|
use widestring::U16CString;
|
||||||
|
use winapi::{
|
||||||
|
ctypes::c_void,
|
||||||
|
shared::{
|
||||||
|
minwindef::{HINSTANCE, HMODULE, TRUE},
|
||||||
|
winerror::ERROR_INSUFFICIENT_BUFFER,
|
||||||
|
},
|
||||||
|
um::{
|
||||||
|
errhandlingapi::GetLastError,
|
||||||
|
libloaderapi::{FreeLibraryAndExitThread, GetModuleFileNameW},
|
||||||
|
winhttp::{WinHttpQueryOption, HINTERNET},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct LibraryHandle(HINSTANCE);
|
||||||
|
|
||||||
|
unsafe impl Send for LibraryHandle {}
|
||||||
|
unsafe impl Sync for LibraryHandle {}
|
||||||
|
impl LibraryHandle {
|
||||||
|
pub unsafe fn new(handle: HINSTANCE) -> Self {
|
||||||
|
Self(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle(&self) -> HINSTANCE {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn free_and_exit_thread(self, code: u32) -> ! {
|
||||||
|
unsafe {
|
||||||
|
FreeLibraryAndExitThread(self.0, code);
|
||||||
|
}
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
pub enum ReadStringFnError {
|
||||||
|
InvalidData,
|
||||||
|
Other { errno: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_string_from_function_call(
|
||||||
|
reader: impl Fn(&mut [u16], &mut u32) -> i32,
|
||||||
|
is_success: impl Fn(i32) -> bool,
|
||||||
|
) -> Result<String, ReadStringFnError> {
|
||||||
|
let mut buffer = vec![0u16; 255];
|
||||||
|
let mut buffer_length = 255;
|
||||||
|
let result = reader(&mut buffer, &mut buffer_length);
|
||||||
|
|
||||||
|
if is_success(result) {
|
||||||
|
let out = U16CString::from_vec_truncate(&buffer[..buffer_length as usize]);
|
||||||
|
|
||||||
|
return out.to_string().map_err(|_| ReadStringFnError::InvalidData);
|
||||||
|
}
|
||||||
|
|
||||||
|
let errno = unsafe { GetLastError() };
|
||||||
|
|
||||||
|
if errno == ERROR_INSUFFICIENT_BUFFER {
|
||||||
|
buffer.resize(buffer_length as usize, 0);
|
||||||
|
let result = reader(&mut buffer, &mut buffer_length);
|
||||||
|
|
||||||
|
if result != TRUE {
|
||||||
|
let errno = unsafe { GetLastError() };
|
||||||
|
|
||||||
|
return Err(ReadStringFnError::Other { errno });
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = U16CString::from_vec_truncate(&buffer[..buffer_length as usize]);
|
||||||
|
|
||||||
|
return out.to_string().map_err(|_| ReadStringFnError::InvalidData);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ReadStringFnError::Other { errno })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn winhttp_query_option(handle: HINTERNET, option: u32) -> Result<String, ReadStringFnError> {
|
||||||
|
read_string_from_function_call(
|
||||||
|
|buf, buflen| unsafe {
|
||||||
|
WinHttpQueryOption(handle, option, buf.as_mut_ptr() as *mut c_void, buflen)
|
||||||
|
},
|
||||||
|
|ret| ret == TRUE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_module_file_name(handle: HMODULE) -> Result<String, ReadStringFnError> {
|
||||||
|
read_string_from_function_call(
|
||||||
|
|buf, buflen| unsafe {
|
||||||
|
let ret = GetModuleFileNameW(handle, buf.as_mut_ptr(), *buflen) as i32;
|
||||||
|
|
||||||
|
if GetLastError() == ERROR_INSUFFICIENT_BUFFER {
|
||||||
|
*buflen = 32767;
|
||||||
|
}
|
||||||
|
|
||||||
|
ret
|
||||||
|
},
|
||||||
|
|_| unsafe { GetLastError() != ERROR_INSUFFICIENT_BUFFER },
|
||||||
|
)
|
||||||
|
}
|
245
src/icf.rs
245
src/icf.rs
@ -1,245 +0,0 @@
|
|||||||
use std::fmt::Display;
|
|
||||||
|
|
||||||
use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit};
|
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use binary_reader::{BinaryReader, Endian};
|
|
||||||
use chrono::{NaiveDate, NaiveDateTime};
|
|
||||||
|
|
||||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
|
||||||
|
|
||||||
const ICF_KEY: [u8; 16] = hex_literal::decode(&[env!("SAEKAWA_ICF_KEY").as_bytes()]);
|
|
||||||
const ICF_IV: [u8; 16] = hex_literal::decode(&[env!("SAEKAWA_ICF_IV").as_bytes()]);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct Version {
|
|
||||||
pub major: u16,
|
|
||||||
pub minor: u8,
|
|
||||||
pub build: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for Version {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "{}.{:0>2}.{:0>2}", self.major, self.minor, self.build)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct IcfInnerData {
|
|
||||||
pub id: String,
|
|
||||||
pub version: Version,
|
|
||||||
pub required_system_version: Version,
|
|
||||||
pub datetime: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct IcfPatchData {
|
|
||||||
pub id: String,
|
|
||||||
pub source_version: Version,
|
|
||||||
pub target_version: Version,
|
|
||||||
pub required_system_version: Version,
|
|
||||||
pub datetime: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum IcfData {
|
|
||||||
System(IcfInnerData),
|
|
||||||
App(IcfInnerData),
|
|
||||||
Patch(IcfPatchData),
|
|
||||||
Option(IcfInnerData),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrypt_icf(data: &mut [u8], key: impl AsRef<[u8]>, iv: impl AsRef<[u8]>) -> Result<Vec<u8>> {
|
|
||||||
let size = data.len();
|
|
||||||
|
|
||||||
let mut decrypted = Vec::with_capacity(size);
|
|
||||||
|
|
||||||
for i in (0..size).step_by(4096) {
|
|
||||||
let from_start = i;
|
|
||||||
|
|
||||||
let bufsz = std::cmp::min(4096, size - from_start);
|
|
||||||
let buf = &data[i..i + bufsz];
|
|
||||||
let mut decbuf = vec![0; bufsz];
|
|
||||||
|
|
||||||
let cipher = Aes128CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?;
|
|
||||||
cipher
|
|
||||||
.decrypt_padded_b2b_mut::<NoPadding>(buf, &mut decbuf)
|
|
||||||
.map_err(|err| anyhow!(err))?;
|
|
||||||
|
|
||||||
let xor1 = u64::from_le_bytes(decbuf[0..8].try_into()?) ^ (from_start as u64);
|
|
||||||
let xor2 = u64::from_le_bytes(decbuf[8..16].try_into()?) ^ (from_start as u64);
|
|
||||||
|
|
||||||
decrypted.extend(xor1.to_le_bytes());
|
|
||||||
decrypted.extend(xor2.to_le_bytes());
|
|
||||||
decrypted.extend(&decbuf[16..]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(decrypted)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_icf_container_data(
|
|
||||||
rd: &mut BinaryReader,
|
|
||||||
) -> Result<(Version, NaiveDateTime, Version)> {
|
|
||||||
let version = Version {
|
|
||||||
build: rd.read_u8()?,
|
|
||||||
minor: rd.read_u8()?,
|
|
||||||
major: rd.read_u16()?,
|
|
||||||
};
|
|
||||||
|
|
||||||
let datetime = NaiveDate::from_ymd_opt(
|
|
||||||
rd.read_i16()? as i32,
|
|
||||||
rd.read_u8()? as u32,
|
|
||||||
rd.read_u8()? as u32,
|
|
||||||
)
|
|
||||||
.ok_or(anyhow!("Invalid date"))?
|
|
||||||
.and_hms_milli_opt(
|
|
||||||
rd.read_u8()? as u32,
|
|
||||||
rd.read_u8()? as u32,
|
|
||||||
rd.read_u8()? as u32,
|
|
||||||
rd.read_u8()? as u32,
|
|
||||||
)
|
|
||||||
.ok_or(anyhow!("Invalid time"))?;
|
|
||||||
|
|
||||||
let required_system_version = Version {
|
|
||||||
build: rd.read_u8()?,
|
|
||||||
minor: rd.read_u8()?,
|
|
||||||
major: rd.read_u16()?,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((version, datetime, required_system_version))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_icf(data: &mut [u8]) -> Result<Vec<IcfData>> {
|
|
||||||
let decrypted = decrypt_icf(data, ICF_KEY, ICF_IV)?;
|
|
||||||
|
|
||||||
let mut rd = BinaryReader::from_vec(&decrypted);
|
|
||||||
rd.endian = Endian::Little;
|
|
||||||
|
|
||||||
let checksum = crc32fast::hash(&decrypted[4..]);
|
|
||||||
let reported_crc = rd.read_u32()?;
|
|
||||||
if reported_crc != checksum {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"Reported CRC32 ({reported_crc:02X}) does not match actual checksum ({checksum:02X})"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let reported_size = rd.read_u32()? as usize;
|
|
||||||
let actual_size = decrypted.len();
|
|
||||||
if actual_size != reported_size {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"Reported size {reported_size} does not match actual size {actual_size}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let padding = rd.read_u64()?;
|
|
||||||
if padding != 0 {
|
|
||||||
return Err(anyhow!("Padding error. Expected 8 NULL bytes."));
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry_count: usize = rd.read_u64()?.try_into()?;
|
|
||||||
let expected_size = 0x40 * (entry_count + 1);
|
|
||||||
if actual_size != expected_size {
|
|
||||||
return Err(anyhow!("Expected size {expected_size} ({entry_count} entries) does not match actual size {actual_size}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let app_id = String::from_utf8(rd.read_bytes(4)?.to_vec())?;
|
|
||||||
let platform_id = String::from_utf8(rd.read_bytes(3)?.to_vec())?;
|
|
||||||
let _platform_generation = rd.read_u8()?;
|
|
||||||
|
|
||||||
let reported_crc = rd.read_u32()?;
|
|
||||||
let mut checksum = 0;
|
|
||||||
for i in 1..=entry_count {
|
|
||||||
let container = &decrypted[0x40 * i..0x40 * (i + 1)];
|
|
||||||
if container[0] == 2 && container[1] == 1 {
|
|
||||||
checksum ^= crc32fast::hash(container);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if reported_crc != checksum {
|
|
||||||
return Err(anyhow!("Reported container CRC32 ({reported_crc:02X}) does not match actual checksum ({checksum:02X})"));
|
|
||||||
}
|
|
||||||
|
|
||||||
for _ in 0..7 {
|
|
||||||
if rd.read_u32()? != 0 {
|
|
||||||
return Err(anyhow!("Padding error. Expected 28 NULL bytes."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut entries: Vec<IcfData> = Vec::with_capacity(entry_count);
|
|
||||||
for _ in 0..entry_count {
|
|
||||||
let sig = rd.read_u32()?;
|
|
||||||
|
|
||||||
if sig != 0x0102 && sig != 0x0201 {
|
|
||||||
return Err(anyhow!("Container does not start with signature (0x0102)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let container_type = rd.read_u32()?;
|
|
||||||
for _ in 0..3 {
|
|
||||||
if rd.read_u64()? != 0 {
|
|
||||||
return Err(anyhow!("Padding error. Expected 24 NULL bytes."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: IcfData = match container_type {
|
|
||||||
0x0000 | 0x0001 | 0x0002 => {
|
|
||||||
let (version, datetime, required_system_version) =
|
|
||||||
decode_icf_container_data(&mut rd)?;
|
|
||||||
|
|
||||||
for _ in 0..2 {
|
|
||||||
if rd.read_u64()? != 0 {
|
|
||||||
return Err(anyhow!("Padding error. Expected 16 NULL bytes."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match container_type {
|
|
||||||
0x0000 => IcfData::System(IcfInnerData {
|
|
||||||
id: platform_id.clone(),
|
|
||||||
version,
|
|
||||||
datetime,
|
|
||||||
required_system_version,
|
|
||||||
}),
|
|
||||||
0x0001 => IcfData::App(IcfInnerData {
|
|
||||||
id: app_id.clone(),
|
|
||||||
version,
|
|
||||||
datetime,
|
|
||||||
required_system_version,
|
|
||||||
}),
|
|
||||||
0x0002 => IcfData::Option(IcfInnerData {
|
|
||||||
id: app_id.clone(),
|
|
||||||
version,
|
|
||||||
datetime,
|
|
||||||
required_system_version,
|
|
||||||
}),
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// PATCH container type also encode the patch's sequence number
|
|
||||||
// in the higher 16 bits.
|
|
||||||
// The lower 16 bits will always be 1.
|
|
||||||
let sequence_number = (container_type >> 8) as u8;
|
|
||||||
|
|
||||||
if (container_type & 1) == 0 || sequence_number == 0 {
|
|
||||||
println!("Unknown ICF container type {container_type:#06x} at byte {:#06x}, skipping", rd.pos);
|
|
||||||
rd.read_bytes(32)?;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (target_version, target_datetime, _) = decode_icf_container_data(&mut rd)?;
|
|
||||||
let (source_version, _, source_required_system_version) =
|
|
||||||
decode_icf_container_data(&mut rd)?;
|
|
||||||
|
|
||||||
IcfData::Patch(IcfPatchData {
|
|
||||||
id: app_id.clone(),
|
|
||||||
source_version,
|
|
||||||
target_version,
|
|
||||||
required_system_version: source_required_system_version,
|
|
||||||
datetime: target_datetime,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
entries.push(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
169
src/lib.rs
169
src/lib.rs
@ -1,111 +1,26 @@
|
|||||||
mod configuration;
|
mod config;
|
||||||
mod handlers;
|
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod icf;
|
mod logging;
|
||||||
mod log;
|
|
||||||
mod saekawa;
|
mod saekawa;
|
||||||
|
mod score_import;
|
||||||
|
mod sigscan;
|
||||||
mod types;
|
mod types;
|
||||||
|
mod updater;
|
||||||
|
|
||||||
use std::ffi::c_void;
|
use std::thread;
|
||||||
use std::{ptr, thread};
|
|
||||||
|
|
||||||
use ::log::{error, warn};
|
use log::{error, info, warn};
|
||||||
use lazy_static::lazy_static;
|
use winapi::{
|
||||||
use url::Url;
|
shared::minwindef::{BOOL, DWORD, HINSTANCE, LPVOID, TRUE},
|
||||||
use winapi::shared::minwindef::{BOOL, DWORD, FALSE, HINSTANCE, LPVOID, TRUE};
|
um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH},
|
||||||
use winapi::um::errhandlingapi::GetLastError;
|
};
|
||||||
use winapi::um::handleapi::{CloseHandle, DuplicateHandle};
|
|
||||||
use winapi::um::processthreadsapi::{GetCurrentProcess, GetCurrentThread};
|
|
||||||
use winapi::um::synchapi::WaitForSingleObject;
|
|
||||||
use winapi::um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH, SYNCHRONIZE};
|
|
||||||
|
|
||||||
use crate::configuration::Configuration;
|
use crate::{
|
||||||
use crate::helpers::hash_endpoint;
|
helpers::winapi_ext::LibraryHandle,
|
||||||
use crate::log::Logger;
|
logging::init_logger,
|
||||||
use crate::saekawa::{hook_init, hook_release};
|
saekawa::{hook_init, hook_release},
|
||||||
|
updater::self_update,
|
||||||
lazy_static! {
|
};
|
||||||
pub static ref CONFIGURATION: Configuration = {
|
|
||||||
let result = Configuration::load();
|
|
||||||
if let Err(err) = result {
|
|
||||||
error!("{:#}", err);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.unwrap()
|
|
||||||
};
|
|
||||||
pub static ref TACHI_STATUS_URL: String = {
|
|
||||||
let result = Url::parse(&CONFIGURATION.tachi.base_url)
|
|
||||||
.and_then(|url| url.join(&CONFIGURATION.tachi.status));
|
|
||||||
if let Err(err) = result {
|
|
||||||
error!("Could not parse Tachi status URL: {:#}", err);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.unwrap().to_string()
|
|
||||||
};
|
|
||||||
pub static ref TACHI_IMPORT_URL: String = {
|
|
||||||
let result = Url::parse(&CONFIGURATION.tachi.base_url)
|
|
||||||
.and_then(|url| url.join(&CONFIGURATION.tachi.import));
|
|
||||||
if let Err(err) = result {
|
|
||||||
error!("Could not parse Tachi import URL: {:#}", err);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.unwrap().to_string()
|
|
||||||
};
|
|
||||||
pub static ref UPSERT_USER_ALL_API_ENCRYPTED: Option<String> =
|
|
||||||
hash_endpoint("UpsertUserAllApi");
|
|
||||||
pub static ref GET_USER_MUSIC_API_ENCRYPTED: Option<String> = hash_endpoint("GetUserMusicApi");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_logger() {
|
|
||||||
env_logger::builder()
|
|
||||||
.filter_level(::log::LevelFilter::Error)
|
|
||||||
.filter_module(
|
|
||||||
"saekawa",
|
|
||||||
if cfg!(debug_assertions) {
|
|
||||||
::log::LevelFilter::Debug
|
|
||||||
} else {
|
|
||||||
::log::LevelFilter::Info
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.parse_default_env()
|
|
||||||
.target(env_logger::Target::Pipe(Box::new(Logger::new())))
|
|
||||||
.format(|f, record| {
|
|
||||||
use crate::log::{colored_level, max_target_width, Padded};
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
let target = record.target();
|
|
||||||
let max_width = max_target_width(target);
|
|
||||||
|
|
||||||
let mut style = f.style();
|
|
||||||
let level = colored_level(&mut style, record.level());
|
|
||||||
|
|
||||||
let mut style = f.style();
|
|
||||||
let target = style.set_bold(true).value(Padded {
|
|
||||||
value: target,
|
|
||||||
width: max_width,
|
|
||||||
});
|
|
||||||
|
|
||||||
let time = chrono::Local::now().format("%d/%m/%Y %H:%M:%S");
|
|
||||||
|
|
||||||
writeln!(f, "[{}] {} {} -> {}", time, level, target, record.args())
|
|
||||||
})
|
|
||||||
.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ThreadHandle(*mut c_void);
|
|
||||||
|
|
||||||
impl ThreadHandle {
|
|
||||||
pub unsafe fn wait_and_close(self, ms: u32) {
|
|
||||||
WaitForSingleObject(self.0, ms);
|
|
||||||
CloseHandle(self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe impl Send for ThreadHandle {}
|
|
||||||
unsafe impl Sync for ThreadHandle {}
|
|
||||||
|
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
#[allow(non_snake_case, unused_variables)]
|
#[allow(non_snake_case, unused_variables)]
|
||||||
@ -114,42 +29,36 @@ extern "system" fn DllMain(dll_module: HINSTANCE, call_reason: DWORD, reserved:
|
|||||||
DLL_PROCESS_ATTACH => {
|
DLL_PROCESS_ATTACH => {
|
||||||
init_logger();
|
init_logger();
|
||||||
|
|
||||||
let (cur_thread, result) = unsafe {
|
let library_handle = unsafe { LibraryHandle::new(dll_module) };
|
||||||
let mut cur_thread = ptr::null_mut();
|
|
||||||
let result = DuplicateHandle(
|
|
||||||
GetCurrentProcess(),
|
|
||||||
GetCurrentThread(),
|
|
||||||
GetCurrentProcess(),
|
|
||||||
&mut cur_thread,
|
|
||||||
SYNCHRONIZE,
|
|
||||||
FALSE,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if result == 0 {
|
|
||||||
warn!(
|
|
||||||
"Failed to get current thread handle, error code: {}",
|
|
||||||
GetLastError()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
(ThreadHandle(cur_thread), result)
|
|
||||||
};
|
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
if result != 0 {
|
info!(
|
||||||
unsafe { cur_thread.wait_and_close(100) };
|
"saekawa {} ({}@{}) starting up...",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
&env!("VERGEN_GIT_SHA")[0..7],
|
||||||
|
env!("VERGEN_GIT_BRANCH"),
|
||||||
|
);
|
||||||
|
|
||||||
|
match self_update(&library_handle) {
|
||||||
|
Ok(should_reboot) => {
|
||||||
|
if should_reboot {
|
||||||
|
info!("Self-update successful. Reloading into new hook...");
|
||||||
|
library_handle.free_and_exit_thread(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Self-update failed: {e:#}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = hook_init() {
|
if let Err(e) = hook_init() {
|
||||||
error!("Failed to initialize hook: {:#}", err);
|
error!("Failed to initialize hook: {e:#}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
DLL_PROCESS_DETACH => {
|
DLL_PROCESS_DETACH => {
|
||||||
if let Err(err) = hook_release() {
|
if let Err(e) = hook_release() {
|
||||||
error!("{:#}", err);
|
warn!("Failed to release hook: {e:#}")
|
||||||
return FALSE;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
81
src/log.rs
81
src/log.rs
@ -1,81 +0,0 @@
|
|||||||
use std::ffi::CString;
|
|
||||||
use std::fmt;
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
|
|
||||||
use winapi::um::debugapi::OutputDebugStringA;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Logger {
|
|
||||||
file: File,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Logger {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
file: File::create("saekawa.log").unwrap(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Write for Logger {
|
|
||||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
||||||
if let Ok(c_str) = CString::new(buf) {
|
|
||||||
unsafe {
|
|
||||||
OutputDebugStringA(c_str.as_ptr());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = std::io::stdout().write(buf);
|
|
||||||
self.file.write(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush(&mut self) -> std::io::Result<()> {
|
|
||||||
// Ignore the result of the write to stdout, since it's not really important
|
|
||||||
let _ = std::io::stdout().flush();
|
|
||||||
self.file.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct Padded<T> {
|
|
||||||
pub(crate) value: T,
|
|
||||||
pub(crate) width: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: fmt::Display> fmt::Display for Padded<T> {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
write!(f, "{: <width$}", self.value, width = self.width)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) static MAX_MODULE_WIDTH: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
|
|
||||||
pub(crate) fn max_target_width(target: &str) -> usize {
|
|
||||||
let max_width = MAX_MODULE_WIDTH.load(Ordering::Relaxed);
|
|
||||||
if max_width < target.len() {
|
|
||||||
MAX_MODULE_WIDTH.store(target.len(), Ordering::Relaxed);
|
|
||||||
target.len()
|
|
||||||
} else {
|
|
||||||
max_width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn colored_level(
|
|
||||||
style: &mut env_logger::fmt::Style,
|
|
||||||
level: log::Level,
|
|
||||||
) -> env_logger::fmt::StyledValue<&'static str> {
|
|
||||||
match level {
|
|
||||||
log::Level::Trace => style
|
|
||||||
.set_color(env_logger::fmt::Color::Magenta)
|
|
||||||
.value("TRACE"),
|
|
||||||
log::Level::Debug => style.set_color(env_logger::fmt::Color::Blue).value("DEBUG"),
|
|
||||||
log::Level::Info => style
|
|
||||||
.set_color(env_logger::fmt::Color::Green)
|
|
||||||
.value(" INFO"),
|
|
||||||
log::Level::Warn => style
|
|
||||||
.set_color(env_logger::fmt::Color::Yellow)
|
|
||||||
.value(" WARN"),
|
|
||||||
log::Level::Error => style.set_color(env_logger::fmt::Color::Red).value("ERROR"),
|
|
||||||
}
|
|
||||||
}
|
|
61
src/logging.rs
Normal file
61
src/logging.rs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
use std::{ffi::CString, fs::File, io::Write};
|
||||||
|
|
||||||
|
use winapi::um::{debugapi::OutputDebugStringA, wincon::GetConsoleWindow};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Logger {
|
||||||
|
file: File,
|
||||||
|
has_console_output: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Logger {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
file: File::create("saekawa.log").unwrap(),
|
||||||
|
has_console_output: unsafe { !GetConsoleWindow().is_null() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for Logger {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
|
if self.has_console_output {
|
||||||
|
let _ = std::io::stdout().write(buf);
|
||||||
|
} else if let Ok(c_str) = CString::new(buf) {
|
||||||
|
unsafe {
|
||||||
|
OutputDebugStringA(c_str.as_ptr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.file.write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
// Ignore the result of the write to stdout, since it's not really important
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
self.file.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_logger() {
|
||||||
|
env_logger::builder()
|
||||||
|
.filter_module(
|
||||||
|
"saekawa",
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
log::LevelFilter::Debug
|
||||||
|
} else {
|
||||||
|
log::LevelFilter::Info
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.parse_default_env()
|
||||||
|
.target(env_logger::Target::Pipe(Box::new(Logger::new())))
|
||||||
|
.format(|f, record| {
|
||||||
|
let target = record.target();
|
||||||
|
let level = record.level();
|
||||||
|
let args = record.args();
|
||||||
|
let time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||||
|
|
||||||
|
writeln!(f, "{time} {level:<5} [{target}] {args}")
|
||||||
|
})
|
||||||
|
.init();
|
||||||
|
}
|
613
src/saekawa.rs
613
src/saekawa.rs
@ -1,331 +1,228 @@
|
|||||||
use std::{
|
use std::{
|
||||||
fmt::Debug,
|
io::{self, Read},
|
||||||
fs::File,
|
mem::{self, MaybeUninit},
|
||||||
io::Read,
|
num::ParseIntError,
|
||||||
path::Path,
|
ptr,
|
||||||
sync::atomic::{AtomicBool, AtomicU16, Ordering},
|
sync::OnceLock,
|
||||||
|
thread,
|
||||||
};
|
};
|
||||||
|
|
||||||
use ::log::{debug, error, info};
|
use ini::Ini;
|
||||||
use anyhow::{anyhow, Result};
|
use log::{debug, error, info};
|
||||||
use log::warn;
|
use snafu::{prelude::Snafu, ResultExt};
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use widestring::U16CString;
|
|
||||||
use winapi::{
|
use winapi::{
|
||||||
ctypes::c_void,
|
shared::minwindef::{BOOL, DWORD, LPCVOID, LPDWORD},
|
||||||
shared::minwindef::{BOOL, DWORD, FALSE, LPCVOID, LPDWORD, LPVOID, MAX_PATH},
|
um::{
|
||||||
um::{errhandlingapi::GetLastError, winbase::GetPrivateProfileStringW, winhttp::HINTERNET},
|
errhandlingapi::GetLastError,
|
||||||
|
libloaderapi::GetModuleHandleW,
|
||||||
|
processthreadsapi::GetCurrentProcess,
|
||||||
|
psapi::{GetModuleInformation, MODULEINFO},
|
||||||
|
winhttp::{HINTERNET, WINHTTP_OPTION_URL},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
configuration::{Configuration, GeneralConfiguration},
|
config::{ConfigLoadError, SaekawaConfig},
|
||||||
handlers::score_handler,
|
|
||||||
helpers::{
|
helpers::{
|
||||||
decrypt_aes256_cbc, is_encrypted_endpoint, is_endpoint, read_hinternet_url,
|
chuni_encoding::{decrypt_aes256_cbc, hash_endpoint, maybe_decompress_buffer},
|
||||||
read_hinternet_user_agent, read_maybe_compressed_buffer, read_slice, request_tachi,
|
winapi_ext::{winhttp_query_option, ReadStringFnError},
|
||||||
},
|
},
|
||||||
icf::{decode_icf, IcfData},
|
score_import::execute_score_import,
|
||||||
types::{
|
sigscan::{self, CryptoKeys},
|
||||||
game::{UpsertUserAllRequest, UserMusicResponse},
|
types::{chuni::UpsertUserAllRequest, ToBatchManual},
|
||||||
tachi::{StatusCheck, TachiResponse, ToTachiImport},
|
|
||||||
},
|
|
||||||
CONFIGURATION, GET_USER_MUSIC_API_ENCRYPTED, TACHI_STATUS_URL, UPSERT_USER_ALL_API_ENCRYPTED,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub static GAME_MAJOR_VERSION: AtomicU16 = AtomicU16::new(0);
|
#[derive(Debug, Snafu)]
|
||||||
pub static PB_IMPORTED: AtomicBool = AtomicBool::new(true);
|
pub enum HookError {
|
||||||
|
#[snafu(display("Could not load configuration"))]
|
||||||
|
ConfigError { source: ConfigLoadError },
|
||||||
|
|
||||||
pub fn hook_init() -> Result<()> {
|
#[snafu(display("No cards were configured in the [cards] section. There is nothing to export to. Add tokens under the cards section with the format `\"access_code\" = \"tachi_api_key\"`. If you wish to export scores from all cards, use `default` in place of an access code."))]
|
||||||
if !CONFIGURATION.general.enable {
|
NoCardsError,
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if CONFIGURATION.general.export_pbs {
|
#[snafu(display("An error occured hooking the underlying functions"))]
|
||||||
warn!("===============================================================================");
|
CrochetError { source: crochet::detour::Error },
|
||||||
warn!("Exporting PBs is enabled. This should only be used once to sync up your scores!");
|
|
||||||
warn!("Leaving it on can make your profile messy! This will be automatically be turned off after exporting is finished.");
|
|
||||||
warn!("You can check when it's done by searching for the message 'Submitting x scores from user ID xxxxx'.");
|
|
||||||
warn!("===============================================================================");
|
|
||||||
|
|
||||||
PB_IMPORTED.store(false, Ordering::SeqCst);
|
#[snafu(display("The game version specified in project.conf is not a number."))]
|
||||||
}
|
InvalidVersion { source: ParseIntError },
|
||||||
|
|
||||||
debug!("Retrieving AMFS path from segatools.ini");
|
#[snafu(display("An error occured parsing project.conf"))]
|
||||||
|
IniError { source: ini::Error },
|
||||||
|
|
||||||
let mut buf = [0u16; MAX_PATH];
|
#[snafu(display("An error occured calling a Win32 function: {errno}"))]
|
||||||
let amfs_cfg = unsafe {
|
Win32Error { errno: u32 },
|
||||||
let sz = GetPrivateProfileStringW(
|
|
||||||
U16CString::from_str_unchecked("vfs").as_ptr(),
|
|
||||||
U16CString::from_str_unchecked("amfs").as_ptr(),
|
|
||||||
U16CString::new().as_ptr(),
|
|
||||||
buf.as_mut_ptr(),
|
|
||||||
MAX_PATH as u32,
|
|
||||||
U16CString::from_str(".\\segatools.ini").unwrap().as_ptr(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if sz == 0 {
|
#[snafu(display("Could not find a pattern in the game executable"))]
|
||||||
let ec = GetLastError();
|
CryptoScanError { source: sigscan::CryptoScanError },
|
||||||
return Err(anyhow!(
|
|
||||||
"AMFS path not specified in segatools.ini, error code {ec}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
match U16CString::from_ptr(buf.as_ptr(), sz as usize) {
|
#[snafu(display("The configured path for failed import exists and is not a directory."))]
|
||||||
Ok(data) => data.to_string_lossy(),
|
FailedImportNotDir,
|
||||||
Err(err) => {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"could not read AMFS path from segatools.ini: {:#}",
|
|
||||||
err
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let amfs_path = Path::new(&amfs_cfg);
|
|
||||||
let icf1_path = amfs_path.join("ICF1");
|
|
||||||
|
|
||||||
if !icf1_path.exists() {
|
#[snafu(display("Could not create the configured directory for failed imports."))]
|
||||||
return Err(anyhow!("Could not find ICF1 inside AMFS path. You will probably not be able to network without this file, so this hook will also be disabled."));
|
FailedCreatingFailedImportDir { source: io::Error },
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Reading ICF1 located at {:?}", icf1_path);
|
|
||||||
|
|
||||||
let mut icf1_buf = {
|
|
||||||
let mut icf1_file = File::open(icf1_path)?;
|
|
||||||
let mut icf1_buf = Vec::new();
|
|
||||||
icf1_file.read_to_end(&mut icf1_buf)?;
|
|
||||||
icf1_buf
|
|
||||||
};
|
|
||||||
let icf = decode_icf(&mut icf1_buf).map_err(|err| anyhow!("Reading ICF failed: {:#}", err))?;
|
|
||||||
|
|
||||||
for entry in icf {
|
|
||||||
if let IcfData::App(app) = entry {
|
|
||||||
info!("Running on {} {}", app.id, app.version);
|
|
||||||
GAME_MAJOR_VERSION.store(app.version.major, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Pinging Tachi API for status check and token verification");
|
|
||||||
|
|
||||||
let resp: TachiResponse<StatusCheck> =
|
|
||||||
request_tachi("GET", TACHI_STATUS_URL.as_str(), None::<()>)?;
|
|
||||||
let user_id = match resp {
|
|
||||||
TachiResponse::Err(err) => {
|
|
||||||
return Err(anyhow!("Tachi API returned an error: {}", err.description));
|
|
||||||
}
|
|
||||||
TachiResponse::Ok(resp) => {
|
|
||||||
if !resp.body.permissions.iter().any(|v| v == "submit_score") {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"API key has insufficient permissions. The permission submit_score must be set."
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(user_id) = resp.body.whoami else {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"Status check was successful, yet API returned userID null?"
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
user_id
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
info!("Logged in to Tachi with userID {user_id}");
|
|
||||||
|
|
||||||
debug!("Initializing detours");
|
|
||||||
|
|
||||||
crochet::enable!(winhttpwritedata_hook_wrapper)?;
|
|
||||||
|
|
||||||
if CONFIGURATION.general.export_pbs || cfg!(debug_assertions) {
|
|
||||||
crochet::enable!(winhttpreaddata_hook_wrapper)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Hook successfully initialized");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hook_release() -> Result<()> {
|
#[derive(Debug, Snafu)]
|
||||||
if !CONFIGURATION.general.enable {
|
pub enum ProcessRequestError {
|
||||||
return Ok(());
|
#[snafu(display("Could not read URL from HINTERNET handle"))]
|
||||||
}
|
UrlReadError { source: ReadStringFnError },
|
||||||
|
|
||||||
if crochet::is_enabled!(winhttpreaddata_hook_wrapper) {
|
#[snafu(display("The URL does not have an endpoint"))]
|
||||||
crochet::disable!(winhttpreaddata_hook_wrapper)?;
|
UrlMissingEndpointError,
|
||||||
}
|
|
||||||
|
|
||||||
if crochet::is_enabled!(winhttpwritedata_hook_wrapper) {
|
#[snafu(display(
|
||||||
crochet::disable!(winhttpwritedata_hook_wrapper)?;
|
"Hooked function was called before all necessary state has been initialized"
|
||||||
}
|
))]
|
||||||
|
UninitializedError,
|
||||||
|
|
||||||
Ok(())
|
#[snafu(display("Could not read request body"))]
|
||||||
|
ReadBodyError { source: io::Error },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[crochet::hook(compile_check, "winhttp.dll", "WinHttpReadData")]
|
#[derive(Debug, Clone)]
|
||||||
fn winhttpreaddata_hook_wrapper(
|
struct GameInformation {
|
||||||
h_request: HINTERNET,
|
pub game_id: String,
|
||||||
lp_buffer: LPVOID,
|
pub major: u16,
|
||||||
dw_number_of_bytes_to_read: DWORD,
|
pub minor: u8,
|
||||||
lpdw_number_of_bytes_read: LPDWORD,
|
pub build: u8,
|
||||||
) -> BOOL {
|
}
|
||||||
debug!("hit winhttpreaddata");
|
|
||||||
|
|
||||||
let result = call_original!(
|
/// This is used by the Tachi <-> CHUNITHM conversion functions,
|
||||||
h_request,
|
/// because some enum indexes changed between CHUNITHM and CHUNITHM NEW,
|
||||||
lp_buffer,
|
/// namely difficulty, and later on, clear lamps.
|
||||||
dw_number_of_bytes_to_read,
|
static GAME_MAJOR_VERSION: OnceLock<u16> = OnceLock::new();
|
||||||
lpdw_number_of_bytes_read
|
static CRYPTO_KEYS: OnceLock<CryptoKeys> = OnceLock::new();
|
||||||
|
static UPSERT_USER_ALL_API: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
|
static CONFIG: OnceLock<SaekawaConfig> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn hook_init() -> Result<(), HookError> {
|
||||||
|
info!("Reading hook configuration");
|
||||||
|
let config = SaekawaConfig::load().context(ConfigSnafu)?;
|
||||||
|
|
||||||
|
if config.cards.is_empty() {
|
||||||
|
return Err(HookError::NoCardsError);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Loaded tokens for {} access codes", config.cards.len());
|
||||||
|
|
||||||
|
if let Some(d) = &config.general.failed_import_dir {
|
||||||
|
if d.exists() && !d.is_dir() {
|
||||||
|
return Err(HookError::FailedImportNotDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.exists() {
|
||||||
|
std::fs::create_dir_all(d).context(FailedCreatingFailedImportDirSnafu)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG
|
||||||
|
.set(config)
|
||||||
|
.expect("OnceLock shouldn't be initialized.");
|
||||||
|
|
||||||
|
debug!("Reading version information from project.conf");
|
||||||
|
let info = get_project_conf()?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Running on {} {}.{}.{}",
|
||||||
|
info.game_id, info.major, info.minor, info.build
|
||||||
);
|
);
|
||||||
|
|
||||||
if result == FALSE {
|
let ver = determine_major_version(&info);
|
||||||
let ec = unsafe { GetLastError() };
|
|
||||||
error!("Calling original WinHttpReadData function failed: {ec}");
|
debug!("Game's major version is {ver}");
|
||||||
return result;
|
|
||||||
|
GAME_MAJOR_VERSION
|
||||||
|
.set(ver)
|
||||||
|
.expect("OnceLock shouldn't be initialized.");
|
||||||
|
|
||||||
|
debug!("Checking if network requests are encrypted");
|
||||||
|
setup_network_encryption(&info)?;
|
||||||
|
|
||||||
|
info!("Enabling hooks");
|
||||||
|
crochet::enable!(winhttpwritedata_hook).context(CrochetSnafu)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hook_release() -> Result<(), HookError> {
|
||||||
|
if crochet::is_enabled!(winhttpwritedata_hook) {
|
||||||
|
info!("Disabling hooks");
|
||||||
|
crochet::disable!(winhttpwritedata_hook).context(CrochetSnafu)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pb_imported = PB_IMPORTED.load(Ordering::SeqCst);
|
Ok(())
|
||||||
if cfg!(not(debug_assertions)) && pb_imported {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = winhttprwdata_hook::<UserMusicResponse>(
|
|
||||||
h_request,
|
|
||||||
lp_buffer,
|
|
||||||
dw_number_of_bytes_to_read,
|
|
||||||
"GetUserMusicApi",
|
|
||||||
&GET_USER_MUSIC_API_ENCRYPTED,
|
|
||||||
move |_| {
|
|
||||||
if pb_imported {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
PB_IMPORTED.store(true, Ordering::Relaxed);
|
|
||||||
if let Err(err) = Configuration::update(Configuration {
|
|
||||||
general: GeneralConfiguration {
|
|
||||||
export_pbs: false,
|
|
||||||
..CONFIGURATION.general
|
|
||||||
},
|
|
||||||
cards: CONFIGURATION.cards.clone(),
|
|
||||||
crypto: CONFIGURATION.crypto.clone(),
|
|
||||||
tachi: CONFIGURATION.tachi.clone(),
|
|
||||||
}) {
|
|
||||||
error!("Could not update configuration to disable exporting PBs: {err:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
error!("{err:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[crochet::hook(compile_check, "winhttp.dll", "WinHttpWriteData")]
|
#[crochet::hook(compile_check, "winhttp.dll", "WinHttpWriteData")]
|
||||||
fn winhttpwritedata_hook_wrapper(
|
fn winhttpwritedata_hook(
|
||||||
h_request: HINTERNET,
|
hrequest: HINTERNET,
|
||||||
lp_buffer: LPCVOID,
|
lp_buffer: LPCVOID,
|
||||||
dw_number_of_bytes_to_write: DWORD,
|
dw_n_bytes_to_write: DWORD,
|
||||||
lpdw_number_of_bytes_written: LPDWORD,
|
lpdw_n_bytes_written: LPDWORD,
|
||||||
) -> BOOL {
|
) -> BOOL {
|
||||||
debug!("hit winhttpwritedata");
|
if let Err(e) = process_request(hrequest, lp_buffer, dw_n_bytes_to_write) {
|
||||||
|
error!("{e:#?}");
|
||||||
if let Err(err) = winhttprwdata_hook::<UpsertUserAllRequest>(
|
|
||||||
h_request,
|
|
||||||
lp_buffer,
|
|
||||||
dw_number_of_bytes_to_write,
|
|
||||||
"UpsertUserAllApi",
|
|
||||||
&UPSERT_USER_ALL_API_ENCRYPTED,
|
|
||||||
|upsert_req| {
|
|
||||||
let user_data = &upsert_req.upsert_user_all.user_data[0];
|
|
||||||
let access_code = &user_data.access_code;
|
|
||||||
if !CONFIGURATION.cards.whitelist.is_empty()
|
|
||||||
&& !CONFIGURATION.cards.whitelist.contains(access_code)
|
|
||||||
{
|
|
||||||
info!("Card {access_code} is not whitelisted, skipping score submission");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
error!("{err:?}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
call_original!(
|
call_original!(
|
||||||
h_request,
|
hrequest,
|
||||||
lp_buffer,
|
lp_buffer,
|
||||||
dw_number_of_bytes_to_write,
|
dw_n_bytes_to_write,
|
||||||
lpdw_number_of_bytes_written
|
lpdw_n_bytes_written
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Common hook for WinHttpWriteData/WinHttpReadData. The flow is similar for both
|
fn process_request(
|
||||||
/// hooks:
|
hrequest: HINTERNET,
|
||||||
/// - Read URL and User-Agent from the handle
|
buffer: LPCVOID,
|
||||||
/// - Extract the API method from the URL, and exit if it's not the method we're
|
bufsiz: DWORD,
|
||||||
/// looking for
|
) -> Result<(), ProcessRequestError> {
|
||||||
/// - Determine if the API is encrypted, and exit if it is and we don't have keys
|
let url = winhttp_query_option(hrequest, WINHTTP_OPTION_URL).context(UrlReadSnafu)?;
|
||||||
/// - Parse the body and convert it to Tachi's BATCH-MANUAL
|
|
||||||
/// - Submit it off to Tachi, if our guard function (which takes the parsed body) allows so.
|
|
||||||
fn winhttprwdata_hook<'a, T: Debug + DeserializeOwned + ToTachiImport + 'static>(
|
|
||||||
handle: HINTERNET,
|
|
||||||
buffer: *const c_void,
|
|
||||||
bufsz: DWORD,
|
|
||||||
unencrypted_endpoint: &str,
|
|
||||||
encrypted_endpoint: &Option<String>,
|
|
||||||
guard_fn: impl Fn(&T) -> bool + Send + 'static,
|
|
||||||
) -> Result<()> {
|
|
||||||
let url = read_hinternet_url(handle)?;
|
|
||||||
let user_agent = read_hinternet_user_agent(handle)?;
|
|
||||||
debug!("user-agent {user_agent}, URL: {url}");
|
|
||||||
|
|
||||||
let maybe_endpoint = url
|
debug!("Captured request to {url}");
|
||||||
|
|
||||||
|
let endpoint = url
|
||||||
.split('/')
|
.split('/')
|
||||||
.last()
|
.last()
|
||||||
.ok_or(anyhow!("Could not extract last part of a split URL"))?;
|
.ok_or(ProcessRequestError::UrlMissingEndpointError)?;
|
||||||
|
let upsert_user_all_endpoint = UPSERT_USER_ALL_API
|
||||||
|
.get()
|
||||||
|
.ok_or(ProcessRequestError::UninitializedError)?;
|
||||||
|
|
||||||
let is_encrypted = is_encrypted_endpoint(maybe_endpoint);
|
if endpoint != upsert_user_all_endpoint {
|
||||||
|
|
||||||
let endpoint = if is_encrypted && user_agent.contains('#') {
|
|
||||||
user_agent
|
|
||||||
.split('#')
|
|
||||||
.next()
|
|
||||||
.ok_or(anyhow!("there should be at least one item in the split"))?
|
|
||||||
} else {
|
|
||||||
maybe_endpoint
|
|
||||||
};
|
|
||||||
|
|
||||||
let is_correct_endpoint = is_endpoint(endpoint, unencrypted_endpoint, encrypted_endpoint);
|
|
||||||
if cfg!(not(debug_assertions)) && !is_correct_endpoint {
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_encrypted && (CONFIGURATION.crypto.key.is_empty() || CONFIGURATION.crypto.iv.is_empty()) {
|
let mut raw_body_slice =
|
||||||
return Err(anyhow!("Communications with the server is encrypted, but no keys were provided. Fill in the keys by editing 'saekawa.toml'."));
|
unsafe { std::slice::from_raw_parts(buffer as *const u8, bufsiz as usize) };
|
||||||
|
let mut raw_body = Vec::with_capacity(bufsiz as usize);
|
||||||
|
|
||||||
|
raw_body_slice
|
||||||
|
.read_to_end(&mut raw_body)
|
||||||
|
.context(ReadBodySnafu)?;
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
debug!("raw request: {}", faster_hex::hex_string(&raw_body));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut raw_body = match read_slice(buffer as *const u8, bufsz as usize) {
|
thread::spawn(move || {
|
||||||
Ok(data) => data,
|
let Some(config) = CONFIG.get() else {
|
||||||
Err(err) => {
|
error!("Config has not been initialized?");
|
||||||
return Err(anyhow!(
|
return;
|
||||||
"There was an error reading the response body: {:#}",
|
};
|
||||||
err
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
debug!("raw body: {}", faster_hex::hex_string(&raw_body));
|
let Some(major_version) = GAME_MAJOR_VERSION.get() else {
|
||||||
|
error!("The game's major version is not known?");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
let compressed_body = if let Some(keys) = CRYPTO_KEYS.get() {
|
||||||
let compressed_body = if is_encrypted {
|
match decrypt_aes256_cbc(&mut raw_body, &keys.key, &keys.iv) {
|
||||||
match decrypt_aes256_cbc(
|
Ok(r) => r,
|
||||||
&mut raw_body,
|
Err(e) => {
|
||||||
&CONFIGURATION.crypto.key,
|
error!("Could not decrypt request: {e:#?}");
|
||||||
&CONFIGURATION.crypto.iv,
|
|
||||||
) {
|
|
||||||
Ok(res) => res,
|
|
||||||
Err(err) => {
|
|
||||||
error!("Could not decrypt response: {:#}", err);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -333,23 +230,171 @@ fn winhttprwdata_hook<'a, T: Debug + DeserializeOwned + ToTachiImport + 'static>
|
|||||||
raw_body
|
raw_body
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = match read_maybe_compressed_buffer(&compressed_body[..]) {
|
let body = match maybe_decompress_buffer(&compressed_body) {
|
||||||
Ok(data) => data,
|
Ok(s) => s,
|
||||||
Err(err) => {
|
Err(e) => {
|
||||||
error!("There was an error decoding the request body: {:#}", err);
|
error!("Could not read request as DEFLATE-compressed or plaintext: {e:#?}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
debug!("decoded response body: {body}");
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
// Hit in debug build
|
debug!("decoded request: {}", body.trim());
|
||||||
if !is_correct_endpoint {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
score_handler::<T>(body, guard_fn)
|
let data = match serde_json::from_str::<UpsertUserAllRequest>(&body) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Could not parse request: {e:#?}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_data = &data.upsert_user_all.user_data[0];
|
||||||
|
let access_code = &user_data.access_code;
|
||||||
|
let Some(tachi_api_key) = config
|
||||||
|
.cards
|
||||||
|
.get(access_code)
|
||||||
|
.or_else(|| config.cards.get("default"))
|
||||||
|
else {
|
||||||
|
info!("No API keys was assigned to {access_code}, and no default API key was set, skipping score submission.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let import = data.to_batch_manual(
|
||||||
|
*major_version,
|
||||||
|
config.general.export_class,
|
||||||
|
config.general.fail_over_lamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = execute_score_import(import, access_code, &tachi_api_key, &config) {
|
||||||
|
error!("{e}");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_project_conf() -> Result<GameInformation, HookError> {
|
||||||
|
let project_conf = Ini::load_from_file("./project.conf").context(IniSnafu)?;
|
||||||
|
let major_version = &project_conf["Version"]["VerMajor"];
|
||||||
|
let minor_version = &project_conf["Version"]["VerMinor"];
|
||||||
|
let build_version = &project_conf["Version"]["VerRelease"];
|
||||||
|
let game_id = &project_conf["Project"]["GameID"];
|
||||||
|
|
||||||
|
Ok(GameInformation {
|
||||||
|
game_id: game_id.to_string(),
|
||||||
|
major: major_version.parse::<u16>().context(InvalidVersionSnafu)?,
|
||||||
|
minor: minor_version.parse::<u8>().context(InvalidVersionSnafu)?,
|
||||||
|
build: build_version.parse::<u8>().context(InvalidVersionSnafu)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn determine_major_version(info: &GameInformation) -> u16 {
|
||||||
|
if info.game_id == "SDGS" {
|
||||||
|
if info.minor < 10 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info.major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_network_encryption(info: &GameInformation) -> Result<(), HookError> {
|
||||||
|
debug!("Getting module information of the game process");
|
||||||
|
let mut modinfo: MaybeUninit<MODULEINFO> = MaybeUninit::uninit();
|
||||||
|
let result = unsafe {
|
||||||
|
GetModuleInformation(
|
||||||
|
GetCurrentProcess(),
|
||||||
|
GetModuleHandleW(ptr::null_mut()),
|
||||||
|
modinfo.as_mut_ptr(),
|
||||||
|
mem::size_of::<MODULEINFO>() as u32,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if result == 0 {
|
||||||
|
let err = unsafe { GetLastError() };
|
||||||
|
|
||||||
|
error!("Could not get information about the game process, error code {err}");
|
||||||
|
return Err(HookError::Win32Error { errno: err });
|
||||||
|
}
|
||||||
|
|
||||||
|
let modinfo = unsafe { modinfo.assume_init() };
|
||||||
|
debug!(
|
||||||
|
"Base address: {:p}, image size: {:x}",
|
||||||
|
modinfo.lpBaseOfDll, modinfo.SizeOfImage
|
||||||
|
);
|
||||||
|
|
||||||
|
debug!("Scanning game for encryption status");
|
||||||
|
let encryption_enabled = unsafe {
|
||||||
|
sigscan::is_network_encrypted(modinfo.lpBaseOfDll as *const _, modinfo.SizeOfImage as _)
|
||||||
|
.context(CryptoScanSnafu)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let endpoint = if info.game_id == "SDGS" {
|
||||||
|
if info.minor < 10 {
|
||||||
|
"UpsertUserAllApiExp"
|
||||||
|
} else {
|
||||||
|
"UpsertUserAllApiC3Exp"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"UpsertUserAllApi"
|
||||||
|
};
|
||||||
|
|
||||||
|
if encryption_enabled {
|
||||||
|
info!("Network requests are encrypted.");
|
||||||
|
|
||||||
|
debug!("Searching for encryption keys. This might take a bit...");
|
||||||
|
|
||||||
|
let keys = unsafe {
|
||||||
|
sigscan::get_crypto_keys(modinfo.lpBaseOfDll as *const _, modinfo.SizeOfImage as _)
|
||||||
|
.context(CryptoScanSnafu)?
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Search completed successfully.");
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
debug!(
|
||||||
|
"Key: {}, IV: {}, salt: {}, iterations: {}",
|
||||||
|
faster_hex::hex_string(&keys.key),
|
||||||
|
faster_hex::hex_string(&keys.iv),
|
||||||
|
faster_hex::hex_string(&keys.salt),
|
||||||
|
keys.iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For some reason, CHUNITHM SUPERSTAR/SUPERSTAR+ forgot to add "Exp" when
|
||||||
|
// hashing the endpoint.
|
||||||
|
let endpoint_password = if info.game_id == "SDGS" && info.minor < 10 {
|
||||||
|
"UpsertUserAllApi"
|
||||||
|
} else {
|
||||||
|
endpoint
|
||||||
|
};
|
||||||
|
|
||||||
|
let hashed_endpoint = hash_endpoint(endpoint_password, &keys.salt, keys.iterations);
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Hashed {endpoint_password} with {:#?} to {hashed_endpoint}",
|
||||||
|
keys.salt
|
||||||
|
);
|
||||||
|
|
||||||
|
UPSERT_USER_ALL_API
|
||||||
|
.set(hashed_endpoint)
|
||||||
|
.expect("OnceLock shouldn't be initialized.");
|
||||||
|
CRYPTO_KEYS
|
||||||
|
.set(keys)
|
||||||
|
.expect("OnceLock shouldn't be initialized.");
|
||||||
|
} else {
|
||||||
|
info!("Network requests are not encrypted.");
|
||||||
|
|
||||||
|
UPSERT_USER_ALL_API
|
||||||
|
.set(endpoint.to_string())
|
||||||
|
.expect("OnceLock shouldn't be initialized.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
257
src/score_import.rs
Normal file
257
src/score_import.rs
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
use std::{fmt::Debug, fs::File, io, thread, time::Duration};
|
||||||
|
|
||||||
|
use log::{debug, error, info};
|
||||||
|
use rand::{rngs::ThreadRng, Rng};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use snafu::{prelude::Snafu, ResultExt};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::SaekawaConfig,
|
||||||
|
types::tachi::{
|
||||||
|
api::{TachiFailureResponse, TachiResponse},
|
||||||
|
api_returns::{ImportPollStatus, ImportResponse},
|
||||||
|
batch_manual::BatchManualImport,
|
||||||
|
documents::ImportDocument,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_RETRY_COUNT: u32 = 3;
|
||||||
|
static SAEKAWA_USER_AGENT: &str = concat!("saekawa/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
#[derive(Snafu, Debug)]
|
||||||
|
pub enum ScoreImportError {
|
||||||
|
#[snafu(display("Could not create import URL"))]
|
||||||
|
InvalidImportUrl { source: url::ParseError },
|
||||||
|
|
||||||
|
#[snafu(display("Tachi API returned an error: {}", response.description))]
|
||||||
|
TachiError { response: TachiFailureResponse },
|
||||||
|
|
||||||
|
#[snafu(display("Could not communicate with Tachi {max_retries} times."))]
|
||||||
|
MaxRetriesExhausted { max_retries: u32 },
|
||||||
|
|
||||||
|
#[snafu(display("Tachi returned an invalid response."))]
|
||||||
|
InvalidTachiResponse { source: io::Error },
|
||||||
|
|
||||||
|
#[snafu(display("Could not create backup batch manual file."))]
|
||||||
|
FailedCreatingBackup { source: io::Error },
|
||||||
|
|
||||||
|
#[snafu(display("Could not write backup batch manual file."))]
|
||||||
|
FailedWritingBackup { source: serde_json::Error },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This function blocks until it has completed, which may take a long time
|
||||||
|
/// depending on the user's internet connection with Tachi. It's best to call
|
||||||
|
/// this in a separate thread.
|
||||||
|
pub fn execute_score_import(
|
||||||
|
import: BatchManualImport,
|
||||||
|
access_code: &str,
|
||||||
|
api_key: &str,
|
||||||
|
config: &SaekawaConfig,
|
||||||
|
) -> Result<(), ScoreImportError> {
|
||||||
|
// Checking if there's actually anything to import before continuing on
|
||||||
|
if import.scores.is_empty()
|
||||||
|
&& (import.classes.is_none()
|
||||||
|
|| import
|
||||||
|
.classes
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|c| c.dan.is_none() && c.emblem.is_none()))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let import_url = config
|
||||||
|
.tachi
|
||||||
|
.base_url
|
||||||
|
.join("/ir/direct-manual/import")
|
||||||
|
.context(InvalidImportUrlSnafu)?
|
||||||
|
.to_string();
|
||||||
|
let client = saekawa_client(config);
|
||||||
|
let response = match request_tachi::<_, ImportResponse>(
|
||||||
|
&client,
|
||||||
|
"POST",
|
||||||
|
&import_url,
|
||||||
|
&api_key,
|
||||||
|
Some(&import),
|
||||||
|
) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(ScoreImportError::MaxRetriesExhausted { max_retries }) => {
|
||||||
|
error!("Could not reach Tachi after {max_retries} attempts.");
|
||||||
|
|
||||||
|
let Some(d) = &config.general.failed_import_dir else {
|
||||||
|
return Err(ScoreImportError::MaxRetriesExhausted { max_retries });
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Saving batch manual JSON to configured failed import directory for later import.");
|
||||||
|
|
||||||
|
let current_time = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S");
|
||||||
|
let failed_import_filename =
|
||||||
|
d.join(format!("saekawa_{}_{}.json", access_code, current_time));
|
||||||
|
|
||||||
|
{
|
||||||
|
let file = File::create(&failed_import_filename).context(FailedCreatingBackupSnafu)?;
|
||||||
|
serde_json::to_writer(file, &import).context(FailedWritingBackupSnafu)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Saved batch manual JSON to {}", failed_import_filename.to_string_lossy());
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match response {
|
||||||
|
TachiResponse::Ok(r) => r,
|
||||||
|
TachiResponse::Err(e) => return Err(ScoreImportError::TachiError { response: e }),
|
||||||
|
};
|
||||||
|
|
||||||
|
match response.body {
|
||||||
|
ImportResponse::Deferred(d) => {
|
||||||
|
info!("Import was queued for processing. Poll URL: {}", d.url);
|
||||||
|
poll_deferred_import(&client, &api_key, &d.url);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ImportResponse::Completed(d) => {
|
||||||
|
log_tachi_import(&response.description, &d);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn saekawa_client(config: &SaekawaConfig) -> ureq::Agent {
|
||||||
|
ureq::builder()
|
||||||
|
.timeout(Duration::from_millis(config.general.timeout))
|
||||||
|
.user_agent(SAEKAWA_USER_AGENT)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exponential_backoff(rand: &mut ThreadRng, attempt: u32) -> u64 {
|
||||||
|
// attempt | backoff
|
||||||
|
// 0 | 2-4 seconds
|
||||||
|
// 1 | 8-16 seconds
|
||||||
|
// 2 | 32-64 seconds
|
||||||
|
return rand.gen_range(500..=1000) * 4_u64.pow(attempt + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_tachi<T, R>(
|
||||||
|
client: &ureq::Agent,
|
||||||
|
method: &str,
|
||||||
|
url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
body: Option<T>,
|
||||||
|
) -> Result<TachiResponse<R>, ScoreImportError>
|
||||||
|
where
|
||||||
|
T: Serialize + Debug,
|
||||||
|
R: for<'de> Deserialize<'de> + Debug,
|
||||||
|
{
|
||||||
|
let auth_header = format!("Bearer {}", api_key);
|
||||||
|
let mut rand = rand::thread_rng();
|
||||||
|
|
||||||
|
for attempt in 0..MAX_RETRY_COUNT {
|
||||||
|
debug!(
|
||||||
|
"Requesting Tachi, attempt {}/{MAX_RETRY_COUNT}",
|
||||||
|
attempt + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = client
|
||||||
|
.request(method, url)
|
||||||
|
.set("Authorization", &auth_header);
|
||||||
|
let response = if let Some(ref body) = body {
|
||||||
|
request.send_json(body)
|
||||||
|
} else {
|
||||||
|
request.call()
|
||||||
|
};
|
||||||
|
let response = match response {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(ureq::Error::Transport(e)) => {
|
||||||
|
error!("Could not reach Tachi API. Is your network up or are they having issues?.");
|
||||||
|
error!("Detailed error: {e:#?}");
|
||||||
|
|
||||||
|
if attempt != MAX_RETRY_COUNT - 1 {
|
||||||
|
let wait_time = exponential_backoff(&mut rand, attempt);
|
||||||
|
|
||||||
|
info!("Waiting for {wait_time}ms before trying again...");
|
||||||
|
thread::sleep(Duration::from_millis(wait_time));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(ureq::Error::Status(code, response)) => {
|
||||||
|
if code >= 500 {
|
||||||
|
error!("Tachi is having a server error. Response code was {code}.");
|
||||||
|
|
||||||
|
if let Ok(s) = response.into_string() {
|
||||||
|
error!("Response from Tachi: {s}");
|
||||||
|
} else {
|
||||||
|
error!("No response could be read.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt != MAX_RETRY_COUNT - 1 {
|
||||||
|
let wait_time = exponential_backoff(&mut rand, attempt);
|
||||||
|
|
||||||
|
info!("Waiting for {wait_time}ms before trying again...");
|
||||||
|
thread::sleep(Duration::from_millis(wait_time));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return response
|
||||||
|
.into_json::<TachiResponse<R>>()
|
||||||
|
.context(InvalidTachiResponseSnafu);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(ScoreImportError::MaxRetriesExhausted {
|
||||||
|
max_retries: MAX_RETRY_COUNT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_tachi_import(description: &str, import: &ImportDocument) {
|
||||||
|
info!(
|
||||||
|
"{description} {} scores, {} sessions, {} errors",
|
||||||
|
import.score_ids.len(),
|
||||||
|
import.created_sessions.len(),
|
||||||
|
import.errors.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for err in &import.errors {
|
||||||
|
error!("{}: {}", err.error_type, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_deferred_import(client: &ureq::Agent, api_key: &str, poll_url: &str) {
|
||||||
|
loop {
|
||||||
|
let resp = match request_tachi::<_, ImportPollStatus>(
|
||||||
|
&client, "GET", &poll_url, &api_key, None::<()>,
|
||||||
|
) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Could not poll import status. While Tachi has received the score, Saekawa cannot make any guarantees about its success. Detailed error: {e:#?}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = match resp {
|
||||||
|
TachiResponse::Ok(r) => r,
|
||||||
|
TachiResponse::Err(e) => {
|
||||||
|
error!("Tachi API returned an error: {}", e.description);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match resp.body {
|
||||||
|
ImportPollStatus::Completed { import } => {
|
||||||
|
log_tachi_import(&resp.description, &import);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
}
|
236
src/sigscan.rs
Normal file
236
src/sigscan.rs
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
use std::{ffi::CStr, slice};
|
||||||
|
|
||||||
|
use lightningscanner::{ScanMode, Scanner};
|
||||||
|
use log::{debug, error};
|
||||||
|
use pbkdf2::pbkdf2_hmac;
|
||||||
|
use sha1::Sha1;
|
||||||
|
use snafu::prelude::Snafu;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CryptoKeys {
|
||||||
|
pub key: Vec<u8>,
|
||||||
|
pub iv: Vec<u8>,
|
||||||
|
pub salt: Vec<u8>,
|
||||||
|
pub iterations: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Snafu)]
|
||||||
|
pub enum CryptoScanError {
|
||||||
|
MissingSignature,
|
||||||
|
NotEncrypted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SAFETY: The caller ensures that `module_base` and `module_size` are valid.
|
||||||
|
pub unsafe fn is_network_encrypted(
|
||||||
|
module_base: *const u8,
|
||||||
|
module_size: usize,
|
||||||
|
) -> Result<bool, CryptoScanError> {
|
||||||
|
let scan_mode = if is_x86_feature_detected!("avx2") {
|
||||||
|
ScanMode::Avx2
|
||||||
|
} else if is_x86_feature_detected!("sse4.2") {
|
||||||
|
ScanMode::Sse42
|
||||||
|
} else {
|
||||||
|
ScanMode::Scalar
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("Using {scan_mode:?} for signature scanning");
|
||||||
|
|
||||||
|
debug!("Scanning for the endpoint salt password");
|
||||||
|
// b"?AVDeflate@projClient@@\x??\x??\x??\x??\x??\x??\x??\x??"
|
||||||
|
// This is what the patchers are patching out when disabling encryption. This is
|
||||||
|
// also where the endpoint salt password can be found.
|
||||||
|
let scanner = Scanner::new("3F 41 56 44 65 66 6C 61 74 65 40 70 72 6F 6A 43 6C 69 65 6E 74 40 40 ?? ?? ?? ?? ?? ?? ?? ??");
|
||||||
|
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||||
|
|
||||||
|
if !result.is_valid() {
|
||||||
|
error!("Could not find the endpoint salt password");
|
||||||
|
return Err(CryptoScanError::MissingSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
let crypto_config = *result.get_addr().wrapping_add(0x1B);
|
||||||
|
|
||||||
|
if crypto_config == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SAFETY: The caller ensures that `module_base` and `module_size` are valid.
|
||||||
|
pub unsafe fn get_crypto_keys(
|
||||||
|
module_base: *const u8,
|
||||||
|
module_size: usize,
|
||||||
|
) -> Result<CryptoKeys, CryptoScanError> {
|
||||||
|
let scan_mode = if is_x86_feature_detected!("avx2") {
|
||||||
|
ScanMode::Avx2
|
||||||
|
} else if is_x86_feature_detected!("sse4.2") {
|
||||||
|
ScanMode::Sse42
|
||||||
|
} else {
|
||||||
|
ScanMode::Scalar
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("Using {scan_mode:?} for signature scanning");
|
||||||
|
|
||||||
|
debug!("Scanning for the endpoint salt password");
|
||||||
|
// b"?AVDeflate@projClient@@\x??\x??\x??\x??\x??\x??\x??\x??"
|
||||||
|
// This is what the patchers are patching out when disabling encryption. This is
|
||||||
|
// also where the endpoint salt password can be found.
|
||||||
|
let scanner = Scanner::new("3F 41 56 44 65 66 6C 61 74 65 40 70 72 6F 6A 43 6C 69 65 6E 74 40 40 ?? ?? ?? ?? ?? ?? ?? ??");
|
||||||
|
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||||
|
|
||||||
|
if !result.is_valid() {
|
||||||
|
error!("Could not find the endpoint salt password");
|
||||||
|
return Err(CryptoScanError::MissingSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
let crypto_config = *result.get_addr().wrapping_add(0x1B);
|
||||||
|
|
||||||
|
if crypto_config == 0 {
|
||||||
|
return Err(CryptoScanError::NotEncrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
let endpoint_salt_password_address = if crypto_config == *result.get_addr().wrapping_add(0x1F) {
|
||||||
|
i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x23)) as *const i8
|
||||||
|
} else {
|
||||||
|
i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x1F)) as *const i8
|
||||||
|
};
|
||||||
|
|
||||||
|
let endpoint_salt_password = CStr::from_ptr(endpoint_salt_password_address);
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Endpoint salt password: {} ({endpoint_salt_password_address:p})",
|
||||||
|
endpoint_salt_password.to_string_lossy()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scanning for the call to [`PKCS5_PBKDF2_HMAC_SHA1`](https://www.openssl.org/docs/man3.2/man3/PKCS5_PBKDF2_HMAC_SHA1.html)
|
||||||
|
// with saltlen=16, iter=31, keylen=8 to find the endpoint salt's salt
|
||||||
|
let scanner = Scanner::new("52 6A 08 6A ?? 6A 10 68 ?? ?? ?? ?? 51 53 E8 ?? ?? ?? ??");
|
||||||
|
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||||
|
|
||||||
|
if !result.is_valid() {
|
||||||
|
return Err(CryptoScanError::MissingSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
let endpoint_salt_rounds = *result.get_addr().wrapping_add(0x04) as u32;
|
||||||
|
let endpoint_salt_salt_address =
|
||||||
|
i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x08)) as *const u8;
|
||||||
|
let endpoint_salt_salt = slice::from_raw_parts(endpoint_salt_salt_address, 16);
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Endpoint salt salt: {} ({endpoint_salt_salt_address:p})",
|
||||||
|
faster_hex::hex_string(endpoint_salt_salt)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut endpoint_salt = vec![0u8; 8];
|
||||||
|
pbkdf2_hmac::<Sha1>(
|
||||||
|
endpoint_salt_password.to_bytes(),
|
||||||
|
endpoint_salt_salt,
|
||||||
|
endpoint_salt_rounds,
|
||||||
|
&mut endpoint_salt,
|
||||||
|
);
|
||||||
|
|
||||||
|
debug!("Endpoint salt: {endpoint_salt:X?}");
|
||||||
|
|
||||||
|
// Scanning for the call to [`PKCS5_PBKDF2_HMAC_SHA1`](https://www.openssl.org/docs/man3.2/man3/PKCS5_PBKDF2_HMAC_SHA1.html)
|
||||||
|
// with saltlen=16, iter=??, keylen=32 to find the encryption key's salt
|
||||||
|
let scanner = Scanner::new("50 6A 20 6A ?? 6A 10 2B CA 68 ?? ?? ?? ?? 51 55 E8 ?? ?? ?? ??");
|
||||||
|
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||||
|
|
||||||
|
if !result.is_valid() {
|
||||||
|
return Err(CryptoScanError::MissingSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryption_key_rounds = *result.get_addr().wrapping_add(0x04) as u32;
|
||||||
|
let encryption_key_salt_address =
|
||||||
|
i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x0A)) as *const u8;
|
||||||
|
let encryption_key_salt = slice::from_raw_parts(encryption_key_salt_address, 16);
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Encryption key salt: {} ({encryption_key_salt_address:p})",
|
||||||
|
faster_hex::hex_string(encryption_key_salt)
|
||||||
|
);
|
||||||
|
|
||||||
|
// b"?AVSystemInterface@projClient@@\x??\x??\x??\x??\x??\x??\x??\x??\x??\x??\x??\x??"
|
||||||
|
let scanner = Scanner::new("3F 41 56 53 79 73 74 65 6D 49 6E 74 65 72 66 61 63 65 40 70 72 6F 6A 43 6C 69 65 6E 74 40 40 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??");
|
||||||
|
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||||
|
|
||||||
|
if !result.is_valid() {
|
||||||
|
return Err(CryptoScanError::MissingSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryption_key_password_address =
|
||||||
|
i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x23)) as *const i8;
|
||||||
|
let encryption_key_password = CStr::from_ptr(encryption_key_password_address);
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Encryption key password: {} ({encryption_key_password_address:p})",
|
||||||
|
encryption_key_password.to_string_lossy()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut encryption_key = vec![0u8; 32];
|
||||||
|
pbkdf2_hmac::<Sha1>(
|
||||||
|
encryption_key_password.to_bytes(),
|
||||||
|
encryption_key_salt,
|
||||||
|
encryption_key_rounds,
|
||||||
|
&mut encryption_key,
|
||||||
|
);
|
||||||
|
|
||||||
|
for byte in encryption_key.iter_mut() {
|
||||||
|
*byte = (*byte % 0x5E) + 0x21;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Encryption key: {encryption_key:X?}");
|
||||||
|
|
||||||
|
// Encryption IV
|
||||||
|
let scanner2 = Scanner::new("E8 ?? ?? ?? ?? F3 0F 7E 05 ?? ?? ?? ?? 6A 01");
|
||||||
|
let result2 = scanner2.find(Some(scan_mode), module_base, module_size);
|
||||||
|
|
||||||
|
let iv_addr = if result2.is_valid() {
|
||||||
|
i32_from_ptr_le_bytes(result2.get_addr().wrapping_add(0x09)) as *const i8
|
||||||
|
} else {
|
||||||
|
let scanner1 = Scanner::new("F3 0F 7E 05 ?? ?? ?? ?? 8B 74 24 24 6A 01");
|
||||||
|
let result1 = scanner1.find(Some(scan_mode), module_base, module_size);
|
||||||
|
|
||||||
|
if !result1.is_valid() {
|
||||||
|
return Err(CryptoScanError::MissingSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
i32_from_ptr_le_bytes(result1.get_addr().wrapping_add(0x04)) as *const i8
|
||||||
|
};
|
||||||
|
|
||||||
|
let iv = CStr::from_ptr(iv_addr).to_bytes().to_vec();
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Encryption IV: {} ({iv_addr:p})",
|
||||||
|
faster_hex::hex_string(&iv)
|
||||||
|
);
|
||||||
|
|
||||||
|
let scanner = Scanner::new("C7 86 ?? ?? ?? ?? ?? ?? ?? ?? 0F 8C ?? ?? ?? ?? 85 ED 0F 84 ?? ?? ?? ?? 85 DB 0F 84 ?? ?? ?? ??");
|
||||||
|
let result = scanner.find(Some(scan_mode), module_base, module_size);
|
||||||
|
|
||||||
|
if !result.is_valid() {
|
||||||
|
return Err(CryptoScanError::MissingSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
let iterations = u32::from_le_bytes(from_raw_parts_const(result.get_addr().wrapping_add(0x06)));
|
||||||
|
|
||||||
|
debug!("Iterations: {iterations}");
|
||||||
|
|
||||||
|
Ok(CryptoKeys {
|
||||||
|
key: encryption_key,
|
||||||
|
iv,
|
||||||
|
salt: endpoint_salt,
|
||||||
|
iterations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn from_raw_parts_const<const N: usize>(ptr: *const u8) -> [u8; N] {
|
||||||
|
slice::from_raw_parts(ptr, N)
|
||||||
|
.try_into()
|
||||||
|
.expect("slice::from_raw_parts with len=N should convert to [u8; N]")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
unsafe fn i32_from_ptr_le_bytes(ptr: *const u8) -> i32 {
|
||||||
|
i32::from_le_bytes(from_raw_parts_const(ptr))
|
||||||
|
}
|
31
src/types/chuni/mod.rs
Normal file
31
src/types/chuni/mod.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
mod music;
|
||||||
|
pub mod upsert;
|
||||||
|
|
||||||
|
use serde::{de, Deserialize};
|
||||||
|
|
||||||
|
pub use self::upsert::UpsertUserAllRequest;
|
||||||
|
|
||||||
|
fn deserialize_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
||||||
|
where
|
||||||
|
D: de::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum BooleanishTypes {
|
||||||
|
String(String),
|
||||||
|
Bool(bool),
|
||||||
|
Number(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
let s: BooleanishTypes = de::Deserialize::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
match s {
|
||||||
|
BooleanishTypes::String(s) => match s.as_str() {
|
||||||
|
"true" => Ok(true),
|
||||||
|
"false" => Ok(false),
|
||||||
|
_ => Err(de::Error::unknown_variant(&s, &["true", "false"])),
|
||||||
|
},
|
||||||
|
BooleanishTypes::Bool(b) => Ok(b),
|
||||||
|
BooleanishTypes::Number(n) => Ok(n > 0),
|
||||||
|
}
|
||||||
|
}
|
41
src/types/chuni/music.rs
Normal file
41
src/types/chuni/music.rs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_aux::prelude::*;
|
||||||
|
|
||||||
|
use super::deserialize_bool;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UserMusicResponse {
|
||||||
|
pub user_id: String,
|
||||||
|
pub length: u32,
|
||||||
|
pub user_music_list: Vec<UserMusicItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UserMusicItem {
|
||||||
|
pub length: u32,
|
||||||
|
pub user_music_detail_list: Vec<UserMusicDetail>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UserMusicDetail {
|
||||||
|
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||||
|
pub music_id: u32,
|
||||||
|
|
||||||
|
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||||
|
pub level: u32,
|
||||||
|
|
||||||
|
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||||
|
pub score_max: u32,
|
||||||
|
|
||||||
|
#[serde(deserialize_with = "deserialize_bool")]
|
||||||
|
pub is_all_justice: bool,
|
||||||
|
|
||||||
|
#[serde(deserialize_with = "deserialize_bool")]
|
||||||
|
pub is_full_combo: bool,
|
||||||
|
|
||||||
|
#[serde(deserialize_with = "deserialize_bool")]
|
||||||
|
pub is_success: bool,
|
||||||
|
}
|
@ -1,29 +1,21 @@
|
|||||||
use serde::{de, Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_aux::prelude::*;
|
use serde_aux::prelude::*;
|
||||||
|
|
||||||
fn deserialize_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
use super::deserialize_bool;
|
||||||
where
|
|
||||||
D: de::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum StringOrBoolean {
|
|
||||||
String(String),
|
|
||||||
Bool(bool),
|
|
||||||
Number(i32),
|
|
||||||
}
|
|
||||||
|
|
||||||
let s: StringOrBoolean = de::Deserialize::deserialize(deserializer)?;
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpsertUserAllRequest {
|
||||||
|
pub user_id: String,
|
||||||
|
pub upsert_user_all: UpsertUserAllBody,
|
||||||
|
}
|
||||||
|
|
||||||
match s {
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
StringOrBoolean::String(s) => match s.as_str() {
|
#[serde(rename_all = "camelCase")]
|
||||||
"true" => Ok(true),
|
pub struct UpsertUserAllBody {
|
||||||
"false" => Ok(false),
|
pub user_data: Vec<UserData>,
|
||||||
_ => Err(de::Error::unknown_variant(&s, &["true", "false"])),
|
pub user_data_ex: Option<Vec<UserDataEx>>,
|
||||||
},
|
pub user_playlog_list: Vec<UserPlaylog>,
|
||||||
StringOrBoolean::Bool(b) => Ok(b),
|
|
||||||
StringOrBoolean::Number(n) => Ok(n > 0),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@ -31,11 +23,18 @@ where
|
|||||||
pub struct UserData {
|
pub struct UserData {
|
||||||
pub access_code: String,
|
pub access_code: String,
|
||||||
|
|
||||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
#[serde(deserialize_with = "deserialize_option_number_from_string")]
|
||||||
pub class_emblem_base: u32,
|
pub class_emblem_base: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(deserialize_with = "deserialize_option_number_from_string")]
|
||||||
|
pub class_emblem_medal: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UserDataEx {
|
||||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||||
pub class_emblem_medal: u32,
|
pub medal: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@ -71,6 +70,7 @@ pub struct UserPlaylog {
|
|||||||
|
|
||||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
#[serde(deserialize_with = "deserialize_number_from_string")]
|
||||||
pub judge_critical: u32,
|
pub judge_critical: u32,
|
||||||
|
|
||||||
// Only introduced in CHUNITHM NEW, thus needing a default value.
|
// Only introduced in CHUNITHM NEW, thus needing a default value.
|
||||||
#[serde(
|
#[serde(
|
||||||
default = "default_judge_heaven",
|
default = "default_judge_heaven",
|
||||||
@ -91,54 +91,3 @@ pub struct UserPlaylog {
|
|||||||
fn default_judge_heaven() -> u32 {
|
fn default_judge_heaven() -> u32 {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UserMusicDetail {
|
|
||||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
|
||||||
pub music_id: u32,
|
|
||||||
|
|
||||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
|
||||||
pub level: u32,
|
|
||||||
|
|
||||||
#[serde(deserialize_with = "deserialize_number_from_string")]
|
|
||||||
pub score_max: u32,
|
|
||||||
|
|
||||||
#[serde(deserialize_with = "deserialize_bool")]
|
|
||||||
pub is_all_justice: bool,
|
|
||||||
|
|
||||||
#[serde(deserialize_with = "deserialize_bool")]
|
|
||||||
pub is_full_combo: bool,
|
|
||||||
|
|
||||||
#[serde(deserialize_with = "deserialize_bool")]
|
|
||||||
pub is_success: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UserMusicItem {
|
|
||||||
pub length: u32,
|
|
||||||
pub user_music_detail_list: Vec<UserMusicDetail>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UserMusicResponse {
|
|
||||||
pub user_id: String,
|
|
||||||
pub length: u32,
|
|
||||||
pub user_music_list: Vec<UserMusicItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UpsertUserAllBody {
|
|
||||||
pub user_data: Vec<UserData>,
|
|
||||||
pub user_playlog_list: Vec<UserPlaylog>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UpsertUserAllRequest {
|
|
||||||
pub user_id: String,
|
|
||||||
pub upsert_user_all: UpsertUserAllBody,
|
|
||||||
}
|
|
143
src/types/mod.rs
143
src/types/mod.rs
@ -1,2 +1,143 @@
|
|||||||
pub mod game;
|
pub mod chuni;
|
||||||
pub mod tachi;
|
pub mod tachi;
|
||||||
|
|
||||||
|
use chrono::{FixedOffset, NaiveDateTime, TimeZone};
|
||||||
|
use num_enum::TryFromPrimitiveError;
|
||||||
|
use snafu::{ResultExt, Snafu};
|
||||||
|
|
||||||
|
use self::{
|
||||||
|
chuni::{upsert::UserPlaylog, UpsertUserAllRequest},
|
||||||
|
tachi::batch_manual::{
|
||||||
|
class::ClassEmblem,
|
||||||
|
score::{Difficulty, Judgements, Lamp, MatchType, OptionalMetrics},
|
||||||
|
BatchManualClasses, BatchManualImport, BatchManualScore,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
pub enum ScoreConversionError {
|
||||||
|
#[snafu(display("Unknown difficulty index."))]
|
||||||
|
InvalidDifficulty {
|
||||||
|
source: TryFromPrimitiveError<Difficulty>,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[snafu(display("Invalid play date."))]
|
||||||
|
InvalidPlayDate { source: chrono::format::ParseError },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserPlaylog {
|
||||||
|
pub fn to_batch_manual(
|
||||||
|
&self,
|
||||||
|
major_version: u16,
|
||||||
|
fail_over_lamp: bool,
|
||||||
|
) -> Result<BatchManualScore, ScoreConversionError> {
|
||||||
|
let lamp = if !self.is_clear && fail_over_lamp {
|
||||||
|
Lamp::Failed
|
||||||
|
} else if self.is_all_justice {
|
||||||
|
if self.judge_justice + self.judge_attack + self.judge_guilty == 0 {
|
||||||
|
Lamp::AllJusticeCritical
|
||||||
|
} else {
|
||||||
|
Lamp::AllJustice
|
||||||
|
}
|
||||||
|
} else if self.is_full_combo {
|
||||||
|
Lamp::FullCombo
|
||||||
|
} else if self.is_clear {
|
||||||
|
Lamp::Clear
|
||||||
|
} else {
|
||||||
|
Lamp::Failed
|
||||||
|
};
|
||||||
|
|
||||||
|
let judgements = Judgements {
|
||||||
|
jcrit: self.judge_heaven + self.judge_critical,
|
||||||
|
justice: self.judge_justice,
|
||||||
|
attack: self.judge_attack,
|
||||||
|
miss: self.judge_guilty,
|
||||||
|
};
|
||||||
|
|
||||||
|
let difficulty = if major_version == 1 && self.level == 4 {
|
||||||
|
Difficulty::WorldsEnd
|
||||||
|
} else {
|
||||||
|
Difficulty::try_from(self.level).context(InvalidDifficultySnafu)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let datetime = NaiveDateTime::parse_from_str(&self.user_play_date, "%Y-%m-%d %H:%M:%S")
|
||||||
|
.context(InvalidPlayDateSnafu)?;
|
||||||
|
let jst_offset = FixedOffset::east_opt(9 * 3600).expect("chrono should parse JST timezone");
|
||||||
|
let jst_time = jst_offset.from_local_datetime(&datetime).unwrap();
|
||||||
|
|
||||||
|
Ok(BatchManualScore {
|
||||||
|
score: self.score,
|
||||||
|
lamp,
|
||||||
|
match_type: MatchType::InGameId,
|
||||||
|
identifier: self.music_id.clone(),
|
||||||
|
difficulty,
|
||||||
|
time_achieved: Some(jst_time.timestamp_millis() as u128),
|
||||||
|
judgements: Some(judgements),
|
||||||
|
optional: Some(OptionalMetrics {
|
||||||
|
max_combo: self.max_combo,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ToBatchManual {
|
||||||
|
fn to_batch_manual(
|
||||||
|
&self,
|
||||||
|
major_version: u16,
|
||||||
|
export_class: bool,
|
||||||
|
fail_over_lamp: bool,
|
||||||
|
) -> BatchManualImport;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToBatchManual for UpsertUserAllRequest {
|
||||||
|
fn to_batch_manual(
|
||||||
|
&self,
|
||||||
|
major_version: u16,
|
||||||
|
export_class: bool,
|
||||||
|
fail_over_lamp: bool,
|
||||||
|
) -> BatchManualImport {
|
||||||
|
let user_data = &self.upsert_user_all.user_data[0];
|
||||||
|
|
||||||
|
let classes = if export_class {
|
||||||
|
let dan = if let Some(medal) = user_data.class_emblem_medal {
|
||||||
|
ClassEmblem::try_from(medal).ok()
|
||||||
|
} else if let Some(user_data_ex) = &self.upsert_user_all.user_data_ex {
|
||||||
|
ClassEmblem::try_from(user_data_ex[0].medal).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let emblem = user_data
|
||||||
|
.class_emblem_base
|
||||||
|
.map(|b| ClassEmblem::try_from(b).ok())
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
Some(BatchManualClasses { dan, emblem })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let scores = self
|
||||||
|
.upsert_user_all
|
||||||
|
.user_playlog_list
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| {
|
||||||
|
let conv = p.to_batch_manual(major_version, fail_over_lamp);
|
||||||
|
|
||||||
|
if conv
|
||||||
|
.as_ref()
|
||||||
|
.is_ok_and(|s| s.difficulty != Difficulty::WorldsEnd)
|
||||||
|
{
|
||||||
|
conv.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
BatchManualImport {
|
||||||
|
classes,
|
||||||
|
scores,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,435 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use chrono::{FixedOffset, NaiveDateTime, TimeZone};
|
|
||||||
use num_enum::TryFromPrimitive;
|
|
||||||
use serde::{de, Deserialize, Deserializer, Serialize};
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
|
|
||||||
use super::game::{UpsertUserAllRequest, UserMusicDetail, UserMusicResponse, UserPlaylog};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum TachiResponse<T> {
|
|
||||||
Ok(TachiSuccessResponse<T>),
|
|
||||||
Err(TachiErrorResponse),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de, T> Deserialize<'de> for TachiResponse<T>
|
|
||||||
where
|
|
||||||
T: Deserialize<'de>,
|
|
||||||
{
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<TachiResponse<T>, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let mut map = Map::deserialize(deserializer)?;
|
|
||||||
|
|
||||||
let success = map
|
|
||||||
.remove("success")
|
|
||||||
.ok_or_else(|| de::Error::missing_field("success"))
|
|
||||||
.map(Deserialize::deserialize)?
|
|
||||||
.map_err(de::Error::custom)?;
|
|
||||||
let rest = Value::Object(map);
|
|
||||||
|
|
||||||
if success {
|
|
||||||
TachiSuccessResponse::deserialize(rest)
|
|
||||||
.map(TachiResponse::Ok)
|
|
||||||
.map_err(de::Error::custom)
|
|
||||||
} else {
|
|
||||||
TachiErrorResponse::deserialize(rest)
|
|
||||||
.map(TachiResponse::Err)
|
|
||||||
.map_err(de::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct TachiSuccessResponse<T> {
|
|
||||||
pub description: String,
|
|
||||||
pub body: T,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct TachiErrorResponse {
|
|
||||||
pub description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct StatusCheck {
|
|
||||||
pub permissions: Vec<String>,
|
|
||||||
pub whoami: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum ImportResponse {
|
|
||||||
Queued {
|
|
||||||
url: String,
|
|
||||||
|
|
||||||
#[serde(rename = "importID")]
|
|
||||||
import_id: String,
|
|
||||||
},
|
|
||||||
Finished(ImportDocument),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct ImportDocument {
|
|
||||||
#[serde(rename = "scoreIDs")]
|
|
||||||
pub score_ids: Vec<String>,
|
|
||||||
|
|
||||||
pub errors: Vec<ImportErrContent>,
|
|
||||||
|
|
||||||
#[serde(rename = "createdSessions")]
|
|
||||||
pub created_sessions: Vec<SessionInfoReturn>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct ImportErrContent {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub error_type: String,
|
|
||||||
|
|
||||||
pub message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct SessionInfoReturn {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub session_type: String,
|
|
||||||
|
|
||||||
#[serde(rename = "sessionID")]
|
|
||||||
pub session_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "importStatus")]
|
|
||||||
pub enum ImportPollStatus {
|
|
||||||
#[serde(rename = "ongoing")]
|
|
||||||
Ongoing { progress: ImportProgress },
|
|
||||||
|
|
||||||
#[serde(rename = "completed")]
|
|
||||||
Completed { import: ImportDocument },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct ImportProgress {
|
|
||||||
pub description: String,
|
|
||||||
pub value: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Import {
|
|
||||||
pub meta: ImportMeta,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub classes: Option<ImportClasses>,
|
|
||||||
pub scores: Vec<ImportScore>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ImportMeta {
|
|
||||||
pub game: String,
|
|
||||||
pub playtype: String,
|
|
||||||
pub service: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ImportMeta {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
game: "chunithm".to_string(),
|
|
||||||
playtype: "Single".to_string(),
|
|
||||||
service: "Saekawa".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct ImportClasses {
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub dan: Option<ClassEmblem>,
|
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub emblem: Option<ClassEmblem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
|
||||||
#[repr(u32)]
|
|
||||||
pub enum ClassEmblem {
|
|
||||||
#[serde(rename = "DAN_I")]
|
|
||||||
First = 1,
|
|
||||||
|
|
||||||
#[serde(rename = "DAN_II")]
|
|
||||||
Second = 2,
|
|
||||||
|
|
||||||
#[serde(rename = "DAN_III")]
|
|
||||||
Third = 3,
|
|
||||||
|
|
||||||
#[serde(rename = "DAN_IV")]
|
|
||||||
Fourth = 4,
|
|
||||||
|
|
||||||
#[serde(rename = "DAN_V")]
|
|
||||||
Fifth = 5,
|
|
||||||
|
|
||||||
#[serde(rename = "DAN_INFINITE")]
|
|
||||||
Infinite = 6,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ImportScore {
|
|
||||||
pub score: u32,
|
|
||||||
pub lamp: TachiLamp,
|
|
||||||
pub match_type: String,
|
|
||||||
pub identifier: String,
|
|
||||||
pub difficulty: Difficulty,
|
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub time_achieved: Option<u128>,
|
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub judgements: Option<Judgements>,
|
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub optional: Option<OptionalMetrics>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
|
||||||
#[repr(u32)]
|
|
||||||
pub enum TachiLamp {
|
|
||||||
#[serde(rename = "FAILED")]
|
|
||||||
Failed = 0,
|
|
||||||
|
|
||||||
#[serde(rename = "CLEAR")]
|
|
||||||
Clear = 1,
|
|
||||||
|
|
||||||
#[serde(rename = "FULL COMBO")]
|
|
||||||
FullCombo = 2,
|
|
||||||
|
|
||||||
#[serde(rename = "ALL JUSTICE")]
|
|
||||||
AllJustice = 3,
|
|
||||||
|
|
||||||
#[serde(rename = "ALL JUSTICE CRITICAL")]
|
|
||||||
AllJusticeCritical = 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
|
||||||
#[repr(u32)]
|
|
||||||
pub enum Difficulty {
|
|
||||||
#[serde(rename = "BASIC")]
|
|
||||||
Basic = 0,
|
|
||||||
|
|
||||||
#[serde(rename = "ADVANCED")]
|
|
||||||
Advanced = 1,
|
|
||||||
|
|
||||||
#[serde(rename = "EXPERT")]
|
|
||||||
Expert = 2,
|
|
||||||
|
|
||||||
#[serde(rename = "MASTER")]
|
|
||||||
Master = 3,
|
|
||||||
|
|
||||||
#[serde(rename = "ULTIMA")]
|
|
||||||
Ultima = 4,
|
|
||||||
|
|
||||||
#[serde(rename = "WORLD'S END")]
|
|
||||||
WorldsEnd = 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Judgements {
|
|
||||||
pub jcrit: u32,
|
|
||||||
pub justice: u32,
|
|
||||||
pub attack: u32,
|
|
||||||
pub miss: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct OptionalMetrics {
|
|
||||||
pub max_combo: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImportScore {
|
|
||||||
pub fn try_from_playlog(
|
|
||||||
p: &UserPlaylog,
|
|
||||||
major_version: u16,
|
|
||||||
fail_over_lamp: bool,
|
|
||||||
) -> Result<ImportScore> {
|
|
||||||
let lamp = if !p.is_clear && fail_over_lamp {
|
|
||||||
TachiLamp::Failed
|
|
||||||
} else if p.is_all_justice {
|
|
||||||
if p.judge_justice + p.judge_attack + p.judge_guilty == 0 {
|
|
||||||
TachiLamp::AllJusticeCritical
|
|
||||||
} else {
|
|
||||||
TachiLamp::AllJustice
|
|
||||||
}
|
|
||||||
} else if p.is_full_combo {
|
|
||||||
TachiLamp::FullCombo
|
|
||||||
} else if p.is_clear {
|
|
||||||
TachiLamp::Clear
|
|
||||||
} else {
|
|
||||||
TachiLamp::Failed
|
|
||||||
};
|
|
||||||
|
|
||||||
let judgements = Judgements {
|
|
||||||
jcrit: p.judge_heaven + p.judge_critical,
|
|
||||||
justice: p.judge_justice,
|
|
||||||
attack: p.judge_attack,
|
|
||||||
miss: p.judge_guilty,
|
|
||||||
};
|
|
||||||
|
|
||||||
let difficulty = if major_version == 1 && p.level == 4 {
|
|
||||||
Difficulty::WorldsEnd
|
|
||||||
} else {
|
|
||||||
Difficulty::try_from(p.level)?
|
|
||||||
};
|
|
||||||
|
|
||||||
let datetime = NaiveDateTime::parse_from_str(&p.user_play_date, "%Y-%m-%d %H:%M:%S")?;
|
|
||||||
let jst_offset =
|
|
||||||
FixedOffset::east_opt(9 * 3600).expect("chrono should be able to parse JST timezone");
|
|
||||||
let jst_time = jst_offset.from_local_datetime(&datetime).unwrap();
|
|
||||||
|
|
||||||
Ok(ImportScore {
|
|
||||||
score: p.score,
|
|
||||||
lamp,
|
|
||||||
match_type: "inGameID".to_string(),
|
|
||||||
identifier: p.music_id.clone(),
|
|
||||||
difficulty,
|
|
||||||
time_achieved: Some(jst_time.timestamp_millis() as u128),
|
|
||||||
judgements: Some(judgements),
|
|
||||||
optional: Some(OptionalMetrics {
|
|
||||||
max_combo: p.max_combo,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_from_music_detail(
|
|
||||||
d: &UserMusicDetail,
|
|
||||||
major_version: u16,
|
|
||||||
fail_over_lamp: bool,
|
|
||||||
) -> Result<ImportScore> {
|
|
||||||
let lamp = if !d.is_success && fail_over_lamp {
|
|
||||||
TachiLamp::Failed
|
|
||||||
} else if d.is_all_justice {
|
|
||||||
TachiLamp::AllJustice
|
|
||||||
} else if d.is_full_combo {
|
|
||||||
TachiLamp::FullCombo
|
|
||||||
} else if d.is_success {
|
|
||||||
TachiLamp::Clear
|
|
||||||
} else {
|
|
||||||
TachiLamp::Failed
|
|
||||||
};
|
|
||||||
|
|
||||||
let difficulty = if major_version == 1 && d.level == 4 {
|
|
||||||
Difficulty::WorldsEnd
|
|
||||||
} else {
|
|
||||||
Difficulty::try_from(d.level)?
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ImportScore {
|
|
||||||
score: d.score_max,
|
|
||||||
lamp,
|
|
||||||
match_type: "inGameID".to_string(),
|
|
||||||
identifier: d.music_id.to_string(),
|
|
||||||
difficulty,
|
|
||||||
time_achieved: None,
|
|
||||||
judgements: None,
|
|
||||||
optional: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait ToTachiImport {
|
|
||||||
fn displayed_id(&self) -> &str;
|
|
||||||
fn displayed_id_type(&self) -> &str;
|
|
||||||
fn to_tachi_import(
|
|
||||||
&self,
|
|
||||||
major_version: u16,
|
|
||||||
export_class: bool,
|
|
||||||
fail_over_lamp: bool,
|
|
||||||
) -> Import;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToTachiImport for UserMusicResponse {
|
|
||||||
fn displayed_id(&self) -> &str {
|
|
||||||
&self.user_id
|
|
||||||
}
|
|
||||||
|
|
||||||
fn displayed_id_type(&self) -> &str {
|
|
||||||
"user ID"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_tachi_import(&self, major_version: u16, _: bool, fail_over_lamp: bool) -> Import {
|
|
||||||
let scores = self
|
|
||||||
.user_music_list
|
|
||||||
.iter()
|
|
||||||
.flat_map(|item| {
|
|
||||||
item.user_music_detail_list.iter().filter_map(|d| {
|
|
||||||
let result =
|
|
||||||
ImportScore::try_from_music_detail(d, major_version, fail_over_lamp);
|
|
||||||
if result
|
|
||||||
.as_ref()
|
|
||||||
.is_ok_and(|v| v.difficulty != Difficulty::WorldsEnd)
|
|
||||||
{
|
|
||||||
result.ok()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
Import {
|
|
||||||
scores,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToTachiImport for UpsertUserAllRequest {
|
|
||||||
fn displayed_id(&self) -> &str {
|
|
||||||
let user_data = &self.upsert_user_all.user_data[0];
|
|
||||||
|
|
||||||
&user_data.access_code
|
|
||||||
}
|
|
||||||
|
|
||||||
fn displayed_id_type(&self) -> &str {
|
|
||||||
"access code"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_tachi_import(
|
|
||||||
&self,
|
|
||||||
major_version: u16,
|
|
||||||
export_class: bool,
|
|
||||||
fail_over_lamp: bool,
|
|
||||||
) -> Import {
|
|
||||||
let user_data = &self.upsert_user_all.user_data[0];
|
|
||||||
|
|
||||||
let classes = if export_class {
|
|
||||||
Some(ImportClasses {
|
|
||||||
dan: ClassEmblem::try_from(user_data.class_emblem_medal).ok(),
|
|
||||||
emblem: ClassEmblem::try_from(user_data.class_emblem_base).ok(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let scores = self
|
|
||||||
.upsert_user_all
|
|
||||||
.user_playlog_list
|
|
||||||
.iter()
|
|
||||||
.filter_map(|playlog| {
|
|
||||||
let result = ImportScore::try_from_playlog(playlog, major_version, fail_over_lamp);
|
|
||||||
if result
|
|
||||||
.as_ref()
|
|
||||||
.is_ok_and(|v| v.difficulty != Difficulty::WorldsEnd)
|
|
||||||
{
|
|
||||||
result.ok()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<ImportScore>>();
|
|
||||||
|
|
||||||
Import {
|
|
||||||
classes,
|
|
||||||
scores,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
74
src/types/tachi/api.rs
Normal file
74
src/types/tachi/api.rs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
use serde::{de, Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TachiResponse<T> {
|
||||||
|
Ok(TachiSuccessResponse<T>),
|
||||||
|
Err(TachiFailureResponse),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct TachiSuccessResponse<T> {
|
||||||
|
pub description: String,
|
||||||
|
pub body: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct TachiFailureResponse {
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub enum TachiApiPermission {
|
||||||
|
#[serde(rename = "customise_profile")]
|
||||||
|
CustomiseProfile,
|
||||||
|
|
||||||
|
#[serde(rename = "customise_score")]
|
||||||
|
CustomiseScore,
|
||||||
|
|
||||||
|
#[serde(rename = "customise_session")]
|
||||||
|
CustomiseSession,
|
||||||
|
|
||||||
|
#[serde(rename = "delete_score")]
|
||||||
|
DeleteScore,
|
||||||
|
|
||||||
|
#[serde(rename = "manage_challenges")]
|
||||||
|
ManageChallenges,
|
||||||
|
|
||||||
|
#[serde(rename = "manage_rivals")]
|
||||||
|
ManageRivals,
|
||||||
|
|
||||||
|
#[serde(rename = "manage_targets")]
|
||||||
|
ManageTargets,
|
||||||
|
|
||||||
|
#[serde(rename = "submit_score")]
|
||||||
|
SubmitScore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de, T> Deserialize<'de> for TachiResponse<T>
|
||||||
|
where
|
||||||
|
T: Deserialize<'de>,
|
||||||
|
{
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let mut map = Map::deserialize(deserializer)?;
|
||||||
|
let success = map
|
||||||
|
.remove("success")
|
||||||
|
.ok_or_else(|| de::Error::missing_field("success"))
|
||||||
|
.map(Deserialize::deserialize)?
|
||||||
|
.map_err(de::Error::custom)?;
|
||||||
|
let rest = Value::Object(map);
|
||||||
|
|
||||||
|
if success {
|
||||||
|
TachiSuccessResponse::deserialize(rest)
|
||||||
|
.map(TachiResponse::Ok)
|
||||||
|
.map_err(de::Error::custom)
|
||||||
|
} else {
|
||||||
|
TachiFailureResponse::deserialize(rest)
|
||||||
|
.map(TachiResponse::Err)
|
||||||
|
.map_err(de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
40
src/types/tachi/api_returns.rs
Normal file
40
src/types/tachi/api_returns.rs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{api::TachiApiPermission, documents::ImportDocument};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ServerStatus {
|
||||||
|
pub whoami: Option<u32>,
|
||||||
|
pub permissions: Vec<TachiApiPermission>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ImportDeferred {
|
||||||
|
pub url: String,
|
||||||
|
|
||||||
|
#[serde(rename = "importID")]
|
||||||
|
pub import_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum ImportResponse {
|
||||||
|
Deferred(ImportDeferred),
|
||||||
|
Completed(ImportDocument),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ImportProgress {
|
||||||
|
description: String,
|
||||||
|
value: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "importStatus")]
|
||||||
|
pub enum ImportPollStatus {
|
||||||
|
#[serde(rename = "ongoing")]
|
||||||
|
Ongoing { progress: ImportProgress },
|
||||||
|
|
||||||
|
#[serde(rename = "completed")]
|
||||||
|
Completed { import: ImportDocument },
|
||||||
|
}
|
33
src/types/tachi/batch_manual/class.rs
Normal file
33
src/types/tachi/batch_manual/class.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
use num_enum::TryFromPrimitive;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct BatchManualClasses {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dan: Option<ClassEmblem>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub emblem: Option<ClassEmblem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
||||||
|
#[repr(u32)]
|
||||||
|
pub enum ClassEmblem {
|
||||||
|
#[serde(rename = "DAN_I")]
|
||||||
|
First = 1,
|
||||||
|
|
||||||
|
#[serde(rename = "DAN_II")]
|
||||||
|
Second = 2,
|
||||||
|
|
||||||
|
#[serde(rename = "DAN_III")]
|
||||||
|
Third = 3,
|
||||||
|
|
||||||
|
#[serde(rename = "DAN_IV")]
|
||||||
|
Fourth = 4,
|
||||||
|
|
||||||
|
#[serde(rename = "DAN_V")]
|
||||||
|
Fifth = 5,
|
||||||
|
|
||||||
|
#[serde(rename = "DAN_INFINITE")]
|
||||||
|
Infinite = 6,
|
||||||
|
}
|
18
src/types/tachi/batch_manual/meta.rs
Normal file
18
src/types/tachi/batch_manual/meta.rs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BatchManualMeta {
|
||||||
|
pub game: String,
|
||||||
|
pub playtype: String,
|
||||||
|
pub service: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BatchManualMeta {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
game: "chunithm".to_string(),
|
||||||
|
playtype: "Single".to_string(),
|
||||||
|
service: "Saekawa".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
src/types/tachi/batch_manual/mod.rs
Normal file
16
src/types/tachi/batch_manual/mod.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
pub mod class;
|
||||||
|
pub mod meta;
|
||||||
|
pub mod score;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use self::{class::BatchManualClasses, meta::BatchManualMeta, score::BatchManualScore};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct BatchManualImport {
|
||||||
|
pub meta: BatchManualMeta,
|
||||||
|
pub scores: Vec<BatchManualScore>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub classes: Option<BatchManualClasses>,
|
||||||
|
}
|
105
src/types/tachi/batch_manual/score.rs
Normal file
105
src/types/tachi/batch_manual/score.rs
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
use num_enum::TryFromPrimitive;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BatchManualScore {
|
||||||
|
pub match_type: MatchType,
|
||||||
|
pub identifier: String,
|
||||||
|
pub difficulty: Difficulty,
|
||||||
|
pub score: u32,
|
||||||
|
pub lamp: Lamp,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub judgements: Option<Judgements>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub time_achieved: Option<u128>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub optional: Option<OptionalMetrics>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
||||||
|
#[repr(u32)]
|
||||||
|
pub enum Lamp {
|
||||||
|
#[serde(rename = "FAILED")]
|
||||||
|
Failed = 0,
|
||||||
|
|
||||||
|
#[serde(rename = "CLEAR")]
|
||||||
|
Clear = 1,
|
||||||
|
|
||||||
|
#[serde(rename = "FULL COMBO")]
|
||||||
|
FullCombo = 2,
|
||||||
|
|
||||||
|
#[serde(rename = "ALL JUSTICE")]
|
||||||
|
AllJustice = 3,
|
||||||
|
|
||||||
|
#[serde(rename = "ALL JUSTICE CRITICAL")]
|
||||||
|
AllJusticeCritical = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)]
|
||||||
|
#[repr(u32)]
|
||||||
|
pub enum Difficulty {
|
||||||
|
#[serde(rename = "BASIC")]
|
||||||
|
Basic = 0,
|
||||||
|
|
||||||
|
#[serde(rename = "ADVANCED")]
|
||||||
|
Advanced = 1,
|
||||||
|
|
||||||
|
#[serde(rename = "EXPERT")]
|
||||||
|
Expert = 2,
|
||||||
|
|
||||||
|
#[serde(rename = "MASTER")]
|
||||||
|
Master = 3,
|
||||||
|
|
||||||
|
#[serde(rename = "ULTIMA")]
|
||||||
|
Ultima = 4,
|
||||||
|
|
||||||
|
#[serde(rename = "WORLD'S END")]
|
||||||
|
WorldsEnd = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub enum MatchType {
|
||||||
|
#[serde(rename = "bmsChartHash")]
|
||||||
|
BmsChartHash,
|
||||||
|
|
||||||
|
#[serde(rename = "itgChartHash")]
|
||||||
|
ItgChartHash,
|
||||||
|
|
||||||
|
#[serde(rename = "popnChartHash")]
|
||||||
|
PopnChartHash,
|
||||||
|
|
||||||
|
#[serde(rename = "uscChartHash")]
|
||||||
|
UscChartHash,
|
||||||
|
|
||||||
|
#[serde(rename = "inGameID")]
|
||||||
|
InGameId,
|
||||||
|
|
||||||
|
#[serde(rename = "inGameStrID")]
|
||||||
|
InGameStrId,
|
||||||
|
|
||||||
|
#[serde(rename = "sdvxInGameID")]
|
||||||
|
SdvxInGameId,
|
||||||
|
|
||||||
|
#[serde(rename = "songTitle")]
|
||||||
|
SongTitle,
|
||||||
|
|
||||||
|
#[serde(rename = "tachiSongID")]
|
||||||
|
TachiSongId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Judgements {
|
||||||
|
pub jcrit: u32,
|
||||||
|
pub justice: u32,
|
||||||
|
pub attack: u32,
|
||||||
|
pub miss: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OptionalMetrics {
|
||||||
|
pub max_combo: u32,
|
||||||
|
}
|
35
src/types/tachi/documents.rs
Normal file
35
src/types/tachi/documents.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub enum SessionType {
|
||||||
|
Appended,
|
||||||
|
Created,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ImportErrContent {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub error_type: String,
|
||||||
|
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionInfoReturn {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub session_type: SessionType,
|
||||||
|
|
||||||
|
#[serde(rename = "sessionID")]
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ImportDocument {
|
||||||
|
#[serde(rename = "scoreIDs")]
|
||||||
|
pub score_ids: Vec<String>,
|
||||||
|
|
||||||
|
pub errors: Vec<ImportErrContent>,
|
||||||
|
|
||||||
|
#[serde(rename = "createdSessions")]
|
||||||
|
pub created_sessions: Vec<SessionInfoReturn>,
|
||||||
|
}
|
4
src/types/tachi/mod.rs
Normal file
4
src/types/tachi/mod.rs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
pub mod api;
|
||||||
|
pub mod api_returns;
|
||||||
|
pub mod batch_manual;
|
||||||
|
pub mod documents;
|
94
src/updater/external.rs
Normal file
94
src/updater/external.rs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
use winapi::{
|
||||||
|
ctypes::c_void,
|
||||||
|
shared::{
|
||||||
|
minwindef::{BOOL, DWORD, HMODULE, LPVOID, PROC},
|
||||||
|
ntdef::{HANDLE, LPCWSTR, LPSTR},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[link_section = ".rtext"]
|
||||||
|
#[used]
|
||||||
|
pub static mut GET_MODULE_FILE_NAME_A_PTR: PROC = 0 as PROC;
|
||||||
|
|
||||||
|
#[link_section = ".rtext"]
|
||||||
|
#[used]
|
||||||
|
pub static mut GET_PROCESS_HEAP_PTR: PROC = 0 as PROC;
|
||||||
|
|
||||||
|
#[link_section = ".rtext"]
|
||||||
|
#[used]
|
||||||
|
pub static mut REPLCE_FILE_W_PTR: PROC = 0 as PROC;
|
||||||
|
|
||||||
|
#[link_section = ".rtext"]
|
||||||
|
#[used]
|
||||||
|
pub static mut LOAD_LIBRARY_W_POINTER: PROC = 0 as PROC;
|
||||||
|
|
||||||
|
#[link_section = ".rtext"]
|
||||||
|
#[used]
|
||||||
|
pub static mut HEAP_FREE_PTR: PROC = 0 as PROC;
|
||||||
|
|
||||||
|
#[link_section = ".rtext"]
|
||||||
|
#[used]
|
||||||
|
pub static mut SLEEP_PTR: PROC = 0 as PROC;
|
||||||
|
|
||||||
|
#[link_section = ".rtext"]
|
||||||
|
#[used]
|
||||||
|
pub static mut GET_LAST_ERROR_PTR: PROC = 0 as PROC;
|
||||||
|
|
||||||
|
type GetModuleFileNameAFn = unsafe extern "system" fn(HMODULE, LPSTR, DWORD) -> DWORD;
|
||||||
|
type GetProcessHeapFn = unsafe extern "system" fn() -> HANDLE;
|
||||||
|
type ReplaceFileWFn =
|
||||||
|
unsafe extern "system" fn(LPCWSTR, LPCWSTR, LPCWSTR, DWORD, LPVOID, LPVOID) -> BOOL;
|
||||||
|
type LoadLibraryWFn = unsafe extern "system" fn(LPCWSTR) -> HMODULE;
|
||||||
|
type HeapFreeFn = unsafe extern "system" fn(HANDLE, DWORD, LPVOID) -> BOOL;
|
||||||
|
type SleepFn = unsafe extern "system" fn(DWORD);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct ReplaceArgs {
|
||||||
|
pub module: HMODULE,
|
||||||
|
pub old: [u16; 32767],
|
||||||
|
pub new: [u16; 32767],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SAFETY: This function *must* only be called when the addresses for the function have been filled in.
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[link_section = ".rtext"]
|
||||||
|
pub unsafe extern "system" fn replace_with_new_library(parameter: *const c_void) -> u32 {
|
||||||
|
let args = parameter as *const ReplaceArgs;
|
||||||
|
let GetModuleFileNameA =
|
||||||
|
std::mem::transmute::<_, GetModuleFileNameAFn>(GET_MODULE_FILE_NAME_A_PTR);
|
||||||
|
let GetProcessHeap = std::mem::transmute::<_, GetProcessHeapFn>(GET_PROCESS_HEAP_PTR);
|
||||||
|
let ReplaceFileW = std::mem::transmute::<_, ReplaceFileWFn>(REPLCE_FILE_W_PTR);
|
||||||
|
let LoadLibraryW = std::mem::transmute::<_, LoadLibraryWFn>(LOAD_LIBRARY_W_POINTER);
|
||||||
|
let HeapFree = std::mem::transmute::<_, HeapFreeFn>(HEAP_FREE_PTR);
|
||||||
|
let Sleep = std::mem::transmute::<_, SleepFn>(SLEEP_PTR);
|
||||||
|
|
||||||
|
// Wait for the old library to be freed
|
||||||
|
let mut filename = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let result = GetModuleFileNameA((*args).module, &mut filename, 1);
|
||||||
|
|
||||||
|
if result == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = ReplaceFileW(
|
||||||
|
(*args).old.as_ptr(),
|
||||||
|
(*args).new.as_ptr(),
|
||||||
|
0 as *const _,
|
||||||
|
2,
|
||||||
|
0 as *mut c_void,
|
||||||
|
0 as *mut c_void,
|
||||||
|
);
|
||||||
|
|
||||||
|
if result > 0 {
|
||||||
|
LoadLibraryW((*args).old.as_ptr());
|
||||||
|
} else {
|
||||||
|
LoadLibraryW((*args).new.as_ptr());
|
||||||
|
}
|
||||||
|
|
||||||
|
HeapFree(GetProcessHeap(), 0, args as *mut _) as u32
|
||||||
|
}
|
578
src/updater/mod.rs
Normal file
578
src/updater/mod.rs
Normal file
@ -0,0 +1,578 @@
|
|||||||
|
mod external;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
ffi::CStr,
|
||||||
|
io::{self, Read},
|
||||||
|
mem::{self},
|
||||||
|
path::Path,
|
||||||
|
ptr,
|
||||||
|
};
|
||||||
|
|
||||||
|
use log::{debug, error, info};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use snafu::{prelude::Snafu, ResultExt};
|
||||||
|
use widestring::U16CString;
|
||||||
|
use winapi::{
|
||||||
|
shared::{
|
||||||
|
minwindef::{BOOL, DWORD, HMODULE, LPVOID, PROC},
|
||||||
|
ntdef::{LPCWSTR, LPSTR},
|
||||||
|
winerror::{
|
||||||
|
CERT_E_CHAINING, CERT_E_EXPIRED, CERT_E_UNTRUSTEDROOT, CRYPT_E_SECURITY_SETTINGS,
|
||||||
|
TRUST_E_EXPLICIT_DISTRUST, TRUST_E_NOSIGNATURE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
um::{
|
||||||
|
heapapi::HeapAlloc,
|
||||||
|
memoryapi::{VirtualAlloc, VirtualProtect},
|
||||||
|
minwinbase::LMEM_ZEROINIT,
|
||||||
|
processthreadsapi::CreateThread,
|
||||||
|
softpub::WINTRUST_ACTION_GENERIC_VERIFY_V2,
|
||||||
|
winbase::LocalAlloc,
|
||||||
|
wincrypt::{
|
||||||
|
CertCloseStore, CertFindCertificateInStore, CryptMsgClose, CryptMsgGetParam,
|
||||||
|
CryptQueryObject, CERT_FIND_SUBJECT_CERT, CERT_INFO,
|
||||||
|
CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, CERT_QUERY_FORMAT_FLAG_BINARY,
|
||||||
|
CERT_QUERY_OBJECT_FILE, CMSG_SIGNER_INFO_PARAM, HCERTSTORE, HCRYPTMSG,
|
||||||
|
PCMSG_SIGNER_INFO, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
|
||||||
|
},
|
||||||
|
winnt::{
|
||||||
|
HANDLE, HEAP_ZERO_MEMORY, IMAGE_DOS_HEADER, IMAGE_NT_HEADERS32, IMAGE_SECTION_HEADER,
|
||||||
|
MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ, PAGE_READWRITE,
|
||||||
|
},
|
||||||
|
wintrust::{
|
||||||
|
WinVerifyTrust, WINTRUST_DATA, WINTRUST_FILE_INFO, WTD_CHOICE_FILE, WTD_REVOKE_NONE,
|
||||||
|
WTD_STATEACTION_VERIFY, WTD_UI_NONE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use self::external::{replace_with_new_library, ReplaceArgs};
|
||||||
|
use crate::helpers::winapi_ext::{get_module_file_name, LibraryHandle, ReadStringFnError};
|
||||||
|
|
||||||
|
const PUBLIC_KEY: [u8; 270] = [
|
||||||
|
0x30, 0x82, 0x01, 0x0a, 0x02, 0x82, 0x01, 0x01, 0x00, 0xf5, 0xbd, 0x02, 0xb0, 0x81, 0xc6, 0x4d,
|
||||||
|
0x4c, 0xa0, 0x40, 0xa8, 0x76, 0x78, 0xe2, 0x61, 0x39, 0x13, 0x1d, 0x2f, 0x0c, 0x70, 0x71, 0x96,
|
||||||
|
0x56, 0x67, 0xf2, 0xbe, 0xc2, 0x5c, 0xc7, 0xd4, 0xa6, 0xb5, 0x07, 0xc5, 0x7a, 0x19, 0x58, 0x10,
|
||||||
|
0x70, 0xb5, 0x87, 0x5f, 0x3f, 0x9a, 0x78, 0x9e, 0x96, 0x5c, 0xc7, 0x88, 0x50, 0x8c, 0x34, 0xcc,
|
||||||
|
0x51, 0xe5, 0xd5, 0xbd, 0xb8, 0xab, 0xed, 0x28, 0x7f, 0x68, 0x6e, 0x27, 0x2a, 0x1d, 0xdb, 0x9a,
|
||||||
|
0xe9, 0x1d, 0xbc, 0xd8, 0xbf, 0xca, 0xdf, 0x65, 0xa3, 0x0a, 0x19, 0x3d, 0x00, 0x14, 0x16, 0xdd,
|
||||||
|
0x87, 0x9f, 0xf5, 0x44, 0x9e, 0x56, 0x1e, 0xfd, 0xb5, 0xf0, 0x75, 0x3d, 0x11, 0x4c, 0x4d, 0xa5,
|
||||||
|
0x1a, 0x24, 0xfe, 0x31, 0x77, 0xc1, 0x55, 0xf7, 0x5d, 0x9c, 0x34, 0xbe, 0x5f, 0x9d, 0x73, 0x2c,
|
||||||
|
0x3e, 0xdb, 0x39, 0x18, 0x3c, 0xb3, 0x46, 0xe0, 0xf4, 0xa1, 0xcc, 0x2f, 0x7b, 0x07, 0xb7, 0x0e,
|
||||||
|
0x7a, 0x92, 0x54, 0xa9, 0x9f, 0xfc, 0x4c, 0xe0, 0xbb, 0xcf, 0xba, 0x36, 0xc6, 0xcb, 0x9d, 0xb1,
|
||||||
|
0x12, 0x4b, 0x50, 0x1c, 0x10, 0x23, 0x87, 0x28, 0x9b, 0x73, 0xe3, 0xd5, 0xc9, 0x38, 0xae, 0xd7,
|
||||||
|
0x66, 0x73, 0x8f, 0xf8, 0x56, 0x2e, 0x48, 0x0a, 0xdb, 0x7f, 0x11, 0xbf, 0xd6, 0x4e, 0x77, 0x6c,
|
||||||
|
0xb8, 0x12, 0xaf, 0x0b, 0x7b, 0x08, 0xe3, 0x0f, 0x7e, 0xf1, 0x6a, 0xc0, 0xac, 0x1c, 0xe2, 0x8c,
|
||||||
|
0x47, 0xb0, 0xec, 0x10, 0xca, 0x02, 0x9c, 0x7d, 0x27, 0x78, 0x33, 0x3c, 0x25, 0x88, 0x5c, 0x4f,
|
||||||
|
0x4b, 0xb8, 0x72, 0xeb, 0x85, 0x31, 0x39, 0xb1, 0x95, 0xae, 0xc3, 0x79, 0x38, 0x20, 0x25, 0x0e,
|
||||||
|
0xab, 0xdc, 0x9c, 0xc8, 0x25, 0x53, 0xd2, 0xcf, 0x93, 0xf0, 0x1d, 0x95, 0x58, 0x0b, 0x0c, 0x9f,
|
||||||
|
0xc5, 0x01, 0x7a, 0xad, 0x4f, 0x55, 0x2f, 0x24, 0xc5, 0x02, 0x03, 0x01, 0x00, 0x01,
|
||||||
|
];
|
||||||
|
|
||||||
|
// I don't know what the hell is going on with linking, but you have to link these manually,
|
||||||
|
// otherwise you end up with the addresses to the intermediary functions, which obviously
|
||||||
|
// doesn't exist once you unloads the original library.
|
||||||
|
#[link(name = "kernel32")]
|
||||||
|
extern "system" {
|
||||||
|
pub fn GetLastError() -> u32;
|
||||||
|
pub fn GetModuleFileNameA(hModule: HMODULE, lpFilename: LPSTR, nsize: DWORD) -> u32;
|
||||||
|
pub fn GetProcessHeap() -> HANDLE;
|
||||||
|
pub fn HeapFree(hHeap: HANDLE, dwFlags: DWORD, lpMem: LPVOID) -> BOOL;
|
||||||
|
pub fn LoadLibraryW(lpFileName: LPCWSTR) -> HMODULE;
|
||||||
|
pub fn ReplaceFileW(
|
||||||
|
lpReplacedFileName: LPCWSTR,
|
||||||
|
lpReplacementFileName: LPCWSTR,
|
||||||
|
lpBackupFileName: LPCWSTR,
|
||||||
|
dwReplaceFlags: DWORD,
|
||||||
|
lpExclude: LPVOID,
|
||||||
|
lpReserved: LPVOID,
|
||||||
|
) -> BOOL;
|
||||||
|
pub fn Sleep(dwMilliseconds: DWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
pub enum SelfUpdateError {
|
||||||
|
#[snafu(display("Could not get the file name of the currently running hook"))]
|
||||||
|
FailedToGetFilename { source: ReadStringFnError },
|
||||||
|
|
||||||
|
#[snafu(display("Invalid DOS signature"))]
|
||||||
|
InvalidDosSignature,
|
||||||
|
|
||||||
|
#[snafu(display("Invalid NT signature"))]
|
||||||
|
InvalidNtSignature,
|
||||||
|
|
||||||
|
#[snafu(display("Updater code section not found."))]
|
||||||
|
NoUpdaterCodeSection,
|
||||||
|
|
||||||
|
#[snafu(display("Failed to allocate memory for update"))]
|
||||||
|
FailedToAllocateMemory,
|
||||||
|
|
||||||
|
#[snafu(display("VirtualProtect failed with error code {errno}"))]
|
||||||
|
FailedVirtualProtect { errno: u32 },
|
||||||
|
|
||||||
|
#[snafu(display("Could not execute updater code: {errno}"))]
|
||||||
|
FailedCreateThread { errno: u32 },
|
||||||
|
|
||||||
|
#[snafu(display("Failed to request update information."))]
|
||||||
|
FailedRequestingUpdate { source: ureq::Error },
|
||||||
|
|
||||||
|
#[snafu(display("Invalid update information."))]
|
||||||
|
InvalidUpdateInformation { source: io::Error },
|
||||||
|
|
||||||
|
#[snafu(display("Could not download updated hook."))]
|
||||||
|
FailedDownloadingUpdate { source: io::Error },
|
||||||
|
|
||||||
|
#[snafu(display("Could not write updated hook to file."))]
|
||||||
|
FailedWritingUpdate { source: io::Error },
|
||||||
|
|
||||||
|
#[snafu(display("SHA-256 checksum mismatch."))]
|
||||||
|
InvalidChecksum,
|
||||||
|
|
||||||
|
#[snafu(display("Could not verify signature: {source:#}"))]
|
||||||
|
InvalidSignature { source: VerifySignatureError },
|
||||||
|
|
||||||
|
#[snafu(display("Failed to get digital signature of the update: {source:#?}"))]
|
||||||
|
FailedGettingPubkey { source: GetSignaturePubkeyError },
|
||||||
|
|
||||||
|
#[snafu(display("Public key mismatched."))]
|
||||||
|
InvalidPubkey,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Snafu, Debug)]
|
||||||
|
pub enum VerifySignatureError {
|
||||||
|
#[snafu(display("Signature verification was disabled by a local policy."))]
|
||||||
|
VerificationDisabledByPolicy,
|
||||||
|
|
||||||
|
#[snafu(display("No signatures found."))]
|
||||||
|
NoSignature,
|
||||||
|
|
||||||
|
#[snafu(display("The signature was explicitly distrusted."))]
|
||||||
|
ExplicitlyDistrusted,
|
||||||
|
|
||||||
|
#[snafu(display("An unknown validation error occured: {errno}"))]
|
||||||
|
Unknown { errno: i32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Snafu, Debug)]
|
||||||
|
pub enum GetSignaturePubkeyError {
|
||||||
|
#[snafu(display("CertQueryObject failed: {errno}"))]
|
||||||
|
QueryObjectError { errno: u32 },
|
||||||
|
|
||||||
|
#[snafu(display("Could not obtain size of signer information: {errno}"))]
|
||||||
|
SignerInfoSizeError { errno: u32 },
|
||||||
|
|
||||||
|
#[snafu(display("Could not allocate memory for signer information: {errno}"))]
|
||||||
|
SignerInfoAllocError { errno: u32 },
|
||||||
|
|
||||||
|
#[snafu(display("Could not obtain signer information: {errno}"))]
|
||||||
|
SignerInfoObtainError { errno: u32 },
|
||||||
|
|
||||||
|
#[snafu(display("Could not look up certificate in certificate store: {errno}"))]
|
||||||
|
CertificateInStoreError { errno: u32 },
|
||||||
|
|
||||||
|
#[snafu(display("Could not read public key."))]
|
||||||
|
ReadPubkeyError { source: io::Error },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
struct UpdateInformation {
|
||||||
|
pub version: String,
|
||||||
|
pub commit: String,
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the hook has a newer version. Returns true if update was successful
|
||||||
|
/// and the hook should uninject itself so a newer version can load in.
|
||||||
|
pub fn self_update(module: &LibraryHandle) -> Result<bool, SelfUpdateError> {
|
||||||
|
let agent = ureq::builder()
|
||||||
|
.user_agent(concat!("saekawa/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
info!("Checking for updates...");
|
||||||
|
let response = agent
|
||||||
|
.get("https://beerpiss.github.io/saekawa/update.json")
|
||||||
|
.call()
|
||||||
|
.context(FailedRequestingUpdateSnafu)?
|
||||||
|
.into_json::<UpdateInformation>()
|
||||||
|
.context(InvalidUpdateInformationSnafu)?;
|
||||||
|
|
||||||
|
if response.commit == env!("VERGEN_GIT_SHA") {
|
||||||
|
info!("Already up-to-date.");
|
||||||
|
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let module_filename =
|
||||||
|
&get_module_file_name(module.handle()).context(FailedToGetFilenameSnafu)?;
|
||||||
|
let module_path = Path::new(&module_filename);
|
||||||
|
|
||||||
|
debug!("Current hook is located at {module_filename:#?}.");
|
||||||
|
|
||||||
|
info!("Downloading update v{}...", response.version);
|
||||||
|
let url = format!(
|
||||||
|
"https://github.com/beerpiss/saekawa/releases/download/v{}/saekawa.dll",
|
||||||
|
response.version
|
||||||
|
);
|
||||||
|
|
||||||
|
debug!("Requesting content from {url}...");
|
||||||
|
let new_hook = {
|
||||||
|
let mut response = agent
|
||||||
|
.get(&url)
|
||||||
|
.call()
|
||||||
|
.context(FailedRequestingUpdateSnafu)?
|
||||||
|
.into_reader();
|
||||||
|
let mut buf = vec![];
|
||||||
|
response
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.context(FailedDownloadingUpdateSnafu)?;
|
||||||
|
|
||||||
|
buf
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("Validating update contents...");
|
||||||
|
validate_sha256(&new_hook, &response.sha256)?;
|
||||||
|
|
||||||
|
let new_module_path = module_path.with_file_name("saekawa.new.dll");
|
||||||
|
|
||||||
|
debug!("Writing update contents to {new_module_path:#?}...");
|
||||||
|
std::fs::write(&new_module_path, new_hook).context(FailedWritingUpdateSnafu)?;
|
||||||
|
|
||||||
|
debug!("Verifying digital signature...");
|
||||||
|
let new_module_filename = new_module_path.to_string_lossy();
|
||||||
|
verify_signature(&new_module_filename).context(InvalidSignatureSnafu)?;
|
||||||
|
|
||||||
|
debug!("Verifying certificate public key...");
|
||||||
|
let actual_pubkey = match get_signature_pubkey(&new_module_filename) {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_file(&new_module_path);
|
||||||
|
return Err(SelfUpdateError::FailedGettingPubkey { source: e });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if actual_pubkey != PUBLIC_KEY {
|
||||||
|
let _ = std::fs::remove_file(&new_module_path);
|
||||||
|
return Err(SelfUpdateError::InvalidPubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Starting update sequence");
|
||||||
|
// You know stuff is going to be cursed when the unsafe block is ~120 lines long.
|
||||||
|
//
|
||||||
|
// TL;DR: There's a function that waits until the current hook has been unloaded,
|
||||||
|
// then replaces the old hook with the new hook, and loads in the new hook.
|
||||||
|
//
|
||||||
|
// This is achieved by linking that function alongside the required functions in a different
|
||||||
|
// code section (".rtext"), setting references for those functions, then copying out
|
||||||
|
// that entire section to a different memory region so it can keep executing when the
|
||||||
|
// old hook is unloaded.
|
||||||
|
//
|
||||||
|
// Thanks to DJTRACKERS and their fervidex hook for the approach.
|
||||||
|
unsafe {
|
||||||
|
external::GET_LAST_ERROR_PTR = GetLastError as PROC;
|
||||||
|
external::GET_MODULE_FILE_NAME_A_PTR = GetModuleFileNameA as PROC;
|
||||||
|
external::GET_PROCESS_HEAP_PTR = GetProcessHeap as PROC;
|
||||||
|
external::HEAP_FREE_PTR = HeapFree as PROC;
|
||||||
|
external::LOAD_LIBRARY_W_POINTER = LoadLibraryW as PROC;
|
||||||
|
external::REPLCE_FILE_W_PTR = ReplaceFileW as PROC;
|
||||||
|
external::SLEEP_PTR = Sleep as PROC;
|
||||||
|
|
||||||
|
debug!("Locating updater code...");
|
||||||
|
let dos_header = module.handle() as *const IMAGE_DOS_HEADER;
|
||||||
|
|
||||||
|
if (*dos_header).e_magic != 0x5A4D {
|
||||||
|
return Err(SelfUpdateError::InvalidDosSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nt_header_address = module.handle().byte_offset((*dos_header).e_lfanew as isize);
|
||||||
|
let nt_header = nt_header_address as *const IMAGE_NT_HEADERS32;
|
||||||
|
|
||||||
|
if (*nt_header).Signature != 0x4550 {
|
||||||
|
return Err(SelfUpdateError::InvalidNtSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
let number_of_sections = (*nt_header).FileHeader.NumberOfSections;
|
||||||
|
|
||||||
|
if number_of_sections < 5 {
|
||||||
|
return Err(SelfUpdateError::NoUpdaterCodeSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
let section_header_offset = (&(*nt_header).OptionalHeader as *const _ as *const u8)
|
||||||
|
.byte_add((*nt_header).FileHeader.SizeOfOptionalHeader as usize)
|
||||||
|
as *const IMAGE_SECTION_HEADER;
|
||||||
|
|
||||||
|
for i in 0..number_of_sections {
|
||||||
|
let section_header = *section_header_offset.byte_add(40 * i as usize);
|
||||||
|
let section_name = CStr::from_bytes_until_nul(§ion_header.Name)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if section_name != ".rtext" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_addr = module
|
||||||
|
.handle()
|
||||||
|
.byte_add(section_header.VirtualAddress as usize)
|
||||||
|
as *mut u8;
|
||||||
|
let section_size = *section_header.Misc.VirtualSize() as usize;
|
||||||
|
|
||||||
|
let dst_addr = VirtualAlloc(
|
||||||
|
ptr::null_mut(),
|
||||||
|
section_size,
|
||||||
|
MEM_COMMIT | MEM_RESERVE,
|
||||||
|
PAGE_READWRITE,
|
||||||
|
) as *mut u8;
|
||||||
|
|
||||||
|
if dst_addr.is_null() {
|
||||||
|
return Err(SelfUpdateError::FailedToAllocateMemory);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Copying updater code section from {:p} to {:p}",
|
||||||
|
src_addr, dst_addr
|
||||||
|
);
|
||||||
|
std::ptr::copy_nonoverlapping(src_addr, dst_addr, section_size);
|
||||||
|
|
||||||
|
let updater_start_address = (replace_with_new_library as PROC)
|
||||||
|
.byte_add(dst_addr as usize)
|
||||||
|
.byte_sub(src_addr as usize);
|
||||||
|
|
||||||
|
debug!("Making updater code executable");
|
||||||
|
let mut old_protect = 0u32;
|
||||||
|
let result = VirtualProtect(
|
||||||
|
dst_addr as *mut _,
|
||||||
|
section_size,
|
||||||
|
PAGE_EXECUTE_READ,
|
||||||
|
&mut old_protect,
|
||||||
|
);
|
||||||
|
|
||||||
|
if result == 0 {
|
||||||
|
return Err(SelfUpdateError::FailedVirtualProtect {
|
||||||
|
errno: GetLastError(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let process_heap = GetProcessHeap();
|
||||||
|
let heap = HeapAlloc(
|
||||||
|
process_heap,
|
||||||
|
HEAP_ZERO_MEMORY,
|
||||||
|
mem::size_of::<ReplaceArgs>(),
|
||||||
|
) as *mut ReplaceArgs;
|
||||||
|
|
||||||
|
debug!("Allocated heap for updater code at {heap:p}");
|
||||||
|
|
||||||
|
(*heap).module = module.handle();
|
||||||
|
let old = U16CString::from_str_truncate(&module_filename);
|
||||||
|
let new = U16CString::from_str_truncate(&new_module_path.to_string_lossy());
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
old.as_ptr(),
|
||||||
|
(*heap).old.as_mut_ptr(),
|
||||||
|
old.as_slice().len(),
|
||||||
|
);
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
new.as_ptr(),
|
||||||
|
(*heap).new.as_mut_ptr(),
|
||||||
|
new.as_slice().len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
debug!("Executing updater code at {updater_start_address:p}");
|
||||||
|
let handle = CreateThread(
|
||||||
|
ptr::null_mut(),
|
||||||
|
0,
|
||||||
|
Some(std::mem::transmute(updater_start_address)),
|
||||||
|
heap as *mut _,
|
||||||
|
0,
|
||||||
|
ptr::null_mut(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if handle.is_null() {
|
||||||
|
error!("Could not execute updater code: {}", GetLastError());
|
||||||
|
return Err(SelfUpdateError::FailedCreateThread {
|
||||||
|
errno: GetLastError(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(SelfUpdateError::NoUpdaterCodeSection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_sha256(data: &[u8], expected: &str) -> Result<(), SelfUpdateError> {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
|
||||||
|
hasher.update(data);
|
||||||
|
|
||||||
|
let hash = hasher.finalize();
|
||||||
|
let hash_string = faster_hex::hex_string(&hash[..]);
|
||||||
|
|
||||||
|
debug!("Expected checksum: {}", expected);
|
||||||
|
debug!("Actual checksum: {}", hash_string);
|
||||||
|
|
||||||
|
if hash_string != expected {
|
||||||
|
return Err(SelfUpdateError::InvalidChecksum);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_signature(file: &str) -> Result<(), VerifySignatureError> {
|
||||||
|
let file_osstr = U16CString::from_str_truncate(file);
|
||||||
|
let mut verification_type = WINTRUST_ACTION_GENERIC_VERIFY_V2;
|
||||||
|
let mut wintrust_data_buf = vec![0u8; mem::size_of::<WINTRUST_DATA>()];
|
||||||
|
let mut fileinfo_buf = vec![0u8; mem::size_of::<WINTRUST_FILE_INFO>()];
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let fileinfo = fileinfo_buf.as_mut_ptr() as *mut WINTRUST_FILE_INFO;
|
||||||
|
let wintrust_data = wintrust_data_buf.as_mut_ptr() as *mut WINTRUST_DATA;
|
||||||
|
|
||||||
|
(*fileinfo).cbStruct = mem::size_of::<WINTRUST_FILE_INFO>() as u32;
|
||||||
|
(*fileinfo).pcwszFilePath = file_osstr.as_ptr();
|
||||||
|
(*fileinfo).hFile = ptr::null_mut();
|
||||||
|
(*fileinfo).pgKnownSubject = ptr::null_mut();
|
||||||
|
|
||||||
|
(*wintrust_data).pPolicyCallbackData = ptr::null_mut();
|
||||||
|
(*wintrust_data).pSIPClientData = ptr::null_mut();
|
||||||
|
(*wintrust_data).cbStruct = mem::size_of::<WINTRUST_DATA>() as u32;
|
||||||
|
(*wintrust_data).dwStateAction = WTD_STATEACTION_VERIFY;
|
||||||
|
(*wintrust_data).dwUIChoice = WTD_UI_NONE;
|
||||||
|
(*wintrust_data).fdwRevocationChecks = WTD_REVOKE_NONE;
|
||||||
|
(*wintrust_data).dwUnionChoice = WTD_CHOICE_FILE;
|
||||||
|
(*wintrust_data).hWVTStateData = ptr::null_mut();
|
||||||
|
(*wintrust_data).pwszURLReference = ptr::null_mut();
|
||||||
|
(*wintrust_data).dwUIContext = 0;
|
||||||
|
*(*wintrust_data).u.pFile_mut() = fileinfo;
|
||||||
|
|
||||||
|
let status = WinVerifyTrust(
|
||||||
|
ptr::null_mut(),
|
||||||
|
&mut verification_type,
|
||||||
|
wintrust_data as *mut _,
|
||||||
|
);
|
||||||
|
|
||||||
|
match status {
|
||||||
|
0 | CERT_E_UNTRUSTEDROOT | CERT_E_EXPIRED | CERT_E_CHAINING => Ok(()),
|
||||||
|
CRYPT_E_SECURITY_SETTINGS => Err(VerifySignatureError::VerificationDisabledByPolicy),
|
||||||
|
TRUST_E_NOSIGNATURE => Err(VerifySignatureError::NoSignature),
|
||||||
|
TRUST_E_EXPLICIT_DISTRUST => Err(VerifySignatureError::ExplicitlyDistrusted),
|
||||||
|
_ => Err(VerifySignatureError::Unknown { errno: status }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_signature_pubkey(file: &str) -> Result<Vec<u8>, GetSignaturePubkeyError> {
|
||||||
|
debug!("Getting public key of {file}.");
|
||||||
|
|
||||||
|
let file_osstr = U16CString::from_str_truncate(file);
|
||||||
|
let mut cert_store: HCERTSTORE = ptr::null_mut();
|
||||||
|
let mut crypt_msg: HCRYPTMSG = ptr::null_mut();
|
||||||
|
let result = unsafe {
|
||||||
|
CryptQueryObject(
|
||||||
|
CERT_QUERY_OBJECT_FILE,
|
||||||
|
file_osstr.as_ptr() as *const _,
|
||||||
|
CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED,
|
||||||
|
CERT_QUERY_FORMAT_FLAG_BINARY,
|
||||||
|
0,
|
||||||
|
ptr::null_mut(),
|
||||||
|
ptr::null_mut(),
|
||||||
|
ptr::null_mut(),
|
||||||
|
&mut cert_store,
|
||||||
|
&mut crypt_msg,
|
||||||
|
ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if result == 0 {
|
||||||
|
return Err(GetSignaturePubkeyError::QueryObjectError {
|
||||||
|
errno: unsafe { GetLastError() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut signer_info_length = 0;
|
||||||
|
let result = unsafe {
|
||||||
|
CryptMsgGetParam(
|
||||||
|
crypt_msg,
|
||||||
|
CMSG_SIGNER_INFO_PARAM,
|
||||||
|
0,
|
||||||
|
ptr::null_mut(),
|
||||||
|
&mut signer_info_length,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if result == 0 {
|
||||||
|
return Err(GetSignaturePubkeyError::SignerInfoSizeError {
|
||||||
|
errno: unsafe { GetLastError() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let signer_info =
|
||||||
|
unsafe { LocalAlloc(LMEM_ZEROINIT, signer_info_length as usize) } as PCMSG_SIGNER_INFO;
|
||||||
|
|
||||||
|
if signer_info.is_null() {
|
||||||
|
return Err(GetSignaturePubkeyError::SignerInfoAllocError {
|
||||||
|
errno: unsafe { GetLastError() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = unsafe {
|
||||||
|
CryptMsgGetParam(
|
||||||
|
crypt_msg,
|
||||||
|
CMSG_SIGNER_INFO_PARAM,
|
||||||
|
0,
|
||||||
|
signer_info as *mut _,
|
||||||
|
&mut signer_info_length,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if result == 0 {
|
||||||
|
return Err(GetSignaturePubkeyError::SignerInfoObtainError {
|
||||||
|
errno: unsafe { GetLastError() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cert_search_params_buf = vec![0u8; mem::size_of::<CERT_INFO>()];
|
||||||
|
let cert_search_params = cert_search_params_buf.as_mut_ptr() as *mut CERT_INFO;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
(*cert_search_params).Issuer = (*signer_info).Issuer;
|
||||||
|
(*cert_search_params).SerialNumber = (*signer_info).SerialNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cert = unsafe {
|
||||||
|
CertFindCertificateInStore(
|
||||||
|
cert_store,
|
||||||
|
X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
|
||||||
|
0,
|
||||||
|
CERT_FIND_SUBJECT_CERT,
|
||||||
|
cert_search_params as *const _,
|
||||||
|
ptr::null(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if cert.is_null() {
|
||||||
|
return Err(GetSignaturePubkeyError::CertificateInStoreError {
|
||||||
|
errno: unsafe { GetLastError() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let cert_info = (*cert).pCertInfo;
|
||||||
|
let cbb = (*cert_info).SubjectPublicKeyInfo.PublicKey;
|
||||||
|
let public_key_length = cbb.cbData;
|
||||||
|
let public_key = cbb.pbData;
|
||||||
|
let mut public_key =
|
||||||
|
std::slice::from_raw_parts(public_key as *const _, public_key_length as _);
|
||||||
|
let mut pubkey_vec = Vec::with_capacity(public_key_length as _);
|
||||||
|
|
||||||
|
public_key
|
||||||
|
.read_to_end(&mut pubkey_vec)
|
||||||
|
.context(ReadPubkeySnafu)?;
|
||||||
|
|
||||||
|
CertCloseStore(cert_store, 0);
|
||||||
|
CryptMsgClose(crypt_msg);
|
||||||
|
|
||||||
|
Ok(pubkey_vec)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user