saekawa 0.4.0: rewrite boogaloo

This commit is contained in:
beerpiss 2024-06-26 23:08:21 +07:00
parent 3a9fd14c20
commit d352266759
41 changed files with 3033 additions and 2012 deletions

View File

@ -1,2 +0,0 @@
[build]
target = "i686-pc-windows-msvc"

2
.gitignore vendored
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
View 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
View 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
View 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
View 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(),
}
}
}

View File

@ -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,
}

View File

@ -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);
}
}

View 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"),
})
}

View File

@ -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())
}

View File

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

View File

@ -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}"
))
}

View File

@ -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."),
))
}

View File

@ -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::*;

View File

@ -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
View 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 },
)
}

View File

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

View File

@ -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;
} }
} }
_ => {} _ => {}

View File

@ -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
View 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();
}

View File

@ -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
View 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
View 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
View 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
View 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,
}

View File

@ -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,
}

View File

@ -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()
}
}
}

View File

@ -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
View 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)
}
}
}

View 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 },
}

View 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,
}

View 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(),
}
}
}

View 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>,
}

View 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,
}

View 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
View 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
View 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
View 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(&section_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)
}
}