From f32e69be99fa47b9c6be81761b15f93e6f3f3fb8 Mon Sep 17 00:00:00 2001 From: Maple Nebel Date: Wed, 15 Jul 2026 17:37:53 +0200 Subject: [PATCH] serial --- src/account/account.rs | 24 +++-- src/json_api/oauth/generate_token.rs | 106 ++++++++++++-------- src/nnid/oauth/generate_token.rs | 139 +++++++++++++++------------ src/nnid/provider.rs | 60 +++++++++++- 4 files changed, 219 insertions(+), 110 deletions(-) diff --git a/src/account/account.rs b/src/account/account.rs index fd58a1b..41c285a 100644 --- a/src/account/account.rs +++ b/src/account/account.rs @@ -117,7 +117,10 @@ pub fn generate_password(pid: i32, cleartext_password: &str) -> Option { bcrypt::hash(password, 10).ok() } -pub async fn read_basic_auth_token(connection: &Pool, token: &str) -> Option { +pub async fn read_basic_auth_token( + connection: &Pool, + token: &str, +) -> Option<(User, Option<[u8; 32]>)> { let data = match BASE64_STANDARD.decode(&token) { Ok(d) => d, Err(e) => { @@ -161,13 +164,16 @@ pub async fn read_basic_auth_token(connection: &Pool, token: &str) -> Option Option { +pub async fn read_bearer_auth_token( + connection: &Pool, + token: &str, +) -> Option<(User, Option<[u8; 32]>)> { let data = TokenData::decode(token)?; let token_info = sqlx::query!( @@ -189,11 +195,11 @@ pub async fn read_bearer_auth_token(connection: &Pool, token: &str) -> Option String { - let mut rng = rand::rng(); + let mut rng = rand::rng(); let mut output = String::with_capacity(16); while output.len() < 16 { @@ -214,6 +220,7 @@ pub fn generate_nex_password() -> String { pub struct Auth( pub User, + pub Option<[u8; 32]>, ); impl AsRef @@ -278,6 +285,7 @@ pub async fn link_certificate_to_pid( Ok(()) } +// todo: make this more consistent by requiring a cert to be asociated to the token if USE_CERT is set #[async_trait] impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r> for Auth @@ -297,13 +305,13 @@ impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r> let (auth_type, token) = request_try!(auth.split_once(' ').ok_or(INVALID_TOKEN_ERRORS)); - let user = match auth_type { + let data = match auth_type { "Basic" if !FORCE_BEARER_AUTH => read_basic_auth_token(pool, token).await, "Bearer" => read_bearer_auth_token(pool, token).await, _ => return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS)), }; - let Some(user) = user else { + let Some((user, cert)) = data else { return Outcome::Error((Status::BadRequest, INVALID_TOKEN_ERRORS)); }; @@ -316,7 +324,7 @@ impl<'r, const FORCE_BEARER_AUTH: bool, const USE_CERT: bool> FromRequest<'r> // ..user // }; - Outcome::Success(Self(user)) + Outcome::Success(Self(user, cert)) } } diff --git a/src/json_api/oauth/generate_token.rs b/src/json_api/oauth/generate_token.rs index 72843d7..767e374 100644 --- a/src/json_api/oauth/generate_token.rs +++ b/src/json_api/oauth/generate_token.rs @@ -1,19 +1,18 @@ use crate::Pool; -use serde::Serialize; -use rocket::FromForm; -use sha2::{Sha256, Digest}; -use bytemuck::bytes_of; -use rocket::{post, State, form::Form, http::Status, serde::json::Json}; -use sqlx::Row; -use std::env; -use once_cell::sync::Lazy; use crate::nnid::oauth::generate_token::create_token; use crate::nnid::oauth::generate_token::token_type::AUTH_TOKEN; +use bytemuck::bytes_of; use chrono::Utc; +use once_cell::sync::Lazy; +use rocket::FromForm; +use rocket::{State, form::Form, http::Status, post, serde::json::Json}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use sqlx::Row; +use std::env; -pub static CLIENT_SECRET: Lazy = Lazy::new(|| { - env::var("OAUTH_CLIENT_SECRET").expect("OAUTH_CLIENT_SECRET not set") -}); +pub static CLIENT_SECRET: Lazy = + Lazy::new(|| env::var("OAUTH_CLIENT_SECRET").expect("OAUTH_CLIENT_SECRET not set")); #[derive(Serialize)] pub struct OAuthTokenResponse { @@ -52,17 +51,18 @@ pub struct OAuthErrorResponse { #[post("/api/v2/oauth2/generate_token", data = "")] pub async fn generate_token( - pool: &State, - form_data: Form> + pool: &State, + form_data: Form>, ) -> Result, (Status, Json)> { - match form_data.client_id { Some("account") | Some("splatnet") => { let cl_secret: String = CLIENT_SECRET.clone(); if form_data.client_secret != Some(&cl_secret) { return Err(( Status::Unauthorized, - Json(OAuthErrorResponse { error: "invalid_client".to_string() }) + Json(OAuthErrorResponse { + error: "invalid_client".to_string(), + }), )); } } @@ -72,7 +72,9 @@ pub async fn generate_token( _ => { return Err(( Status::BadRequest, - Json(OAuthErrorResponse { error: "invalid_client".to_string() }) + Json(OAuthErrorResponse { + error: "invalid_client".to_string(), + }), )); } } @@ -82,23 +84,31 @@ pub async fn generate_token( "password" => { let username = form_data.username.ok_or(( Status::BadRequest, - Json(OAuthErrorResponse { error: "invalid_request".to_string() }) + Json(OAuthErrorResponse { + error: "invalid_request".to_string(), + }), ))?; let password = form_data.password.ok_or(( Status::BadRequest, - Json(OAuthErrorResponse { error: "invalid_request".to_string() }) + Json(OAuthErrorResponse { + error: "invalid_request".to_string(), + }), ))?; let user_row = match sqlx::query("SELECT pid, password FROM users WHERE username = $1") .bind(username) .fetch_optional(pool.inner()) - .await + .await { Ok(Some(row)) => row, - _ => return Err(( - Status::BadRequest, - Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) - )), + _ => { + return Err(( + Status::BadRequest, + Json(OAuthErrorResponse { + error: "invalid_grant".to_string(), + }), + )); + } }; let db_pid: i32 = user_row.get("pid"); @@ -107,31 +117,39 @@ pub async fn generate_token( if !verify_nintendo_password(db_pid, password, &db_bcrypt_hash) { return Err(( Status::Unauthorized, - Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) + Json(OAuthErrorResponse { + error: "invalid_grant".to_string(), + }), )); } db_pid - }, + } "authorization_code" => { let incoming_code = form_data.code.ok_or(( - Status::BadRequest, - Json(OAuthErrorResponse { error: "invalid_request".to_string() }) + Status::BadRequest, + Json(OAuthErrorResponse { + error: "invalid_request".to_string(), + }), ))?; - + let code_row = match sqlx::query( - "SELECT pid, expires_at, used FROM oauth_auth_codes WHERE code = $1" + "SELECT pid, expires_at, used FROM oauth_auth_codes WHERE code = $1", ) .bind(incoming_code) .fetch_optional(pool.inner()) .await { Ok(Some(row)) => row, - _ => return Err(( - Status::BadRequest, - Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) - )), + _ => { + return Err(( + Status::BadRequest, + Json(OAuthErrorResponse { + error: "invalid_grant".to_string(), + }), + )); + } }; let is_used: bool = code_row.get("used"); @@ -140,8 +158,10 @@ pub async fn generate_token( if is_used || expires_at < Utc::now().naive_utc() { return Err(( - Status::BadRequest, - Json(OAuthErrorResponse { error: "invalid_grant".to_string() }) + Status::BadRequest, + Json(OAuthErrorResponse { + error: "invalid_grant".to_string(), + }), )); } @@ -151,15 +171,19 @@ pub async fn generate_token( .await; target_pid - }, + } - _ => return Err(( - Status::BadRequest, - Json(OAuthErrorResponse { error: "unsupported_grant_type".to_string() }) - )) + _ => { + return Err(( + Status::BadRequest, + Json(OAuthErrorResponse { + error: "unsupported_grant_type".to_string(), + }), + )); + } }; - let token = create_token(pool.inner(), pid, AUTH_TOKEN, None).await; + let token = create_token(pool.inner(), pid, AUTH_TOKEN, None, None).await; Ok(Json(OAuthTokenResponse { access_token: token, diff --git a/src/nnid/oauth/generate_token.rs b/src/nnid/oauth/generate_token.rs index a239ab3..0f16371 100644 --- a/src/nnid/oauth/generate_token.rs +++ b/src/nnid/oauth/generate_token.rs @@ -1,50 +1,43 @@ #![allow(unused)] -use rocket::{post, FromForm, State}; -use rocket::form::Form; -use serde::{Serialize}; +use crate::Pool; use crate::account::account::{Auth, DeviceCert, User, link_certificate_to_pid}; use crate::error::{Error, Errors}; use crate::nnid::agreements::{CFIP, EVIL_AGREEMENT_THING}; -use crate::nnid::oauth::generate_token::token_type::{AUTH_REFRESH_TOKEN, AUTH_TOKEN}; use crate::nnid::oauth::TokenData; -use crate::Pool; +use crate::nnid::oauth::generate_token::token_type::{AUTH_REFRESH_TOKEN, AUTH_TOKEN}; use crate::xml::Xml; +use rocket::form::Form; +use rocket::{FromForm, State, post}; +use serde::Serialize; -pub mod token_type{ +pub mod token_type { pub const AUTH_REFRESH_TOKEN: i32 = 1; pub const AUTH_TOKEN: i32 = 0; pub const NEX_TOKEN: i32 = 2; } -const ACCOUNT_ID_OR_PASSWORD_ERRORS: Errors = Errors{ - error: &[ - Error{ - code: "0106", - message: "Invalid account ID or password" - } - ] +const ACCOUNT_ID_OR_PASSWORD_ERRORS: Errors = Errors { + error: &[Error { + code: "0106", + message: "Invalid account ID or password", + }], }; -const ACCOUNT_BANNED_ERRORS: Errors = Errors{ - error: &[ - Error{ - code: "0108", - message: "Account banned from server" - } - ] +const ACCOUNT_BANNED_ERRORS: Errors = Errors { + error: &[Error { + code: "0108", + message: "Account banned from server", + }], }; - -const REREAD_EULA_EXTRABANNED_ERRORS: Errors = Errors{ - error: &[ - Error{ - code: "0109", - message: "REREAD THE EULA LOL" - } - ] +const REREAD_EULA_EXTRABANNED_ERRORS: Errors = Errors { + error: &[Error { + code: "0109", + message: "REREAD THE EULA LOL", + }], }; #[derive(FromForm)] -pub struct TokenRequestData<'a>{ +pub struct TokenRequestData<'a> { grant_type: &'a str, user_id: &'a str, password: &'a str, @@ -55,17 +48,27 @@ pub struct TokenRequestData<'a>{ pub struct TokenReturnData { token: String, refresh_token: String, - expires_in: i32 + expires_in: i32, } -pub async fn create_token(pool: &Pool, pid: i32, token_type: i32, title_id: Option<&str>) -> String{ +pub async fn create_token( + pool: &Pool, + pid: i32, + token_type: i32, + title_id: Option<&str>, + cert_hash: Option<&[u8]>, +) -> String { let data = sqlx::query!( - "insert into tokens (token_type, pid, title_id) - values ($1, $2, $3) returning token_id, random", - token_type, pid, title_id - ) - .fetch_one(pool) - .await.unwrap(); + "insert into tokens (token_type, pid, title_id, cert_hash) + values ($1, $2, $3, $4) returning token_id, random", + token_type, + pid, + title_id, + cert_hash + ) + .fetch_one(pool) + .await + .unwrap(); let token_id = data.token_id; let random = data.random; @@ -73,61 +76,79 @@ pub async fn create_token(pool: &Pool, pid: i32, token_type: i32, title_id: Opti let token = TokenData { token_id, random, - pid + pid, }; token.encode().to_string() } - impl TokenReturnData { - async fn new(pid: i32, pool: &Pool) -> Self{ - let token = create_token(pool, pid, AUTH_TOKEN, None).await; + async fn new(pid: i32, pool: &Pool) -> Self { + let token = create_token(pool, pid, AUTH_TOKEN, None, None).await; - let refresh_token = create_token(pool, pid, AUTH_REFRESH_TOKEN, None).await; + let refresh_token = create_token(pool, pid, AUTH_REFRESH_TOKEN, None, None).await; - Self{ + Self { token, refresh_token, - expires_in: 3600 + expires_in: 3600, + } + } + async fn new_with_cert(pid: i32, cert: Option<&[u8]>, pool: &Pool) -> Self { + let token = create_token(pool, pid, AUTH_TOKEN, None, None).await; + + let refresh_token = create_token(pool, pid, AUTH_REFRESH_TOKEN, None, None).await; + + Self { + token, + refresh_token, + expires_in: 3600, } } } #[derive(Serialize)] -#[serde(rename="OAuth20")] -pub struct TokenRequestReturnData{ - access_token: TokenReturnData +#[serde(rename = "OAuth20")] +pub struct TokenRequestReturnData { + access_token: TokenReturnData, } -#[post("/v1/api/oauth20/access_token/generate", data="")] -pub async fn generate_token(pool: &State, data: Form>, ip: CFIP, cert: DeviceCert) -> Result, Option>>{ +#[post("/v1/api/oauth20/access_token/generate", data = "")] +pub async fn generate_token( + pool: &State, + data: Form>, + ip: CFIP, + cert: DeviceCert, +) -> Result, Option>> { let pool = pool.inner(); - let user = User::get_by_username(data.user_id, pool).await + let user = User::get_by_username(data.user_id, pool) + .await .ok_or(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS))?; - if !user.verify_hashed_password(&data.password).is_some_and(|v| v){ + if !user + .verify_hashed_password(&data.password) + .is_some_and(|v| v) + { return Err(Some(ACCOUNT_ID_OR_PASSWORD_ERRORS)); } - if user.account_level < 0{ + if user.account_level < 0 { if user.account_level == -2 { return Err(Some(REREAD_EULA_EXTRABANNED_ERRORS)); } - if user.account_level == -3{ + if user.account_level == -3 { EVIL_AGREEMENT_THING.write().await.insert(ip.0); return Err(Some(REREAD_EULA_EXTRABANNED_ERRORS)); } return Err(Some(ACCOUNT_BANNED_ERRORS)); } - + link_certificate_to_pid(&pool, &cert.0, user.pid).await?; - let access_token = TokenReturnData::new(user.pid, pool).await; + let access_token = + TokenReturnData::new_with_cert(user.pid, Some(&cert.0.hash()[..]), pool).await; - Ok(Xml(TokenRequestReturnData{ - access_token - })) -} \ No newline at end of file + Ok(Xml(TokenRequestReturnData { access_token })) +} diff --git a/src/nnid/provider.rs b/src/nnid/provider.rs index 850b47e..bbe41d5 100644 --- a/src/nnid/provider.rs +++ b/src/nnid/provider.rs @@ -4,10 +4,14 @@ use crate::error::{Error, Errors}; use crate::nnid::oauth::generate_token::create_token; use crate::nnid::oauth::generate_token::token_type::NEX_TOKEN; use crate::xml::Xml; +use log::{info, warn}; use nex_account::grpc::Pid; use reqwest::header::SERVER; -use rocket::{State, get}; +use rocket::http::Status; +use rocket::request::{FromRequest, Outcome, Request}; +use rocket::{State, async_trait, get}; use serde::Serialize; +use sqlx::query; use sqlx::types::ipnetwork::IpNetwork::V4; use std::net::Ipv4Addr; @@ -55,10 +59,52 @@ pub struct ServiceToken { token: String, } +pub async fn store_or_check_serial(pool: &Pool, serial: &str, cert_hash: [u8; 32]) -> bool { + let Ok(res) = query!( + "select serial from certificates where hash = $1", + &cert_hash[..] + ) + .fetch_one(pool) + .await + else { + warn!( + "user tried to access a route which is locked behind a console asociated token without a console token" + ); + return false; + }; + + let Some(stored_serial) = res.serial else { + query!( + "update certificates set serial = $1 where hash = $2", + serial, + &cert_hash[..] + ); + return true; + }; + + serial == stored_serial +} + +struct Serial(String); + +#[async_trait] +impl<'r> FromRequest<'r> for Serial { + type Error = Errors<'static>; + async fn from_request(request: &'r Request<'_>) -> Outcome { + let Some(header) = request.headers().get("X-Nintendo-Serial-Number").next() else { + warn!("serial number wasnt sent on request which expected a serial number"); + return Outcome::Error((Status::BadRequest, SERVER_ERROR)); + }; + + Outcome::Success(Self(header.to_owned())) + } +} + #[get("/v1/api/provider/service_token/@me")] pub async fn get_service_token( pool: &State, auth: Auth, + serial: Serial, ) -> Result, Option>> { // just gonna put this here as a side note for the future: // we could also be using key derivation to derive the nex token as if it were a key @@ -69,7 +115,17 @@ pub async fn get_service_token( let pool = pool.inner(); - let token = create_token(pool, auth.pid, NEX_TOKEN, None).await; + let Some(cert_hash) = auth.1 else { + info!("attempt to generate service token using non wii u/certificate token"); + return Err(Some(SERVER_ERROR)); + }; + + if !store_or_check_serial(pool, &serial.0, cert_hash).await { + info!("serial mismatched with certificate"); + return Err(Some(SERVER_ERROR)); + } + + let token = create_token(pool, auth.pid, NEX_TOKEN, None, Some(&cert_hash[..])).await; Ok(Xml(ServiceToken { token })) }