This commit is contained in:
xeon 2025-02-21 14:29:49 +03:00
parent e7ad26ba33
commit 6bb60149ce
18 changed files with 3351 additions and 1 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
sdk.db
sdk_server.toml

2334
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

30
Cargo.toml Normal file
View file

@ -0,0 +1,30 @@
[package]
name = "hoyo-sdk"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1.43.0", features = ["full"] }
axum = "0.8.1"
sqlx = { version = "0.8.3", features = ["sqlite", "runtime-tokio"] }
rsa = "0.9.7"
password-hash = { version = "0.5.0", features = ["alloc", "rand_core"] }
pbkdf2 = { version = "0.12.2", features = ["simple"] }
rand = "0.8.5"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.135"
toml = "0.8.19"
rbase64 = "2.0.3"
thiserror = "2.0.11"
regex = "1.11.1"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
[profile.release]
strip = true # Automatically strip symbols from the binary.
lto = true # Link-time optimization.
opt-level = 3 # Optimize for speed.
codegen-units = 1 # Maximum size reduction optimizations.

View file

@ -1,3 +1,10 @@
# hoyo-sdk
Hoyo SDK server emulator
Portable and lightweight SDK server implementation in Rust.
### Usage
Just compile and run. The server uses sqlite, so no additional database setup required.
### Register an account
By default, you can register an account here: http://127.0.0.1:20100/account/register
(assuming you're running server with the default configuration)

191
html/register.html Normal file
View file

@ -0,0 +1,191 @@
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="pragma" content="no-cache" />
<meta http-equiv="cache-control" content="no-cache" />
<meta http-equiv="x-ua-compatible" content="IE=edge" />
<meta
name="viewport"
content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover"
/>
<title>Register account</title>
<style>
* {
margin: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
body,
html {
height: 100%;
width: 100%;
overflow-y: hidden;
overflow-x: auto;
background-color: #fff;
font-size: 100px;
color: #333;
font-family:
Microsoft YaHei,
Arial,
sans-serif;
}
a {
text-decoration: none;
cursor: pointer;
color: #4ea4dc;
}
a:hover {
color: #6cf;
}
#root {
width: 100%;
height: 100%;
overflow-y: scroll;
overflow-x: auto;
background-color: #f8f8f8;
position: relative;
font-size: 0.14rem;
}
.container {
background-color: #fff;
border-radius: 0.04rem;
overflow: hidden;
width: 70%;
margin: 0.3rem auto auto;
}
.header {
font-size: 0.2rem;
line-height: 0.6rem;
text-align: center;
background-color: #4ea4dc;
color: #fff;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
}
.content .flash {
margin: 0.2rem;
padding: 0.1rem;
width: 55%;
text-align: center;
font-size: 0.15rem;
border-radius: 4px;
}
.content .flash.error {
background: #e7746b;
color: #ffffff;
}
.content .flash.success {
background: #8bc34a;
color: #ffffff;
}
.content .flash.warning {
background: #ff9800;
color: #ffffff;
}
.content form {
padding: 0.2rem;
width: 55%;
}
.content form input {
padding: 0 20px;
width: 100%;
height: 48px;
background: #fff;
border-radius: 4px;
border: 1px solid #e6e6e6;
color: #333;
outline: none;
margin-bottom: 0.4rem;
}
.content form input:focus {
background-color: #f8f8f8;
outline: none;
}
.content form button {
padding: 0 20px;
width: 100%;
height: 48px;
background: #4ea4dc;
border-radius: 0.04rem;
border: 1px solid #4ea4dc;
color: #fff;
font-size: 0.2rem;
}
.content form button:hover {
background-color: #6cf;
border-color: #6cf;
cursor: pointer;
}
.content .crumbs {
display: flex;
justify-content: space-between;
width: 50%;
margin-bottom: 0.2rem;
}
@media screen and (max-width: 1400px) {
.content .flash {
width: 75%;
}
.content form {
width: 75%;
}
.content .crumbs {
width: 65%;
}
}
@media screen and (max-width: 800px) {
.container {
width: 90%;
}
.content .flash {
width: 95%;
}
.content form {
width: 100%;
}
.content .crumbs {
width: 85%;
}
}
</style>
</head>
<body>
<div id="root">
<div class="container">
<div class="header">Register account</div>
<div class="content">
<form method="post">
<input
type="text"
name="username"
required=""
placeholder="Username"
/>
<input
type="password"
name="password"
required=""
placeholder="Password"
/>
<input
type="password"
name="password_v2"
required=""
placeholder="Confirm Password"
/>
<button type="submit">Register</button>
</form>
</div>
</div>
</div>
</body>
</html>

171
html/result.html Normal file
View file

@ -0,0 +1,171 @@
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="pragma" content="no-cache" />
<meta http-equiv="cache-control" content="no-cache" />
<meta http-equiv="x-ua-compatible" content="IE=edge" />
<meta
name="viewport"
content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover"
/>
<title>Register account</title>
<style>
* {
margin: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
body,
html {
height: 100%;
width: 100%;
overflow-y: hidden;
overflow-x: auto;
background-color: #fff;
font-size: 100px;
color: #333;
font-family:
Microsoft YaHei,
Arial,
sans-serif;
}
a {
text-decoration: none;
cursor: pointer;
color: #4ea4dc;
}
a:hover {
color: #6cf;
}
#root {
width: 100%;
height: 100%;
overflow-y: scroll;
overflow-x: auto;
background-color: #f8f8f8;
position: relative;
font-size: 0.14rem;
}
.container {
background-color: #fff;
border-radius: 0.04rem;
overflow: hidden;
width: 70%;
margin: 0.3rem auto auto;
}
.header {
font-size: 0.2rem;
line-height: 0.6rem;
text-align: center;
background-color: #4ea4dc;
color: #fff;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
}
.content .flash {
margin: 0.2rem;
padding: 0.1rem;
width: 55%;
text-align: center;
font-size: 0.15rem;
border-radius: 4px;
}
.content .flash.error {
background: #e7746b;
color: #ffffff;
}
.content .flash.success {
background: #8bc34a;
color: #ffffff;
}
.content .flash.warning {
background: #ff9800;
color: #ffffff;
}
.content form {
padding: 0.2rem;
width: 55%;
}
.content form input {
padding: 0 20px;
width: 100%;
height: 48px;
background: #fff;
border-radius: 4px;
border: 1px solid #e6e6e6;
color: #333;
outline: none;
margin-bottom: 0.4rem;
}
.content form input:focus {
background-color: #f8f8f8;
outline: none;
}
.content form button {
padding: 0 20px;
width: 100%;
height: 48px;
background: #4ea4dc;
border-radius: 0.04rem;
border: 1px solid #4ea4dc;
color: #fff;
font-size: 0.2rem;
}
.content form button:hover {
background-color: #6cf;
border-color: #6cf;
cursor: pointer;
}
.content .crumbs {
display: flex;
justify-content: space-between;
width: 50%;
margin-bottom: 0.2rem;
}
@media screen and (max-width: 1400px) {
.content .flash {
width: 75%;
}
.content form {
width: 75%;
}
.content .crumbs {
width: 65%;
}
}
@media screen and (max-width: 800px) {
.container {
width: 90%;
}
.content .flash {
width: 95%;
}
.content form {
width: 100%;
}
.content .crumbs {
width: 85%;
}
}
</style>
</head>
<body>
<div id="root">
<div class="container">
<div class="header">Register account</div>
<div class="content">
<div class="flash %RESULT%">%MESSAGE%</div>
</div>
</div>
</div>
</body>
</html>

BIN
rsa/private_key.der Normal file

Binary file not shown.

2
sdk.default.toml Normal file
View file

@ -0,0 +1,2 @@
http_addr = "127.0.0.1:20100"
db_file = "sdk.db"

19
src/config.rs Normal file
View file

@ -0,0 +1,19 @@
use serde::Deserialize;
const DEFAULT: &str = include_str!("../sdk.default.toml");
#[derive(Deserialize)]
pub struct SdkConfig {
pub http_addr: String,
pub db_file: String,
}
pub fn load_or_create(path: &str) -> SdkConfig {
std::fs::read_to_string(path).map_or_else(
|_| {
std::fs::write(path, DEFAULT).unwrap();
toml::from_str(DEFAULT).unwrap()
},
|data| toml::from_str(&data).unwrap(),
)
}

83
src/database/mod.rs Normal file
View file

@ -0,0 +1,83 @@
use rand::distributions::{Alphanumeric, DistString};
use schema::{DbSdkAccountRow, Password, Username};
use sqlx::{migrate::MigrateDatabase, Sqlite, SqlitePool};
pub mod schema;
#[derive(Clone)]
pub struct DbContext(SqlitePool);
type Result<T> = std::result::Result<T, sqlx::Error>;
impl DbContext {
pub async fn connect(db_file: &str) -> Result<Self> {
let db_url = format!("sqlite://{db_file}");
if !Sqlite::database_exists(&db_url).await.unwrap_or(false) {
Sqlite::create_database(&db_url).await?;
}
let db = Self(SqlitePool::connect(&db_url).await?);
db.prepare_tables().await?;
Ok(db)
}
async fn prepare_tables(&self) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS t_sdk_account (
uid INTEGER PRIMARY KEY,
token TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL
);
"#,
)
.execute(&self.0)
.await?;
Ok(())
}
pub async fn create_account(
&self,
username: Username,
password: Password,
) -> Result<Option<DbSdkAccountRow>> {
if self
.get_account_by_name(username.as_str().to_string())
.await?
.is_some()
{
return Ok(None);
}
let token = Alphanumeric.sample_string(&mut rand::thread_rng(), 64);
Ok(Some(sqlx::query_as(
"INSERT INTO t_sdk_account (token, username, password) VALUES ($1, $2, $3) RETURNING *",
)
.bind(token)
.bind(username.as_str())
.bind(password.as_hash_str())
.fetch_one(&self.0)
.await?))
}
pub async fn get_account_by_name(&self, username: String) -> Result<Option<DbSdkAccountRow>> {
const SELECT_BY_NAME_QUERY: &str = r#"SELECT * FROM t_sdk_account WHERE username = ($1)"#;
sqlx::query_as(SELECT_BY_NAME_QUERY)
.bind(&username)
.fetch_optional(&self.0)
.await
}
pub async fn get_account_by_uid(&self, uid: i32) -> Result<Option<DbSdkAccountRow>> {
const SELECT_BY_UID_QUERY: &str = r#"SELECT * FROM t_sdk_account WHERE uid = ($1)"#;
sqlx::query_as(SELECT_BY_UID_QUERY)
.bind(uid)
.fetch_optional(&self.0)
.await
}
}

