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:
beerpsi 2023-11-16 03:37:42 +07:00 committed by GitHub
parent 183995a002
commit 4ce49973a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1345 additions and 40 deletions

4
.gitignore vendored
View File

@ -7,10 +7,6 @@
debug/
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
**/*.rs.bk

1141
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "saekawa"
version = "0.1.0"
version = "0.2.0"
authors = ["beerpsi <lacvtg.a1.2023@gmail.com>"]
edition = "2021"
license = "0BSD"
@ -16,19 +16,24 @@ codegen-units = 1
panic = "abort"
[dependencies]
winapi = { version = "0.3.9", features = ["winhttp", "minwindef", "consoleapi"] }
serde = { version = "1.0.192", features = ["derive"] }
serde_json = "1.0.108"
aes = "0.8.3"
anyhow = "1.0.75"
cbc = "0.1.2"
chrono = "0.4.31"
confy = "0.5.1"
env_logger = "0.10.1"
faster-hex = "0.8.1"
flate2 = "1.0.28"
lazy_static = "1.4.0"
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"
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_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"] }

View File

@ -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
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
- Adam Thibert ([adamaq01](https://github.com/adamaq01)). A lot of the code was shamelessly lifted from his
[Mikado](https://github.com/adamaq01/Mikado), a similar hook for SDVX.
- Adam Thibert ([adamaq01](https://github.com/adamaq01)). A lot of the code was
shamelessly lifted from his [Mikado](https://github.com/adamaq01/Mikado), a similar
hook for SDVX.
### License
0BSD

View File

@ -15,6 +15,19 @@ timeout = 3000
# example: whitelist = ["00001111222233334444"]
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 instance base URL
base_url = 'https://kamaitachi.xyz/'

View File

@ -8,6 +8,7 @@ use std::path::Path;
pub struct Configuration {
pub general: GeneralConfiguration,
pub cards: CardsConfiguration,
pub crypto: CryptoConfiguration,
pub tachi: TachiConfiguration,
}
@ -57,6 +58,25 @@ pub struct CardsConfiguration {
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)]
pub struct TachiConfiguration {
pub base_url: String,

View File

@ -1,5 +1,6 @@
use std::{fmt::Debug, io::Read};
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
use anyhow::{anyhow, Result};
use flate2::read::ZlibDecoder;
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 {
let timeout = CONFIGURATION.general.timeout;
@ -79,6 +82,7 @@ where
Ok(response)
}
/// 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];
@ -122,29 +126,84 @@ pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
}
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 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(slice);
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 = slice.read_to_string(&mut ret);
let result = buf.as_ref().read_to_string(&mut ret);
if result.is_ok() {
return Ok(ret);
}
// Unwrapping here is fine, if result was Ok we wouldn't reach this place.
Err(anyhow!(
"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."),
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.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())
}

View File

@ -4,8 +4,10 @@ mod log;
mod saekawa;
mod types;
use ::log::error;
use ::log::{debug, error};
use lazy_static::lazy_static;
use pbkdf2::pbkdf2_hmac_array;
use sha1::Sha1;
use url::Url;
use winapi::shared::minwindef::{BOOL, DWORD, HINSTANCE, LPVOID, TRUE};
use winapi::um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH};
@ -44,6 +46,23 @@ lazy_static! {
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() {

View File

@ -1,4 +1,4 @@
use std::{ffi::CString, ptr};
use std::ffi::CString;
use ::log::{debug, error, info};
use anyhow::{anyhow, Result};
@ -14,12 +14,15 @@ use winapi::{
};
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::{
game::UpsertUserAllRequest,
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;
@ -103,19 +106,66 @@ fn winhttpwritedata_hook(
};
debug!("winhttpwritedata URL: {url}");
let request_body = match read_potentially_deflated_buffer(
lp_buffer as *const u8,
dw_number_of_bytes_to_write as usize,
) {
let Some(endpoint) = url.split('/').last() else {
error!("Could not get name of endpoint");
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,
Err(err) => {
error!("There was an error reading the request body: {:#}", err);
error!("There was an error decoding the request body: {:#}", err);
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();
}
@ -197,13 +247,13 @@ fn get_proc_address(module: &str, function: &str) -> Result<*mut __some_function
let fun_name = CString::new(function).unwrap();
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() };
return Err(anyhow!("could not get module handle, error code {ec}"));
}
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() };
return Err(anyhow!("could not get function address, error code {ec}"));
}

View File

@ -76,7 +76,7 @@ pub struct UserPlaylog {
pub is_clear: bool,
}
fn default_judge_heaven<'a>() -> u32 {
fn default_judge_heaven() -> u32 {
0
}