feat: a bunch of things

This commit is contained in:
Andrea Toska 2025-02-27 10:25:31 +01:00
commit 2e2b01990e
20 changed files with 16216 additions and 137 deletions

View file

@ -1,10 +1,158 @@
use diesel::{Queryable, Selectable};
use std::io::Write;
use argon2::{Algorithm, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use bytemuck::bytes_of;
use log::{error, warn};
use rocket::http::Status;
use rocket::{async_trait, Request};
use rocket::request::{FromRequest, Outcome};
use sha2::{Digest, Sha256};
use sha2::digest::FixedOutput;
use crate::error::{Error, Errors};
use crate::Pool;
#[derive(Queryable, Selectable)]
#[diesel(table_name = crate::db::user)]
#[diesel(check_for_backend(diesel::mysql::Mysql))]
macro_rules! request_try {
($expression:expr) => {
match $expression{
Ok(v) => v,
Err(e) => return Outcome::Error((Status::BadRequest, e))
}
};
}
const INVALID_TOKEN_ERRORS: Errors<'static> = Errors{
error: &[
Error{
message: "Invalid access token",
code: "0005"
}
]
};
// optimization note: add token caching
pub struct User {
pub id: i32,
pub name: String,
pub hashed_pw: String
pub password: String,
pub birthdate: chrono::NaiveDate,
pub timezone: String,
pub email: String,
pub gender: String,
pub country: String,
pub language: String,
pub marketing_allowed: bool,
pub region: i32,
pub mii_identifier: String
}
fn generate_nintendo_hash(pid: i32, text_password: &str) -> String{
let mut sha = Sha256::new();
sha.write_all(&bytes_of(&pid)).unwrap();
sha.write_all(&[0x02, 0x65, 0x43 ,0x46]).unwrap();
sha.write_all(text_password.as_bytes()).unwrap();
hex::encode(&sha.finalize()[..])
}
impl User{
fn generate_nintendo_hash(&self, text_password: &str) -> String{
generate_nintendo_hash(self.id, text_password)
}
pub fn verify_password(&self, cleartext_password: &str) -> (bool, bool){
let Ok(hash) = PasswordHash::new(&self.password) else {
error!("invalid password in database for user with pid: {}", self.id);
if self.password == self.generate_nintendo_hash(cleartext_password){
return (true, true)
}
return (false, false)
};
let argon = Argon2::default();
(argon.verify_password(cleartext_password.as_bytes(), &hash).is_ok(), false)
}
}
pub fn generate_password(pid: i32, cleartext_password: &str) -> Option<String>{
let password = generate_nintendo_hash(pid, cleartext_password);
let salt = SaltString::generate(&mut OsRng);
let argon = Argon2::default();
let pw = argon.hash_password(password.as_bytes(), &salt).ok()?;
Some(pw.to_string())
}
pub async fn read_basic_auth_token(connection: &mut AsyncMysqlConnection, token: &str) -> Option<User> {
let data = BASE64_STANDARD.decode(&token).ok()?;
let decoded_basic_token = String::from_utf8(data).ok()?;
let (login_username, login_password) = decoded_basic_token.split_once(' ')?;
let mut user: User = users
.filter(name.eq(login_username))
.select(User::as_select())
.first(connection)
.await.ok()?;
let (password_valid, upgrade_password) = user.verify_password(&login_password);
if password_valid{
if upgrade_password{
user.password = generate_password(&login_password).unwrap();
user = connection.update_and_fetch(&user).await.ok()?;
}
Some(user)
} else {
None
}
}
async fn read_bearer_auth_token(connection: &mut AsyncMysqlConnection, token: &str) -> Option<User> {
let data = BASE64_STANDARD.decode(&token).ok()?;
warn!("bearer token login currently unsupported");
None
}
#[async_trait]
impl<'r> FromRequest<'r> for User{
type Error = Errors<'static>;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let pool: &Pool = request.rocket().state().unwrap();
let mut connection = pool.get().await.unwrap();
let auth = request_try!(request.headers().get("Authorization").next().ok_or(INVALID_TOKEN_ERRORS));
let (auth_type, token) = request_try!(auth.split_once(' ').ok_or(INVALID_TOKEN_ERRORS));
let user = match auth_type{
"Basic" => read_basic_auth_token(&mut connection, token).await,
"Bearer" => read_bearer_auth_token(&mut connection, token).await,
_ => return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS)),
};
let Some(user) = user else {
return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS));
};
Outcome::Success(user)
}
}