90
src/database/schema.rs Normal file
View file

@ -0,0 +1,90 @@
use std::sync::LazyLock;
use regex::Regex;
use sqlx::FromRow;
use thiserror::Error;
use crate::util;
#[derive(sqlx::Encode, sqlx::Decode)]
pub struct Username(String);
impl Username {
pub fn parse(username: String) -> Option<Self> {
static ALLOWED_USERNAME_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new("^[a-zA-Z0-9._@-]{6,25}$").unwrap());
ALLOWED_USERNAME_REGEX
.is_match(&username)
.then_some(Self(username))
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl sqlx::Type<sqlx::Sqlite> for Username {
fn type_info() -> <sqlx::Sqlite as sqlx::Database>::TypeInfo {
<String as sqlx::Type<sqlx::Sqlite>>::type_info()
}
fn compatible(ty: &<sqlx::Sqlite as sqlx::Database>::TypeInfo) -> bool {
<String as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
}
}
#[derive(Error, Debug)]
pub enum PasswordError {
#[error("password pair mismatch")]
PairMismatch,
#[error("user input doesn't meet requirements")]
RequirementsMismatch,
#[error("failed to generate password hash: {0}")]
HashFailed(pbkdf2::password_hash::Error),
}
#[derive(sqlx::Encode, sqlx::Decode)]
pub struct Password(String);
impl Password {
pub fn new(password: String, password_v2: String) -> Result<Self, PasswordError> {
(password == password_v2)
.then_some(())
.ok_or(PasswordError::PairMismatch)?;
matches!(password.len(), 8..30)
.then_some(())
.ok_or(PasswordError::RequirementsMismatch)?;
let hash = util::hash_string(&password).map_err(|err| PasswordError::HashFailed(err))?;
Ok(Self(hash))
}
pub fn verify(&self, password: &str) -> bool {
util::verify_hash(password, &self.0).is_some()
}
pub fn as_hash_str(&self) -> &str {
self.0.as_str()
}
}
impl sqlx::Type<sqlx::Sqlite> for Password {
fn type_info() -> <sqlx::Sqlite as sqlx::Database>::TypeInfo {
<String as sqlx::Type<sqlx::Sqlite>>::type_info()
}
fn compatible(ty: &<sqlx::Sqlite as sqlx::Database>::TypeInfo) -> bool {
<String as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
}
}
#[derive(FromRow)]
pub struct DbSdkAccountRow {
pub uid: i32,
pub token: String,
pub username: Username,
pub password: Password,
}

View file

@ -0,0 +1,60 @@
use super::*;
#[derive(Deserialize)]
struct RequestData {
pub uid: String,
pub token: String,
}
#[derive(Deserialize)]
struct GranterTokenRequest {
pub data: String,
}
pub fn routes() -> Router<AppStateRef> {
Router::new().route(
"/{product_name}/combo/granter/login/v2/login",
post(login_v2),
)
}
#[derive(Serialize)]
struct ResponseData {
pub account_type: u32,
pub combo_id: String,
pub combo_token: String,
pub data: &'static str,
pub heartbeat: bool,
pub open_id: String,
}
async fn login_v2(
state: State<AppStateRef>,
request: Json<GranterTokenRequest>,
) -> Json<Response<ResponseData>> {
let Ok(data) = serde_json::from_str::<RequestData>(&request.data) else {
return Json(Response::error(-101, "Account token error"));
};
let Ok(uid) = data.uid.parse() else {
return Json(Response::error(-101, "Account token error"));
};
match state.db.get_account_by_uid(uid).await {
Ok(Some(account)) if account.token == data.token => {
success_rsp(data.uid.clone(), account.token)
}
_ => Json(Response::error(-101, "Account token error")),
}
}
fn success_rsp(uid: String, token: String) -> Json<Response<ResponseData>> {
Json(Response::new(ResponseData {
account_type: 1,
combo_id: uid.clone(),
combo_token: token,
data: r#"{"guest":false}"#,
heartbeat: false,
open_id: uid,
}))
}

View file

@ -0,0 +1,117 @@
use super::*;
pub fn routes() -> Router<AppStateRef> {
Router::new()
.route("/{product_name}/mdk/shield/api/login", post(login))
.route("/{product_name}/mdk/shield/api/verify", post(verify))
}
#[derive(Deserialize)]
struct LoginRequest {
pub account: String,
pub password: String,
pub is_crypto: bool,
}
#[derive(Deserialize)]
struct VerifyRequest {
pub uid: String,
pub token: String,
}
#[derive(Serialize, Default)]
struct ResponseData {
pub account: ResponseAccountData,
pub device_grant_required: bool,
pub reactive_required: bool,
pub realperson_required: bool,
pub safe_mobile_required: bool,
}
#[derive(Serialize, Default)]
struct ResponseAccountData {
pub area_code: String,
pub email: String,
pub country: String,
pub is_email_verify: String,
pub token: String,
pub uid: String,
}
async fn login(
state: State<AppStateRef>,
request: Json<LoginRequest>,
) -> Json<Response<ResponseData>> {
if !request.is_crypto {
return Json(Response::error(
-10,
"Invalid account format: unencrypted passwords are disabled by SDK security policy",
));
}
let Ok(password) = crate::util::rsa_decrypt(&request.password) else {
return Json(Response::error(-10, "Your patch is outdated, get a new one at https://discord.gg/reversedrooms (Password decryption failed)"));
};
let account = match state.db.get_account_by_name(request.account.clone()).await {
Ok(Some(account)) => account,
Ok(None) => return Json(Response::error(-101, "Account or password error")),
Err(err) => {
tracing::error!("database error: {err}");
return Json(Response::error(-1, "Internal server error"));
}
};
if !account.password.verify(&password) {
return Json(Response::error(-101, "Account or password error"));
}
Json(Response::new(ResponseData {
account: ResponseAccountData {
area_code: String::from("**"),
email: account.username.as_str().to_string(),
country: String::from("RU"),
is_email_verify: String::from("1"),
uid: account.uid.to_string(),
token: account.token,
},
..Default::default()
}))
}
async fn verify(
state: State<AppStateRef>,
request: Json<VerifyRequest>,
) -> Json<Response<ResponseData>> {
let Ok(uid) = request.uid.parse() else {
return Json(Response::error(-101, "Account cache error"));
};
let account = match state.db.get_account_by_uid(uid).await {
Ok(Some(account)) => account,
Ok(None) => return Json(Response::error(-101, "Account cache error")),
Err(err) => {
tracing::error!("database error: {err}");
return Json(Response::error(-1, "Internal server error"));
}
};
if account.token == request.token {
Json(Response::new(ResponseData {
account: ResponseAccountData {
area_code: String::from("**"),
email: account.username.as_str().to_string(),
country: String::from("RU"),
is_email_verify: String::from("1"),
uid: account.uid.to_string(),
token: account.token,
},
..Default::default()
}))
} else {
Json(Response::error(
-101,
"For account safety, please log in again",
))
}
}

38
src/handlers/mod.rs Normal file
View file

@ -0,0 +1,38 @@
use crate::AppStateRef;
use axum::{
extract::State,
response::Html,
routing::{get, post},
Form, Json, Router,
};
use serde::{Deserialize, Serialize};
pub mod combo_granter;
pub mod mdk_shield_api;
pub mod register;
pub mod risky_api;
#[derive(Serialize)]
pub struct Response<T> {
data: Option<T>,
message: String,
retcode: i32,
}
impl<T> Response<T> {
pub fn new(data: T) -> Self {
Self {
data: Some(data),
message: String::from("OK"),
retcode: 0,
}
}
pub fn error(retcode: i32, message: &str) -> Self {
Self {
data: None,
message: message.to_string(),
retcode,
}
}
}

82
src/handlers/register.rs Normal file
View file

@ -0,0 +1,82 @@
use super::*;
use crate::database::schema::{Password, PasswordError, Username};
const REGISTER_PAGE: Html<&str> = Html(include_str!("../../html/register.html"));
pub fn routes() -> Router<AppStateRef> {
Router::new()
.route("/account/register", get(|| async { REGISTER_PAGE }))
.route("/account/register", post(process_register_request))
}
#[derive(Deserialize)]
struct RegisterForm {
pub username: String,
pub password: String,
pub password_v2: String,
}
async fn process_register_request(
State(state): State<AppStateRef>,
Form(form): Form<RegisterForm>,
) -> Html<String> {
let Some(username) = Username::parse(form.username) else {
return html_result(ResultStatus::Failure, "Invalid username format; should consists of characters [A-Za-z0-9_] and be at least 6 characters long.");
};
let password = match Password::new(form.password, form.password_v2) {
Ok(password) => password,
Err(PasswordError::PairMismatch) => {
return html_result(ResultStatus::Failure, "Passwords pair doesn't match")
}
Err(PasswordError::RequirementsMismatch) => {
return html_result(
ResultStatus::Failure,
"Password should contain at least 8 and not more than 30 characters",
)
}
Err(PasswordError::HashFailed(err)) => {
tracing::error!("failed to hash password, err: {err}");
return html_result(ResultStatus::Failure, "Internal server error");
}
};
match state.db.create_account(username, password).await {
Ok(Some(_)) => html_result(
ResultStatus::Success,
"Account successfully registered, now you can use in-game login",
),
Ok(None) => html_result(
ResultStatus::Failure,
"Account with specified username already exists",
),
Err(err) => {
tracing::error!("database operation error: {err}");
html_result(ResultStatus::Failure, "Internal server error")
}
}
}
enum ResultStatus {
Failure,
Success,
}
impl ResultStatus {
pub fn as_str(&self) -> &str {
match self {
Self::Failure => "error",
Self::Success => "success",
}
}
}
fn html_result(result: ResultStatus, message: &str) -> Html<String> {
static RESULT_HTML: &str = include_str!("../../html/result.html");
Html(
RESULT_HTML
.replace("%RESULT%", result.as_str())
.replace("%MESSAGE%", message),
)
}

View file

@ -0,0 +1,9 @@
use super::*;
pub fn routes() -> Router<AppStateRef> {
Router::new().route("/account/risky/api/check", post(risky_api_check))
}
async fn risky_api_check(_: String) -> &'static str {
r#"{"data":{},message:"OK",retcode:0}"#
}

