feat: massive feature pack

* support for salt-less encrypted configs on paradise lost and older
* json serializing to concrete types
* poll imports for status updates
* fire off imports in a separate thread
This commit is contained in:
beerpiss 2023-11-16 21:55:50 +07:00
parent b989dd0ea1
commit 18a60e1811
4 changed files with 422 additions and 137 deletions

View File

@ -17,11 +17,18 @@ 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.
# patch out encryption from your game, you will need to fill in these values.
#
# All values are in hex strings.
key = ""
iv = ""
# Salt for deriving hashed API endpoint names. Not necessary on PARADISE LOST
# and older. For CHUNITHM NEW and later versions, this must be set if encryption
# is not patched out, otherwise the hook will be active but doing nothing, since
# it cannot recognize the score upload endpoint.
#
# This must also be a hex string.
salt = ""
# Number of PBKDF2 iterations to hash the endpoint URL. Set to 70 for CHUNITHM SUN

View File

@ -1,9 +1,9 @@
use std::{fmt::Debug, io::Read};
use std::{fmt::Debug, io::Read, ptr};
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
use anyhow::{anyhow, Result};
use flate2::read::ZlibDecoder;
use log::debug;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use widestring::U16CString;
use winapi::{
@ -11,11 +11,17 @@ use winapi::{
shared::{minwindef::TRUE, winerror::ERROR_INSUFFICIENT_BUFFER},
um::{
errhandlingapi::GetLastError,
winhttp::{WinHttpQueryOption, HINTERNET, WINHTTP_OPTION_URL},
winhttp::{
WinHttpQueryHeaders, WinHttpQueryOption, HINTERNET, WINHTTP_OPTION_URL,
WINHTTP_QUERY_FLAG_REQUEST_HEADERS, WINHTTP_QUERY_USER_AGENT,
},
},
};
use crate::{CONFIGURATION, UPSERT_USER_ALL_API_ENCRYPTED};
use crate::{
types::tachi::{Import, ImportDocument, ImportPollStatus, TachiResponse, ImportResponse},
CONFIGURATION, TACHI_IMPORT_URL, UPSERT_USER_ALL_API_ENCRYPTED,
};
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
@ -55,17 +61,6 @@ where
Ok(response)
}
pub fn call_tachi<T>(method: impl AsRef<str>, url: impl AsRef<str>, body: Option<T>) -> Result<()>
where
T: Serialize + Debug,
{
let response = request(method, url, body)?;
let response: serde_json::Value = response.into_json()?;
debug!("Tachi API response: {:#?}", response);
Ok(())
}
pub fn request_tachi<T, R>(
method: impl AsRef<str>,
url: impl AsRef<str>,
@ -129,6 +124,58 @@ pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
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}"
))
}
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);
@ -204,6 +251,84 @@ pub fn decrypt_aes256_cbc(
let cipher = Aes256CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?;
Ok(cipher
.decrypt_padded_mut::<Pkcs7>(body)
.map_err(|err| anyhow!("{:#}", err))?
.map_err(|err| anyhow!(err))?
.to_owned())
}
pub 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(())
}

View File