View file

@ -1 +1 @@
mod account;
pub mod account;

16
src/dsresponse.rs Normal file
View file

@ -0,0 +1,16 @@
use std::marker::PhantomData;
use rocket::{Request, Response};
use rocket::http::{Header, Status};
use rocket::response::Responder;
use crate::error::Errors;
use crate::xml::Xml;
pub struct Ds<T>(pub T);
impl<'r, 'o: 'r, T: Responder<'r, 'o>> Responder<'r, 'o> for Ds<T> {
fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'o> {
Response::build_from(self.0.respond_to(request)?)
.header(Header::new("Server", "Nintendo 3DS (http)"))
.ok()
}
}

28
src/error.rs Normal file
View file

@ -0,0 +1,28 @@
use log::error;
use rocket::http::Status;
use rocket::{Request, Response};
use rocket::response::content::RawXml;
use rocket::response::Responder;
use rocket::serde::Serialize;
use crate::nnid::timezones::Timezone;
use crate::xml::{serialize_with_version, Xml};
#[derive(Serialize, Debug)]
pub struct Error<'a>{
pub code: &'a str,
pub message: &'a str
}
#[derive(Serialize, Debug)]
#[serde(rename(serialize = "errors"))]
pub struct Errors<'a>{
pub error: &'a [Error<'a>],
}
impl<'r, 'o: 'r> Responder<'r, 'o> for Errors<'r> {
fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'o> {
Response::build_from(Xml(self).respond_to(request)?)
.status(Status::BadRequest)
.ok()
}
}

View file