67
src/main.rs Normal file
View file

@ -0,0 +1,67 @@
use std::{
process::ExitCode,
sync::{LazyLock, OnceLock},
};
use axum::Router;
use config::SdkConfig;
use database::DbContext;
use handlers::{combo_granter, mdk_shield_api, register, risky_api};
use tokio::net::TcpListener;
use tracing::error;
mod config;
mod database;
mod handlers;
mod util;
struct AppState {
db: DbContext,
#[expect(dead_code)]
config: &'static SdkConfig,
}
type AppStateRef = &'static AppState;
#[tokio::main]
async fn main() -> ExitCode {
static CONFIG: LazyLock<SdkConfig> =
LazyLock::new(|| config::load_or_create("sdk_server.toml"));
static STATE: OnceLock<AppState> = OnceLock::new();
println!("
_____ _ _____ \n | __ \\ | | __ \\ \n | |__) |_____ _____ _ __ ___ ___ __| | |__) |___ ___ _ __ ___ ___ \n | _ // _ \\ \\ / / _ \\ '__/ __|/ _ \\/ _` | _ // _ \\ / _ \\| '_ ` _ \\/ __|\n | | \\ \\ __/\\ V / __/ | \\__ \\ __/ (_| | | \\ \\ (_) | (_) | | | | | \\__ \\\n |_| \\_\\___| \\_/ \\___|_| |___/\\___|\\__,_|_| \\_\\___/ \\___/|_| |_| |_|___/");
init_tracing();
let db = match DbContext::connect(&CONFIG.db_file).await {
Ok(db) => db,
Err(err) => {
error!("Failed to open SQLite database. Error: {err}");
return ExitCode::FAILURE;
}
};
let _ = STATE.set(AppState {
db,
config: &CONFIG,
});
let router = Router::new()
.merge(risky_api::routes())
.merge(register::routes())
.merge(mdk_shield_api::routes())
.merge(combo_granter::routes())
.with_state(STATE.get().unwrap());
let listener = TcpListener::bind(&CONFIG.http_addr)
.await
.expect("TcpListener::bind failed. Is another instance of this server already running?");
axum::serve(listener, router).await.unwrap();
ExitCode::SUCCESS
}
fn init_tracing() {
tracing_subscriber::fmt().without_time().init();
}