@ -15,14 +15,17 @@ use winapi::{
use crate::{
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,
decrypt_aes256_cbc, execute_tachi_import, is_encrypted_endpoint,
is_upsert_user_all_endpoint, read_hinternet_url, read_hinternet_user_agent,
read_maybe_compressed_buffer, read_slice, request_tachi,
},
types::{
game::UpsertUserAllRequest,
tachi::{ClassEmblem, Difficulty, Import, ImportClasses, ImportScore},
tachi::{
ClassEmblem, Difficulty, Import, ImportClasses, ImportScore, StatusCheck, TachiResponse,
},
},
CONFIGURATION, TACHI_IMPORT_URL, TACHI_STATUS_URL, UPSERT_USER_ALL_API_ENCRYPTED,
CONFIGURATION, TACHI_STATUS_URL,
};
type WinHttpWriteDataFunc = unsafe extern "system" fn(HINTERNET, LPCVOID, DWORD, LPDWORD) -> BOOL;
@ -36,22 +39,28 @@ pub fn hook_init() -> Result<()> {
return Ok(());
}
let resp: serde_json::Value = request_tachi("GET", TACHI_STATUS_URL.as_str(), None::<()>)?;
let user_id = resp["body"]["whoami"]
.as_u64()
.ok_or(anyhow::anyhow!("Couldn't parse user from Tachi response"))?;
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 permission. The permission submit_score must be set."
));
}
let mut permissions = resp["body"]["permissions"]
.as_array()
.ok_or(anyhow!("Couldn't parse permissions from Tachi response"))?
.into_iter()
.filter_map(|v| v.as_str());
let Some(user_id) = resp.body.whoami else {
return Err(anyhow!(
"Status check was successful, yet API returned userID null?"
));
};
if permissions.all(|v| v != "submit_score") {
return Err(anyhow!(
"API key has insufficient permission. The permission submit_score must be set."
));
}
user_id
}
};
info!("Logged in to Tachi with userID {user_id}");
@ -106,26 +115,52 @@ fn winhttpwritedata_hook(
return orig();
}
};
debug!("winhttpwritedata URL: {url}");
let user_agent = match read_hinternet_user_agent(h_request) {
Ok(ua) => ua,
Err(err) => {
error!(
"There was an error reading the request's User-Agent: {:#}",
err
);
return orig();
}
};
debug!("request from user-agent {user_agent} with URL: {url}");
let Some(endpoint) = url.split('/').last() else {
// Quick explanation for this rodeo:
//
// Since CRYSTAL+, requests are encrypted with a hardcoded key/IV pair, and endpoints
// are supposed to be HMAC'd with a salt, and then stored in the User-Agent in the format
// of `{hashed_endpoint}#{numbers}`, as well as mangling the URL.
//
// However, there was an oversight in the implementation for versions PARADISE LOST
// and older: the endpoint stored in the User-Agent was not hashed. A mangled URL
// still indicates encryption was used, however, so you still need to provide key/IV. Only
// the salt is not needed.
let Some(maybe_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 {
let is_encrypted = is_encrypted_endpoint(maybe_endpoint);
let endpoint = if is_encrypted && user_agent.contains('#') {
user_agent
.split('#')
.next()
.expect("there should be at least one item in the split")
} else {
maybe_endpoint
};
if is_encrypted && (CONFIGURATION.crypto.key.is_empty() || CONFIGURATION.crypto.iv.is_empty()) {
error!("Communications with the server is encrypted, but no keys were provided. Fill in the keys by editing 'saekawa.toml'.");
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'.");
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();
}
@ -140,106 +175,115 @@ fn winhttpwritedata_hook(
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,
// Rest of the processing can be safely moved to a different thread, since we're not dealing
// with the hooked function's stuff anymore, probably.
std::thread::spawn(move || {
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;
}
}
} else {
raw_request_body
};
let request_body = match read_maybe_compressed_buffer(&request_body_compressed[..]) {
Ok(data) => data,
Err(err) => {
error!("Could not decrypt request: {:#}", err);
return orig();
error!("There was an error decoding the request body: {:#}", err);
return;
}
};
debug!("decoded request body: {request_body}");
// Reached in debug mode
if !is_upsert_user_all {
return;
}
} 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 decoding the request body: {:#}", err);
return orig();
}
};
debug!("decoded request body: {request_body}");
// Reached in debug mode
if !is_upsert_user_all {
return orig();
}
let upsert_req = match serde_json::from_str::<UpsertUserAllRequest>(&request_body) {
Ok(req) => req,
Err(err) => {
error!("Could not parse request body: {:#}", err);
return orig();
}
};
debug!("Parsed request body: {:#?}", 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 orig();
}
let classes = if CONFIGURATION.general.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 = upsert_req
.upsert_user_all
.user_playlog_list
.into_iter()
.filter_map(|playlog| {
let result =
ImportScore::try_from_playlog(playlog, CONFIGURATION.general.fail_over_lamp);
if result
.as_ref()
.is_ok_and(|v| v.difficulty != Difficulty::WorldsEnd)
{
result.ok()
} else {
None
let upsert_req = match serde_json::from_str::<UpsertUserAllRequest>(&request_body) {
Ok(req) => req,
Err(err) => {
error!("Could not parse request body: {:#}", err);
return;
}
})
.collect::<Vec<ImportScore>>();
};
if scores.is_empty() {
if classes.is_none() {
return orig();
}
debug!("parsed request body: {:#?}", upsert_req);
if classes
.clone()
.is_some_and(|v| v.dan.is_none() && v.emblem.is_none())
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)
{
return orig();
info!("Card {access_code} is not whitelisted, skipping score submission");
return;
}
}
let import = Import {
classes,
scores,
..Default::default()
};
let classes = if CONFIGURATION.general.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
};
match call_tachi("POST", TACHI_IMPORT_URL.as_str(), Some(import)) {
Ok(_) => info!("Successfully imported scores for card {access_code}"),
Err(err) => error!("Could not import scores for card {access_code}: {:#}", err),
};
let scores = upsert_req
.upsert_user_all
.user_playlog_list
.into_iter()
.filter_map(|playlog| {
let result =
ImportScore::try_from_playlog(playlog, CONFIGURATION.general.fail_over_lamp);
if result
.as_ref()
.is_ok_and(|v| v.difficulty != Difficulty::WorldsEnd)
{
result.ok()
} else {
None
}
})
.collect::<Vec<ImportScore>>();
if scores.is_empty() {
if classes.is_none() {
return;
}
if classes
.clone()
.is_some_and(|v| v.dan.is_none() && v.emblem.is_none())
{
return;
}
}
let import = Import {
classes,
scores,
..Default::default()
};
info!(
"Submitting {} scores from access code {}",
import.scores.len(),
user_data.access_code
);
if let Err(err) = execute_tachi_import(import) {
error!("{:#}", err);
}
});
orig()
}

View File

@ -1,10 +1,119 @@
use anyhow::Result;
use chrono::{FixedOffset, NaiveDateTime, TimeZone};
use num_enum::TryFromPrimitive;
use serde::{Deserialize, Serialize};
use serde::{de, Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use super::game::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,