@ -1,26 +1,60 @@
use std::env;
use diesel::{Connection, MysqlConnection};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use dotenvy::dotenv;
use log::info;
use rocket::fairing::AdHoc;
use rocket::http::Header;
use rocket::routes;
use sqlx::Postgres;
use sqlx::postgres::PgPoolOptions;
mod xml;
mod conntest;
mod db;
mod nnid;
mod account;
mod error;
mod dsresponse;
type Pool = sqlx::Pool<Postgres>;
#[rocket::launch]
async fn launch() -> _ {
dotenv().ok();
let act_database_url = env::var("ACCOUNT_DATABASE_URL").expect("account database url is not set");
let conn = MysqlConnection::establish(&act_database_url).expect("unable to connect to database");
let act_database_url = env::var("DATABASE_URL").expect("account database url is not set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&act_database_url).await
.expect("unable to create pool");
rocket::build()
.manage(pool)
.attach(AdHoc::on_response("org", |_, response| Box::pin(async move {
response.adjoin_header(Header::new("x-organization", "Nintendo"));
//response.adjoin_header(Header::new("x-organization", "Nintendo"));
response.adjoin_header(Header::new("x-nintendo-date", SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis()
.to_string()
));
response.adjoin_header(Header::new("Content-Type", "text/xml; charset=utf-8"));
response.remove_header("x-content-type-options");
response.remove_header("x-frame-options");
response.remove_header("permissions-policy");
})))
.mount("/", routes![conntest::conntest])
.mount("/", routes![
conntest::conntest,
nnid::devices::current_device_status,
nnid::agreements::get_agreement,
nnid::timezones::get_timezone,
nnid::person_exists::person_exists,
nnid::email::validate
])
}

45
src/nnid/agreements.rs Normal file
View file

@ -0,0 +1,45 @@
use std::{env, fs, io};
use rocket::fs::NamedFile;
use rocket::get;
use rocket::response::content::RawXml;
use tokio::fs::try_exists;
use crate::dsresponse::Ds;
use crate::nnid::devices::Device;
use crate::xml::Xml;
#[get("/v1/api/content/agreements/Nintendo-Network-EULA/<lang>/@latest")]
pub async fn get_agreement(lang: &str) -> io::Result<Ds<RawXml<NamedFile>>>{
let base_path = {
// if this crashes then something is wrong with the server setup so crashing here is fine imo
let mut path = env::current_dir().unwrap();
path.push("res");
path.push("agreement");
path
};
let requested_file_path = {
let mut path = base_path.clone();
path.push(format!("{}.xml", lang));
path
};
if try_exists(&requested_file_path).await.is_ok_and(|v| v == true){
Ok(Ds(RawXml(NamedFile::open(&requested_file_path).await?)))
} else {
let fallback_path = {
let mut path = base_path;
path.push("DEFAULT.xml");
path
};
Ok(Ds(RawXml(NamedFile::open(&fallback_path).await?)))
}
}

176
src/nnid/create_account.rs Normal file
View file

@ -0,0 +1,176 @@
use chrono::NaiveDate;
use rocket::{post, State};
use serde::{Deserialize, Serialize};
use crate::account::account::{generate_password, User};
use crate::error::Errors;
use crate::Pool;
use crate::xml::{Xml, YesNoVal};
#[derive(Deserialize)]
struct Email{
address: Box<str>
}
#[derive(Deserialize, Serialize)]
struct Mii{
name: Box<str>,
primary: YesNoVal,
data: Box<str>,
}
#[derive(Deserialize)]
#[serde(rename(serialize = "person"))]
struct AccountCreationData{
birth_date: NaiveDate,
user_id: Box<str>,
password: Box<str>,
country: Box<str>,
language: Box<str>,
tz_name: Box<str>,
email: Email,
gender: Box<str>,
marketing_flag: YesNoVal,
region: i32
}
#[derive(Serialize)]
#[serde(rename(serialize = "person"))]
struct AccountCreationResponseData{
pid: i32
}
#[post("/v1/api/people", data="<data>")]
async fn create_account(database: &State<Pool>, data: Xml<AccountCreationData>) -> Result<Xml<AccountCreationResponseData>, Errors>{
let database = database.inner();
let AccountCreationData {
user_id,
password,
birth_date,
tz_name,
language,
email: Email{
address
},
marketing_flag,
gender,
region,
country,
..
} = *data;
let new_account = sqlx::query("
INSERT INTO users.users (
pid,
username,
password,
birthdate,
birthdate,
timezone,
email,
country,
language,
marketing_allowed,
off_device_allowed,
region,
mii_data
) VALUES (
?,?,?,?,?,?,?,?,?,?
)
");
let pid = connection.transaction::<_, diesel::result::Error, _>(|conn| Box::pin(async move{
use crate::schema::users::dsl::*;
diesel::insert_into(users)
.values(&new_account)
.returning(User::as_returning())
.get_result(conn)
.await?;
Ok(())
})).await;
}
#[cfg(test)]
mod test{
use chrono::NaiveDate;
use crate::nnid::create_account::AccountCreationData;
const TEST_XML: &str =
"<?xml version=\"1.0\" encoding=\"utf-8\"?>
<person>
<birth_date>1991-02-03</birth_date>
<user_id>testtest</user_id>
<password>[PASSWORD]</password>
<country>DE</country>
<language>en</language>
<tz_name>Europe/Berlin</tz_name>
<agreement>
<agreement_date>2025-02-24T19:42:45</agreement_date>
<country>US</country>
<location>https://account.spfn.cc/v1/api/content/agreements/Nintendo-Network-EULA/0300</location>
<type>NINTENDO-NETWORK-EULA</type>
<version>0300</version>
</agreement>
<email>
<address>tvnebel@gmail.com</address>
<owned>N</owned>
<parent>N</parent>
<primary>Y</primary>
<validated>N</validated>
<type>DEFAULT</type>
</email>
<mii>
<name>y</name>
<primary>Y</primary>
<data>
AwAAQDrPvmeBxJIQ3j+V8Ip4iCWDvgAAAEB5AAAAIABOAEEATQBFAAAAAAAAAEBAAAAhAQJoRBgm
NEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAANzO
</data>
</mii>
<parental_consent>
<scope>1</scope>
<consent_date>2025-02-24T19:42:45</consent_date>
<approval_id>0</approval_id>
</parental_consent>
<gender>M</gender>
<region>1309343744</region>
<marketing_flag>N</marketing_flag>
<device_attributes>
<device_attribute>
<name>uuid_account</name>
<value>55fdbad0-f2ab-11ef-b648-010144cdca06</value>
</device_attribute>
<device_attribute>
<name>uuid_common</name>
<value>898ed052-5e25-11ef-b648-010144cdca06</value>
</device_attribute>
<device_attribute>
<name>persistent_id</name>
<value>8000001d</value>
</device_attribute>
<device_attribute>
<name>transferable_id_base</name>
<value>0800000444cdca06</value>
</device_attribute>
<device_attribute>
<name>transferable_id_base_common</name>
<value>0640000444cdca06</value>
</device_attribute>
</device_attributes>
<off_device_flag>N</off_device_flag>
</person>";
#[test]
fn test(){
let data: AccountCreationData = quick_xml::de::from_str(TEST_XML).unwrap();
assert_eq!(data.birth_date, NaiveDate::from_ymd_opt(1991,02,03).unwrap());
assert_eq!(data.user_id.as_ref(), "testtest");
}
}

28
src/nnid/devices.rs Normal file
View file

@ -0,0 +1,28 @@
use rocket::get;
use serde::Serialize;
use crate::xml::Xml;
#[derive(Serialize)]
#[serde(rename(serialize = "device"))]
pub struct Device;
#[get("/v1/api/devices/@current/status")]
pub fn current_device_status() -> Xml<Device>{
Xml(Device)
}
#[cfg(test)]
mod tests {
use std::str::from_utf8;
use crate::nnid::devices::Device;
#[test]
fn test_device_data(){
let text = crate::xml::serialize_with_version(&Device).unwrap();
println!("{}", text);
}
}

11
src/nnid/email.rs Normal file
View file

@ -0,0 +1,11 @@
use rocket::{post, FromForm};
use rocket::form::Form;
#[derive(FromForm)]
struct ValidateEmailInput{
email: String,
}
#[post("/v1/api/support/validate/email", data="<data>")]
pub fn validate(data: Form<ValidateEmailInput>){
}

6
src/nnid/mod.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod devices;
pub mod agreements;
pub mod timezones;
pub mod person_exists;
pub mod email;
mod create_account;

58
src/nnid/person_exists.rs Normal file
View file

@ -0,0 +1,58 @@
use rocket::{get, State};
use sqlx::Row;
use crate::error::{Error, Errors};
use crate::Pool;
use crate::xml::Xml;
#[get("/v1/api/people/<username>")]
pub async fn person_exists(database: &State<Pool>, username: &str) -> Result<(), Errors<'static>>{
let database = database.inner();
let exists: bool = sqlx::query_as!(
bool,
"SELECT EXISTS(SELECT 1 FROM users.users WHERE username = ? )",
username
).fetch_one(database)
.await
.unwrap_or(true);
if exists {
Err(
Errors{
error: &[
Error{
code: "0100",
message: "Account ID already exists"
}
],
}
)
} else {
Ok(())
}
}
#[cfg(test)]
mod test{
use crate::error::{Error, Errors};
use crate::xml::serialize_with_version;
#[test]
fn test(){
let val = Errors{
error: &[
Error{
code: "0100",
message: "Account ID already exists"
}
],
};
let enc = serialize_with_version(&val).unwrap();
assert_eq!(
enc.as_ref(),
"<?xml version=\"1.0\"?><errors><error><code>0100</code><message>Account ID already exists</message></error></errors>"
)
}
}

64
src/nnid/timezones.rs Normal file
View file

@ -0,0 +1,64 @@
use std::collections::HashMap;
use std::{env, fs};
use once_cell::sync::Lazy;
use rocket::get;
use serde::{Deserialize, Serialize};
use serde_json::from_slice;
use crate::xml::{serialize_with_version, Xml};
#[derive(Serialize, Deserialize)]
#[serde(rename(serialize = "timezone"))]
pub struct Timezone{
area: String,
language: String,
name: String,
utc_offset: String,
order: String
}
#[derive(Serialize)]
#[serde(rename(serialize = "timezones"))]
pub struct Timezones<'a>{
pub timezone: &'a [Timezone],
}
pub static TIMEZONES: Lazy<HashMap<String, HashMap<String, Vec<Timezone>>>> = Lazy::new(||{
let path = {
// if this crashes then something is wrong with the server setup so crashing here is fine imo
let mut path = env::current_dir().unwrap();
path.push("res");
path.push("timezones.json");
path
};
serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
});
#[get("/v1/api/content/time_zones/<zone>/<lang>")]
pub fn get_timezone(zone: &str, lang: &str) -> Option<Xml<Timezones<'static>>>{
let timezone = (&*TIMEZONES).get(zone)?.get(lang)?;
let timezones = Timezones{ timezone };
Some(Xml(timezones))
}
#[cfg(test)]
mod test{
use crate::nnid::timezones::{Timezones, TIMEZONES};
use crate::xml::serialize_with_version;
#[test]
fn test(){
let timezone = (&*TIMEZONES).get("DE").unwrap().get("en").unwrap();
let timezones = Timezones{ timezone };
let ser = serialize_with_version(&timezones).unwrap();
println!("{}", ser);
assert_eq!(
ser.as_ref(),
"<?xml version=\"1.0\"?><timezones><timezone><area>Europe/Berlin</area><language>en</language><name>Amsterdam, Berlin, Rome</name><utc_offset>3600</utc_offset><order>0</order></timezone></timezones>"
)
}
}