46
src/util.rs Normal file
View file

@ -0,0 +1,46 @@
use rsa::{Pkcs1v15Encrypt, RsaPrivateKey};
use thiserror::Error;
use password_hash::{PasswordHash, PasswordHasher, SaltString};
use pbkdf2::Pbkdf2;
const SDK_PRIVATE_KEY: &[u8] = include_bytes!("../rsa/private_key.der");
#[derive(Error, Debug)]
pub enum CryptoError {
#[error("failed to decrypt: {0}")]
Decrypt(#[from] rsa::Error),
#[error("failed to decode base64 string")]
FromBase64,
#[error("from_utf8 failed: {0}")]
FromUtf8(#[from] std::string::FromUtf8Error),
}
pub fn rsa_decrypt(cipher: &str) -> Result<String, CryptoError> {
let private_key: RsaPrivateKey = rsa::pkcs8::DecodePrivateKey::from_pkcs8_der(SDK_PRIVATE_KEY)
.expect("failed to decode private key");
let payload = private_key.decrypt(
Pkcs1v15Encrypt,
&rbase64::decode(cipher).map_err(|_| CryptoError::FromBase64)?,
)?;
Ok(String::from_utf8(payload)?)
}
pub fn hash_string(content: &str) -> Result<String, pbkdf2::password_hash::Error> {
const HASH_PARAMS: pbkdf2::Params = pbkdf2::Params {
rounds: 10_000,
output_length: 32,
};
let salt = SaltString::generate(rand::thread_rng());
let hash =
Pbkdf2.hash_password_customized(content.as_bytes(), None, None, HASH_PARAMS, &salt)?;
Ok(hash.serialize().to_string())
}
#[must_use]
pub fn verify_hash(content: &str, hash_str: &str) -> Option<()> {
let hash = PasswordHash::new(hash_str).ok()?;
hash.verify_password(&[&Pbkdf2], content).ok()
}