mirror of
https://github.com/beerpiss/saekawa.git
synced 2024-11-28 01:10:53 +01:00
feat: Allow requests to title server to stay encrypted (#1)
* initial encryption work still need to figure out how to decrypt the requests. i don't even have an idea how long the key is. * feat: encryption support
This commit is contained in:
parent
183995a002
commit
4ce49973a5
4
.gitignore
vendored
4
.gitignore
vendored
@ -7,10 +7,6 @@
|
|||||||
debug/
|
debug/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
|
||||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
|
||||||
Cargo.lock
|
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
# These are backup files generated by rustfmt
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
|
1141
Cargo.lock
generated
Normal file
1141
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
Cargo.toml
27
Cargo.toml
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "saekawa"
|
name = "saekawa"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
authors = ["beerpsi <lacvtg.a1.2023@gmail.com>"]
|
authors = ["beerpsi <lacvtg.a1.2023@gmail.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "0BSD"
|
license = "0BSD"
|
||||||
@ -16,19 +16,24 @@ codegen-units = 1
|
|||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
winapi = { version = "0.3.9", features = ["winhttp", "minwindef", "consoleapi"] }
|
aes = "0.8.3"
|
||||||
serde = { version = "1.0.192", features = ["derive"] }
|
|
||||||
serde_json = "1.0.108"
|
|
||||||
anyhow = "1.0.75"
|
anyhow = "1.0.75"
|
||||||
|
cbc = "0.1.2"
|
||||||
|
chrono = "0.4.31"
|
||||||
confy = "0.5.1"
|
confy = "0.5.1"
|
||||||
|
env_logger = "0.10.1"
|
||||||
|
faster-hex = "0.8.1"
|
||||||
|
flate2 = "1.0.28"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
log = "0.4.20"
|
log = "0.4.20"
|
||||||
url = "2.4.1"
|
|
||||||
chrono = "0.4.31"
|
|
||||||
env_logger = "0.10.1"
|
|
||||||
retour = { version = "0.3.1", features = ["static-detour"] }
|
|
||||||
widestring = "1.0.2"
|
|
||||||
flate2 = "1.0.28"
|
|
||||||
ureq = { version = "2.8.0", features = ["json"] }
|
|
||||||
num_enum = "0.7.1"
|
num_enum = "0.7.1"
|
||||||
|
pbkdf2 = "0.12.2"
|
||||||
|
retour = { version = "0.3.1", features = ["static-detour"] }
|
||||||
|
serde = { version = "1.0.192", features = ["derive"] }
|
||||||
serde-aux = "4.2.0"
|
serde-aux = "4.2.0"
|
||||||
|
serde_json = "1.0.108"
|
||||||
|
sha1 = "0.10.6"
|
||||||
|
ureq = { version = "2.8.0", features = ["json"] }
|
||||||
|
url = "2.4.1"
|
||||||
|
widestring = "1.0.2"
|
||||||
|
winapi = { version = "0.3.9", features = ["winhttp", "minwindef", "consoleapi"] }
|
||||||
|
@ -20,11 +20,13 @@ edit your segatools game.bat to look like the green line:
|
|||||||
This hook requires the game's network communications to be decrypted, which can be done
|
This hook requires the game's network communications to be decrypted, which can be done
|
||||||
by patching the game binary with your preferred patcher.
|
by patching the game binary with your preferred patcher.
|
||||||
|
|
||||||
Support for encrypted requests might come soon.
|
If you choose not to patch, you will need to obtain the necessary keys and provide them
|
||||||
|
in `saekawa.toml`.
|
||||||
|
|
||||||
### Credits
|
### Credits
|
||||||
- Adam Thibert ([adamaq01](https://github.com/adamaq01)). A lot of the code was shamelessly lifted from his
|
- Adam Thibert ([adamaq01](https://github.com/adamaq01)). A lot of the code was
|
||||||
[Mikado](https://github.com/adamaq01/Mikado), a similar hook for SDVX.
|
shamelessly lifted from his [Mikado](https://github.com/adamaq01/Mikado), a similar
|
||||||
|
hook for SDVX.
|
||||||
|
|
||||||
### License
|
### License
|
||||||
0BSD
|
0BSD
|
@ -15,6 +15,19 @@ timeout = 3000
|
|||||||
# example: whitelist = ["00001111222233334444"]
|
# example: whitelist = ["00001111222233334444"]
|
||||||
whitelist = []
|
whitelist = []
|
||||||
|
|
||||||
|
[crypto]
|
||||||
|
# Since CRYSTAL+, the game employs network encryption. If you do not wish to
|
||||||
|
# patch your game to disable encryption, you will need to fill in these values.
|
||||||
|
#
|
||||||
|
# All values are in hex strings.
|
||||||
|
key = ""
|
||||||
|
iv = ""
|
||||||
|
salt = ""
|
||||||
|
|
||||||
|
# Number of PBKDF2 iterations to hash the endpoint URL. Set to 70 for CHUNITHM SUN
|
||||||
|
# and newer, and 44 for older versions.
|
||||||
|
iterations = 70
|
||||||
|
|
||||||
[tachi]
|
[tachi]
|
||||||
# Tachi instance base URL
|
# Tachi instance base URL
|
||||||
base_url = 'https://kamaitachi.xyz/'
|
base_url = 'https://kamaitachi.xyz/'
|
||||||
|
@ -8,6 +8,7 @@ use std::path::Path;
|
|||||||
pub struct Configuration {
|
pub struct Configuration {
|
||||||
pub general: GeneralConfiguration,
|
pub general: GeneralConfiguration,
|
||||||
pub cards: CardsConfiguration,
|
pub cards: CardsConfiguration,
|
||||||
|
pub crypto: CryptoConfiguration,
|
||||||
pub tachi: TachiConfiguration,
|
pub tachi: TachiConfiguration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +58,25 @@ pub struct CardsConfiguration {
|
|||||||
pub whitelist: Vec<String>,
|
pub whitelist: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct CryptoConfiguration {
|
||||||
|
#[serde(with = "faster_hex")]
|
||||||
|
pub key: Vec<u8>,
|
||||||
|
|
||||||
|
#[serde(with = "faster_hex")]
|
||||||
|
pub iv: Vec<u8>,
|
||||||
|
|
||||||
|
#[serde(with = "faster_hex")]
|
||||||
|
pub salt: Vec<u8>,
|
||||||
|
|
||||||
|
#[serde(default = "default_iterations")]
|
||||||
|
pub iterations: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_iterations() -> u32 {
|
||||||
|
70
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct TachiConfiguration {
|
pub struct TachiConfiguration {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
use std::{fmt::Debug, io::Read};
|
use std::{fmt::Debug, io::Read};
|
||||||
|
|
||||||
|
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use flate2::read::ZlibDecoder;
|
use flate2::read::ZlibDecoder;
|
||||||
use log::debug;
|
use log::debug;
|
||||||
@ -14,7 +15,9 @@ use winapi::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::CONFIGURATION;
|
use crate::{CONFIGURATION, UPSERT_USER_ALL_API_ENCRYPTED};
|
||||||
|
|
||||||
|
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
|
||||||
|
|
||||||
pub fn request_agent() -> ureq::Agent {
|
pub fn request_agent() -> ureq::Agent {
|
||||||
let timeout = CONFIGURATION.general.timeout;
|
let timeout = CONFIGURATION.general.timeout;
|
||||||
@ -79,6 +82,7 @@ where
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Queries a HINTERNET handle for its URL, then return the result.
|
||||||
pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
|
pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
|
||||||
let mut buf_length = 255;
|
let mut buf_length = 255;
|
||||||
let mut buffer = [0u16; 255];
|
let mut buffer = [0u16; 255];
|
||||||
@ -122,29 +126,84 @@ pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ec = unsafe { GetLastError() };
|
let ec = unsafe { GetLastError() };
|
||||||
return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"));
|
Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_potentially_deflated_buffer(buf: *const u8, len: usize) -> Result<String> {
|
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 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 ret = String::new();
|
||||||
|
|
||||||
let mut decoder = ZlibDecoder::new(slice);
|
let mut decoder = ZlibDecoder::new(buf.as_ref());
|
||||||
let zlib_result = decoder.read_to_string(&mut ret);
|
let zlib_result = decoder.read_to_string(&mut ret);
|
||||||
if zlib_result.is_ok() {
|
if zlib_result.is_ok() {
|
||||||
return Ok(ret);
|
return Ok(ret);
|
||||||
}
|
}
|
||||||
|
|
||||||
ret.clear();
|
ret.clear();
|
||||||
let result = slice.read_to_string(&mut ret);
|
let result = buf.as_ref().read_to_string(&mut ret);
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
return Ok(ret);
|
return Ok(ret);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrapping here is fine, if result was Ok we wouldn't reach this place.
|
|
||||||
Err(anyhow!(
|
Err(anyhow!(
|
||||||
"Could not decode contents of buffer as both DEFLATE-compressed ({:#}) and plaintext ({:#}) UTF-8 string.",
|
"Could not decode contents of buffer as both DEFLATE-compressed ({:#}) and plaintext ({:#}) UTF-8 string.",
|
||||||
zlib_result.err().expect("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
|
zlib_result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
|
||||||
result.err().expect("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."),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determine if we're looking at the UpsertUserAllApi endpoint,
|
||||||
|
/// which is the endpoint that contains our scores.
|
||||||
|
pub fn is_upsert_user_all_endpoint(endpoint: &str) -> bool {
|
||||||
|
if endpoint == "UpsertUserAllApi" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if UPSERT_USER_ALL_API_ENCRYPTED
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|v| v == endpoint)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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())
|
||||||
|
}
|
||||||
|
21
src/lib.rs
21
src/lib.rs
@ -4,8 +4,10 @@ mod log;
|
|||||||
mod saekawa;
|
mod saekawa;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
use ::log::error;
|
use ::log::{debug, error};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
use pbkdf2::pbkdf2_hmac_array;
|
||||||
|
use sha1::Sha1;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use winapi::shared::minwindef::{BOOL, DWORD, HINSTANCE, LPVOID, TRUE};
|
use winapi::shared::minwindef::{BOOL, DWORD, HINSTANCE, LPVOID, TRUE};
|
||||||
use winapi::um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH};
|
use winapi::um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH};
|
||||||
@ -44,6 +46,23 @@ lazy_static! {
|
|||||||
|
|
||||||
result.unwrap().to_string()
|
result.unwrap().to_string()
|
||||||
};
|
};
|
||||||
|
pub static ref UPSERT_USER_ALL_API_ENCRYPTED: Option<String> = {
|
||||||
|
if CONFIGURATION.crypto.salt.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_bytes = pbkdf2_hmac_array::<Sha1, 16>(
|
||||||
|
b"UpsertUserAllApi",
|
||||||
|
&CONFIGURATION.crypto.salt,
|
||||||
|
CONFIGURATION.crypto.iterations,
|
||||||
|
);
|
||||||
|
|
||||||
|
let key = faster_hex::hex_string(&key_bytes);
|
||||||
|
|
||||||
|
debug!("Running with encryption support: UpsertUserAllApi maps to {key}");
|
||||||
|
|
||||||
|
Some(key)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_logger() {
|
fn init_logger() {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use std::{ffi::CString, ptr};
|
use std::ffi::CString;
|
||||||
|
|
||||||
use ::log::{debug, error, info};
|
use ::log::{debug, error, info};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
@ -14,12 +14,15 @@ use winapi::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
helpers::{call_tachi, read_hinternet_url, read_potentially_deflated_buffer, request_tachi},
|
helpers::{
|
||||||
|
call_tachi, decrypt_aes256_cbc, is_encrypted_endpoint, is_upsert_user_all_endpoint,
|
||||||
|
read_hinternet_url, read_maybe_compressed_buffer, read_slice, request_tachi,
|
||||||
|
},
|
||||||
types::{
|
types::{
|
||||||
game::UpsertUserAllRequest,
|
game::UpsertUserAllRequest,
|
||||||
tachi::{ClassEmblem, Difficulty, Import, ImportClasses, ImportScore},
|
tachi::{ClassEmblem, Difficulty, Import, ImportClasses, ImportScore},
|
||||||
},
|
},
|
||||||
CONFIGURATION, TACHI_IMPORT_URL, TACHI_STATUS_URL,
|
CONFIGURATION, TACHI_IMPORT_URL, TACHI_STATUS_URL, UPSERT_USER_ALL_API_ENCRYPTED,
|
||||||
};
|
};
|
||||||
|
|
||||||
type WinHttpWriteDataFunc = unsafe extern "system" fn(HINTERNET, LPCVOID, DWORD, LPDWORD) -> BOOL;
|
type WinHttpWriteDataFunc = unsafe extern "system" fn(HINTERNET, LPCVOID, DWORD, LPDWORD) -> BOOL;
|
||||||
@ -103,19 +106,66 @@ fn winhttpwritedata_hook(
|
|||||||
};
|
};
|
||||||
debug!("winhttpwritedata URL: {url}");
|
debug!("winhttpwritedata URL: {url}");
|
||||||
|
|
||||||
let request_body = match read_potentially_deflated_buffer(
|
let Some(endpoint) = url.split('/').last() else {
|
||||||
lp_buffer as *const u8,
|
error!("Could not get name of endpoint");
|
||||||
dw_number_of_bytes_to_write as usize,
|
return orig();
|
||||||
) {
|
};
|
||||||
|
|
||||||
|
let is_upsert_user_all = is_upsert_user_all_endpoint(endpoint);
|
||||||
|
// Exit early if release mode and the endpoint is not what we're looking for
|
||||||
|
if cfg!(not(debug_assertions)) && !is_upsert_user_all {
|
||||||
|
return orig();
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_encrypted = is_encrypted_endpoint(endpoint);
|
||||||
|
if is_encrypted
|
||||||
|
&& (CONFIGURATION.crypto.key.is_empty()
|
||||||
|
|| CONFIGURATION.crypto.iv.is_empty()
|
||||||
|
|| UPSERT_USER_ALL_API_ENCRYPTED.is_none())
|
||||||
|
{
|
||||||
|
error!("Communications with the server is encrypted, but no keys were provided. Fill in the keys by editing 'saekawa.toml'.");
|
||||||
|
return orig();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut raw_request_body =
|
||||||
|
match read_slice(lp_buffer as *const u8, dw_number_of_bytes_to_write as usize) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(err) => {
|
||||||
|
error!("There was an error reading the request body: {:#}", err);
|
||||||
|
return orig();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("raw request body: {:?}", raw_request_body);
|
||||||
|
|
||||||
|
let request_body_compressed = if is_encrypted {
|
||||||
|
match decrypt_aes256_cbc(
|
||||||
|
&mut raw_request_body,
|
||||||
|
&CONFIGURATION.crypto.key,
|
||||||
|
&CONFIGURATION.crypto.iv,
|
||||||
|
) {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(err) => {
|
||||||
|
error!("Could not decrypt request: {:#}", err);
|
||||||
|
return orig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raw_request_body
|
||||||
|
};
|
||||||
|
|
||||||
|
let request_body = match read_maybe_compressed_buffer(&request_body_compressed[..]) {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("There was an error reading the request body: {:#}", err);
|
error!("There was an error decoding the request body: {:#}", err);
|
||||||
return orig();
|
return orig();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
debug!("winhttpwritedata request body: {request_body}");
|
|
||||||
|
|
||||||
if !url.contains("UpsertUserAllApi") {
|
debug!("decoded request body: {request_body}");
|
||||||
|
|
||||||
|
// Reached in debug mode
|
||||||
|
if !is_upsert_user_all {
|
||||||
return orig();
|
return orig();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,13 +247,13 @@ fn get_proc_address(module: &str, function: &str) -> Result<*mut __some_function
|
|||||||
let fun_name = CString::new(function).unwrap();
|
let fun_name = CString::new(function).unwrap();
|
||||||
|
|
||||||
let module = unsafe { GetModuleHandleA(module_name.as_ptr()) };
|
let module = unsafe { GetModuleHandleA(module_name.as_ptr()) };
|
||||||
if (module as *const c_void) == ptr::null() {
|
if (module as *const c_void).is_null() {
|
||||||
let ec = unsafe { GetLastError() };
|
let ec = unsafe { GetLastError() };
|
||||||
return Err(anyhow!("could not get module handle, error code {ec}"));
|
return Err(anyhow!("could not get module handle, error code {ec}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let addr = unsafe { GetProcAddress(module, fun_name.as_ptr()) };
|
let addr = unsafe { GetProcAddress(module, fun_name.as_ptr()) };
|
||||||
if (addr as *const c_void) == ptr::null() {
|
if (addr as *const c_void).is_null() {
|
||||||
let ec = unsafe { GetLastError() };
|
let ec = unsafe { GetLastError() };
|
||||||
return Err(anyhow!("could not get function address, error code {ec}"));
|
return Err(anyhow!("could not get function address, error code {ec}"));
|
||||||
}
|
}
|
||||||
|
@ -76,7 +76,7 @@ pub struct UserPlaylog {
|
|||||||
pub is_clear: bool,
|
pub is_clear: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_judge_heaven<'a>() -> u32 {
|
fn default_judge_heaven() -> u32 {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user