View file

@ -1,17 +1,51 @@
use std::fmt::Formatter;
use std::io::Cursor;
use std::ops::{Deref, DerefMut};
use std::result;
use rocket::http::Status;
use rocket::Request;
use rocket::{async_trait, Data, Request};
use rocket::response::Responder;
use serde::Serialize;
use serde::{Deserialize, Deserializer, Serialize};
use rocket::response::Result;
use log::error;
use quick_xml::events::{BytesDecl, Event};
use quick_xml::se::Serializer;
use quick_xml::{DeError, SeError};
use rocket::data::{ByteUnit, FromData, Outcome};
use rocket::response::content::RawXml;
use rocket::response::status::BadRequest;
use serde::__private::de::UntaggedUnitVisitor;
use serde::de::{DeserializeOwned, Error, Visitor};
use thiserror::Error;
pub fn serialize_with_version(serializable: &impl Serialize) -> result::Result<Box<str>, SeError>{
let mut write_dest = "<?xml version=\"1.0\"?>".to_owned();
serializable.serialize(Serializer::new(&mut write_dest))?;
Ok(write_dest.into_boxed_str())
}
#[derive(Debug)]
pub struct Xml<T>(pub T);
impl<T> Deref for Xml<T>{
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> DerefMut for Xml<T>{
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<'r, 'o: 'r, T: Serialize> Responder<'r, 'o> for Xml<T>{
fn respond_to(self, request: &'r Request<'_>) -> Result<'o> {
match quick_xml::se::to_string(&self.0){
match serialize_with_version(&self.0){
Ok(ser) => {
RawXml(ser).respond_to(request)
},
@ -21,4 +55,96 @@ impl<'r, 'o: 'r, T: Serialize> Responder<'r, 'o> for Xml<T>{
}
}
}
}
#[async_trait]
impl<'r, T: DeserializeOwned> FromData<'r> for Xml<T>{
type Error = Option<DeError>;
async fn from_data(req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r, Self> {
let data = data.open(1 * ByteUnit::MB);
let Ok(data) = data.into_string().await else {
return Outcome::Error((Status::BadRequest, None))
};
match quick_xml::de::from_str(&data){
Ok(v) => Outcome::Success(Self(v)),
Err(e) => Outcome::Error((Status::BadRequest, Some(e)))
}
}
}
pub struct YesNoVal(pub bool);
struct YesNoVisitor;
#[derive(Debug, Error)]
#[error("did not find Y or N")]
struct NotYNError;
impl<'de> Visitor<'de> for YesNoVisitor{
type Value = YesNoVal;
fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
write!(formatter, "expected Y or N")
}
fn visit_str<E>(self, v: &str) -> result::Result<Self::Value, E>
where
E: Error,
{
match v{
"Y" => Ok(YesNoVal(true)),
"N" => Ok(YesNoVal(false)),
_ => Err(E::custom("didnt get N or Y"))
}
}
fn visit_bytes<E>(self, v: &[u8]) -> result::Result<Self::Value, E>
where
E: Error,
{
const Y_BYTES: &[u8] = "Y".as_bytes();
const N_BYTES: &[u8] = "N".as_bytes();
match v{
Y_BYTES => Ok(YesNoVal(true)),
N_BYTES => Ok(YesNoVal(false)),
_ => Err(E::custom("didnt get N or Y"))
}
}
}
impl<'de> Deserialize<'de> for YesNoVal{
fn deserialize<D>(deserializer: D) -> result::Result<Self, D::Error>
where
D: Deserializer<'de>
{
deserializer.deserialize_str(YesNoVisitor)
}
}
impl Serialize for YesNoVal{
fn serialize<S>(&self, serializer: S) -> result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_char(
match self.0{
true => 'Y',
false => 'N',
}
)
}
}