diff --git a/Cargo.lock b/Cargo.lock index 43a3bc23..50ff1ab6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -294,6 +294,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -892,6 +898,18 @@ version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -1152,6 +1170,20 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.13.0" @@ -1161,6 +1193,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "2.5.0" @@ -1259,6 +1311,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1592,6 +1654,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1730,6 +1793,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.1" @@ -2934,6 +3008,7 @@ dependencies = [ "async-trait", "async_zip", "base64 0.22.1", + "byteorder", "bytes", "chrono", "digest", @@ -2950,6 +3025,7 @@ dependencies = [ "onelauncher_gamemode", "onelauncher_macro", "onelauncher_migration", + "p256", "paste", "rand 0.8.5", "regex", @@ -3066,6 +3142,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "pango" version = "0.18.3" @@ -3406,6 +3494,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3834,6 +3931,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "194d8e591e405d1eecf28819740abed6d719d1a2db87fc0bcdedee9a26d55560" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.8" @@ -4211,6 +4318,20 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "selectors" version = "0.22.0" diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 56cec8f4..0c7852fd 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -66,6 +66,7 @@ rand = { workspace = true } url = { workspace = true } async-trait = { workspace = true } paste = { workspace = true } +byteorder = { workspace = true } # integration discord-rich-presence = { workspace = true } @@ -76,6 +77,7 @@ sha1 = { workspace = true } sha2 = { workspace = true } murmur2 = { workspace = true } md-5 = { workspace = true } +p256 = { workspace = true } # io zip = { workspace = true } diff --git a/packages/core/examples/auth.rs b/packages/core/examples/auth.rs new file mode 100644 index 00000000..8e94c8a0 --- /dev/null +++ b/packages/core/examples/auth.rs @@ -0,0 +1,25 @@ +use onelauncher_core::{api::{credentials, proxy::ProxyDynamic}, error::LauncherResult, initialize_core, store::CoreOptions, utils::io::IOError}; +use tokio::io::AsyncBufReadExt; + +#[tokio::main] +pub async fn main() -> LauncherResult<()> { + initialize_core(CoreOptions::default(), ProxyDynamic::new()).await?; + + let flow = credentials::begin().await?; + println!("{flow:#?}"); + + let stdin = tokio::io::stdin(); + let mut reader = tokio::io::BufReader::new(stdin); + let mut code = String::new(); + + println!("Please enter the code you received:"); + reader.read_line(&mut code).await.map_err(IOError::from)?; + println!("Please wait"); + code = code.trim().to_string(); + + let creds = credentials::finish(&code, flow).await?; + + println!("Credentials: {creds:#?}"); + + Ok(()) +} \ No newline at end of file diff --git a/packages/core/src/api/credentials.rs b/packages/core/src/api/credentials.rs new file mode 100644 index 00000000..9141acdf --- /dev/null +++ b/packages/core/src/api/credentials.rs @@ -0,0 +1,87 @@ +//! Authentication flow manager + +use crate::{error::LauncherResult, store::{credentials::{MinecraftCredentials, MinecraftLogin}, State}}; + +/// Begin a Microsoft authentication flow. +#[tracing::instrument] +pub async fn begin() -> LauncherResult { + let state = State::get().await?; + let mut store = state.credentials.write().await; + + store.begin().await +} + +/// Complete a Microsoft authentication flow to recieve [`MinecraftCredentials`]. +#[tracing::instrument] +pub async fn finish(code: &str, flow: MinecraftLogin) -> LauncherResult { + let state = State::get().await?; + let mut store = state.credentials.write().await; + + store.finish(code, flow).await +} + +/// Get the current default user if it exists by [`uuid::Uuid`]. +#[tracing::instrument] +pub async fn get_default_user() -> LauncherResult> { + let state = State::get().await?; + let store = state.credentials.read().await; + + Ok(store.default_user) +} + +/// Set the current default user by [`uuid::Uuid`]. +#[tracing::instrument] +pub async fn set_default_user(user: Option) -> LauncherResult<()> { + let user = match user { + Some(user) => Some(get_user(user).await?.id), + None => None, + }; + + let state = State::get().await?; + let mut store = state.credentials.write().await; + + store.default_user = user; + store.save().await?; + + Ok(()) +} + +/// Remove a user account by its [`uuid::Uuid`] from the global store. +#[tracing::instrument] +pub async fn remove_user(user: uuid::Uuid) -> LauncherResult<()> { + let state = State::get().await?; + let mut store = state.credentials.write().await; + + store.remove(user).await?; + + if store.default_user == Some(user) { + set_default_user(Some(user)).await?; + } + + Ok(()) +} + +/// Get a list of user [`MinecraftCredentials`]. +#[tracing::instrument] +pub async fn users() -> LauncherResult> { + let state = State::get().await?; + let store = state.credentials.read().await; + + Ok(store.users.values().cloned().collect()) +} + +/// Get a specifc user's [`MinecraftCredentials`] by their [`uuid::Uuid`]. +/// Use [`crate::store::MinecraftState#refresh`] instead. +#[tracing::instrument] +pub async fn get_user(user: uuid::Uuid) -> LauncherResult { + let state = State::get().await?; + let store = state.credentials.read().await; + + let user = store + .users + .get(&user) + .ok_or_else(|| anyhow::anyhow!("failed to get nonexistent user with uuid {user}"))? + .clone(); + + Ok(user) +} diff --git a/packages/core/src/api/mod.rs b/packages/core/src/api/mod.rs index 0b05697c..63406fc2 100644 --- a/packages/core/src/api/mod.rs +++ b/packages/core/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod cluster; +pub mod credentials; pub mod ingress; pub mod game; pub mod proxy; diff --git a/packages/core/src/constants.rs b/packages/core/src/constants.rs index d79b2950..3cfe8ab5 100644 --- a/packages/core/src/constants.rs +++ b/packages/core/src/constants.rs @@ -32,22 +32,6 @@ pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1"; /// metadata base url. pub const SKYCLIENT_BASE_URL: &str = "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1"; -// =========== Paths =========== -/// The public `settings.json` file used to store the global [`Settings`] state. -/// -/// [`Settings`]: crate::store::Settings -pub const SETTINGS_FILE: &str = "settings.json"; -/// The public `authentication.json` file used to store the global [`MinecraftAuth`] state. -/// -/// [`MinecraftAuth`]: crate::store::MinecraftAuth -pub const AUTH_FILE: &str = "authentication.json"; - -/// The current [`Settings`] format version, bumped for breaking changes. -/// If updated, a config file migration logic **NEEDS** to be implemented. -/// -/// [`Settings`]: crate::store::Settings -pub const CURRENT_SETTINGS_FORMAT_VERSION: u32 = 2; - // =========== Hacky Mojang-spec OS constants =========== #[cfg(target_os = "windows")] pub const TARGET_OS: &str = "windows"; diff --git a/packages/core/src/error.rs b/packages/core/src/error.rs index 00b31244..73ec2d36 100644 --- a/packages/core/src/error.rs +++ b/packages/core/src/error.rs @@ -15,9 +15,11 @@ pub enum LauncherError { #[error(transparent)] MetadataError(#[from] crate::store::metadata::MetadataError), #[error(transparent)] - DaoError(#[from] DaoError), - #[error(transparent)] ClusterError(#[from] crate::api::cluster::ClusterError), + #[error(transparent)] + MinecraftAuthError(#[from] crate::store::credentials::MinecraftAuthError), + #[error(transparent)] + DaoError(#[from] DaoError), #[error(transparent)] SerdeError(#[from] serde_json::Error), diff --git a/packages/core/src/store/credentials.rs b/packages/core/src/store/credentials.rs new file mode 100644 index 00000000..f07939ab --- /dev/null +++ b/packages/core/src/store/credentials.rs @@ -0,0 +1,1049 @@ +//! Handles MSA Authentication flow. + +use base64::Engine; +use chrono::{DateTime, Utc}; +use p256::ecdsa::signature::Signer; +use p256::ecdsa::SigningKey; +use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::Digest; +use std::collections::HashMap; +use std::fmt::Write; +use uuid::Uuid; + +use crate::constants::MINECRAFT_SCOPES; +use crate::error::{LauncherError, LauncherResult}; +use crate::store::Core; +use crate::utils::io; + +use super::Dirs; + +/// The core state of Microsoft authentication for the launcher +#[derive(Serialize, Deserialize, Debug)] +pub struct CredentialsStore { + /// A [`HashMap`] of all logged in users by [`Uuid`] and their Minecraft credentials. + pub users: HashMap, + /// The current stored device token to be used while logging in. + pub token: Option, + /// The default Minecraft user which the launcher uses, by [`Uuid`]. + pub default_user: Option, +} + +impl CredentialsStore { + /// Initialize a new Minecraft global state. + #[tracing::instrument] + pub async fn initialize() -> LauncherResult { + let path = Dirs::get_auth_file().await?; + let store = io::read_json(&path) + .await + .ok(); + + store.map_or_else( + || { + Ok(Self { + users: HashMap::new(), + token: None, + default_user: None, + }) + }, + Ok, + ) + } + + /// Save the current Minecraft credentials. + #[tracing::instrument(skip(self))] + pub async fn save(&self) -> LauncherResult<()> { + let path = Dirs::get_auth_file().await?; + io::write_json(&path, &self).await?; + + Ok(()) + } + + /// Refresh all tokens in the global state. + #[tracing::instrument(skip(self))] + async fn refresh( + &mut self, + current_date: DateTime, + force: bool, + ) -> LauncherResult<(DeviceKey, DeviceToken, DateTime, bool)> { + macro_rules! device_key { + ($self:ident, $device_key:expr, $device_token:expr, $SaveDeviceToken:path) => {{ + let key = device_key()?; + let res = device_token(&key, current_date).await?; + + self.token = Some(SaveDeviceToken { + id: key.id.clone(), + private_key: key + .key + .to_pkcs8_pem(p256::pkcs8::LineEnding::default()) + .map_err(|err| MinecraftAuthError::PKCS8Error(err))? + .to_string(), + x: key.x.clone(), + y: key.y.clone(), + token: res.value.clone(), + }); + self.save().await?; + + (key, res.value, res.date, true) + }}; + } + + let (key, token, date, valid_date) = if let Some(ref token) = self.token { + if let Ok(private_key) = SigningKey::from_pkcs8_pem(&token.private_key) { + if token.token.not_after > Utc::now() && !force { + ( + DeviceKey { + id: token.id.clone(), + key: private_key, + x: token.x.clone(), + y: token.y.clone(), + }, + token.token.clone(), + current_date, + false, + ) + } else { + let key = DeviceKey { + id: token.id.clone(), + key: private_key, + x: token.x.clone(), + y: token.y.clone(), + }; + + let res = device_token(&key, current_date).await?; + + (key, res.value, res.date, true) + } + } else { + device_key!(self, device_key, device_token, SaveDeviceToken) + } + } else { + device_key!(self, device_key, device_token, SaveDeviceToken) + }; + + Ok((key, token, date, valid_date)) + } + + /// Begin a Microsoft authentication flow. + #[tracing::instrument(skip(self))] + pub async fn begin(&mut self) -> LauncherResult { + let (key, token, current_date, valid_date) = self.refresh(Utc::now(), false).await?; + let verify = generate_oauth_challenge(); + let mut hash = sha2::Sha256::new(); + hash.update(&verify); + let hash_result = hash.finalize(); + let challenge = base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(hash_result); + + match sisu_authenticate(&token.token, &challenge, &key, current_date).await { + Ok((session_id, redirect_uri)) => Ok(MinecraftLogin { + verify, + challenge, + session_id, + redirect_uri: redirect_uri.value.msa_oauth_redirect, + }), + Err(err) => { + if valid_date { + Err(err.into()) + } else { + let (key, token, current_date, _) = self.refresh(Utc::now(), false).await?; + let verify = generate_oauth_challenge(); + let mut hash = sha2::Sha256::new(); + hash.update(&verify); + let hash_result = hash.finalize(); + let challenge = base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(hash_result); + let (session_id, redirect_uri) = + sisu_authenticate(&token.token, &challenge, &key, current_date).await?; + + Ok(MinecraftLogin { + verify, + challenge, + session_id, + redirect_uri: redirect_uri.value.msa_oauth_redirect, + }) + } + } + } + } + + /// Finish a Microsoft authentication flow. + #[tracing::instrument(skip(self))] + pub async fn finish( + &mut self, + code: &str, + flow: MinecraftLogin, + ) -> LauncherResult { + let (key, token, _, _) = self.refresh(Utc::now(), false).await?; + let oauth_token = oauth_token(code, &flow.verify).await?; + let sisu_authorize = sisu_authorize( + Some(&flow.session_id), + &oauth_token.value.access_token, + &token.token, + &key, + oauth_token.date, + ) + .await?; + let xbox_token = xsts_authorize( + sisu_authorize.value, + &token.token, + &key, + sisu_authorize.date, + ) + .await?; + let minecraft_token = minecraft_token(xbox_token.value).await?; + minecraft_entitlements(&minecraft_token.access_token).await?; + let profile = minecraft_profile(&minecraft_token.access_token).await?; + let profile_id = profile.id.unwrap_or_default(); + let credentials = MinecraftCredentials { + id: profile_id, + username: profile.name, + access_token: minecraft_token.access_token, + refresh_token: oauth_token.value.refresh_token, + #[allow(clippy::cast_possible_wrap)] + expires: oauth_token.date + chrono::TimeDelta::seconds(oauth_token.value.expires_in as i64), + }; + + self.users.insert(profile_id, credentials.clone()); + + if self.default_user.is_none() { + self.default_user = Some(profile_id); + } + + self.save().await?; + + Ok(credentials) + } + + /// Refresh the current stored [`MinecraftCredentials`]. + async fn refresh_token( + &mut self, + creds: &MinecraftCredentials, + ) -> LauncherResult> { + let cred_id = creds.id; + let cred_name = creds.username.clone(); + let oauth_token = oauth_refresh(&creds.refresh_token).await?; + let (key, token, current_date, _) = self.refresh(oauth_token.date, false).await?; + let sisu_authorize = sisu_authorize( + None, + &oauth_token.value.access_token, + &token.token, + &key, + current_date, + ) + .await?; + let xbox_token = xsts_authorize( + sisu_authorize.value, + &token.token, + &key, + sisu_authorize.date, + ) + .await?; + let minecraft_token = minecraft_token(xbox_token.value).await?; + let val = MinecraftCredentials { + id: cred_id, + username: cred_name, + access_token: minecraft_token.access_token, + refresh_token: oauth_token.value.refresh_token, + #[allow(clippy::cast_possible_wrap)] + expires: oauth_token.date + chrono::TimeDelta::seconds(oauth_token.value.expires_in as i64), + }; + + self.users.insert(val.id, val.clone()); + self.save().await?; + + Ok(Some(val)) + } + + /// Get the default user account as a set of optional [`MinecraftCredentials`]. + #[tracing::instrument(skip(self))] + pub async fn get_default(&mut self) -> LauncherResult> { + let credentials = if let Some(default_user) = self.default_user { + if let Some(creds) = self.users.get(&default_user) { + Some(creds) + } else { + self.users.values().next() + } + } else { + self.users.values().next() + }; + + if let Some(creds) = credentials { + if self.default_user != Some(creds.id) { + self.default_user = Some(creds.id); + self.save().await?; + } + + if creds.expires < Utc::now() { + let old_creds = creds.clone(); + let res = self.refresh_token(&old_creds).await; + + match res { + Ok(val) => Ok(val), + Err(err) => { + match err { + LauncherError::MinecraftAuthError(MinecraftAuthError::RequestError { + ref source, + .. + }) if source.is_connect() || source.is_timeout() => { + return Ok(Some(old_creds)); + } + _ => {} + } + + Err(err) + } + } + } else { + Ok(Some(creds.clone())) + } + } else { + Ok(None) + } + } + + /// Remove a set of [`MinecraftCredentials`] from a Uuid. + #[tracing::instrument(skip(self))] + pub async fn remove(&mut self, id: Uuid) -> LauncherResult> { + let val = self.users.remove(&id); + + if self.default_user == Some(id) { + self.default_user = None; + } + + self.save().await?; + Ok(val) + } +} + +/// A [`reqwest::Request`] with a [`DateTime`] attached to it. +struct RequestWithDate { + pub date: DateTime, + pub value: T, +} + +/// A device token used for Microsoft authentication. +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct DeviceToken { + /// The time that the token was issued in [`DateTime`]. + pub issue_instant: DateTime, + /// The expiration date of this token in [`DateTime`]. + pub not_after: DateTime, + /// The token as a string. + pub token: String, + /// The JSON-encoded [`HashMap`] of display claims. + pub display_claims: HashMap, +} + +/// A transferrable EC key translated to a [`SaveDeviceToken`] +#[derive(Debug)] +pub struct DeviceKey { + /// The ID of this device key. + pub id: String, + /// The EC private key. + pub key: SigningKey, + /// The EC `x` value. + pub x: String, + /// The EC `y` value. + pub y: String, +} + +/// A serializable device token that is used to finish authenticaton. +#[derive(Serialize, Deserialize, Debug)] +pub struct SaveDeviceToken { + /// The ID of the token. + pub id: String, + /// The private key associated with the token. + pub private_key: String, + /// The EC `x` value of the key. + pub x: String, + /// The EC `y` value of the key. + pub y: String, + /// The assocated [`DeviceToken`]. + pub token: DeviceToken, +} + +/// Core variables passed throughout the Minecraft login flow. +#[derive(Serialize, Deserialize, Debug)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub struct MinecraftLogin { + pub verify: String, + /// The OAuth challenge generated at the start of the flow. + pub challenge: String, + /// The generated session id. + pub session_id: String, + /// The xboxlive redirect URI. + pub redirect_uri: String, +} + +/// A structure of all needed Minecraft credentials for logging in and account management. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub struct MinecraftCredentials { + /// The uuid of the credentials. + pub id: Uuid, + /// The username of the Minecraft account. + pub username: String, + /// The access token as a String. + pub access_token: String, + /// The refresh token as a string for [`MinecraftState#refresh`]. + pub refresh_token: String, + /// The time that the access token expires as a [`DateTime`]. + pub expires: DateTime, +} + +#[tracing::instrument(skip(key))] +pub async fn device_token( + key: &DeviceKey, + current_date: DateTime, +) -> Result, MinecraftAuthError> { + let res = send_signed_request( + None, + "https://device.auth.xboxlive.com/device/authenticate", + "/device/authenticate", + json!({ + "Properties": { + "AuthMethod": "ProofOfPossession", + "Id": format!("{{{}}}", key.id), + "DeviceType": "Win32", + "Version": "10.16.0", + "ProofKey": { + "kty": "EC", + "x": key.x, + "y": key.y, + "crv": "P-256", + "alg": "ES256", + "use": "sig" + } + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" + }), + key, + MinecraftAuthStep::DeviceToken, + current_date, + ) + .await?; + + Ok(RequestWithDate { + date: res.current_date, + value: res.body, + }) +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RedirectUri { + pub msa_oauth_redirect: String, +} + +#[tracing::instrument(skip(key))] +async fn sisu_authenticate( + token: &str, + challenge: &str, + key: &DeviceKey, + current_date: DateTime, +) -> Result<(String, RequestWithDate), MinecraftAuthError> { + // what was microsoft on when writing this?????????? + let res = send_signed_request::( + None, + "https://sisu.xboxlive.com/authenticate", + "/authenticate", + json!({ + "AppId": Core::get().msa_client_id, + "DeviceToken": token, + "Offers": [MINECRAFT_SCOPES], + "Query": { + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": generate_oauth_challenge(), + "prompt": "select_account" + }, + "RedirectUri": Core::get().msa_redirect_uri, + "Sandbox": "RETAIL", + "TokenType": "code", + "TitleId": "1794566092" + }), + key, + MinecraftAuthStep::SisuAuthenicate, + current_date, + ) + .await?; + + let session_id = res + .headers + .get("X-SessionId") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| MinecraftAuthError::SessionIdError)? + .to_string(); + + Ok(( + session_id, + RequestWithDate { + date: res.current_date, + value: res.body, + }, + )) +} + +#[derive(Deserialize)] +struct OAuthToken { + // pub token_type: String, + pub expires_in: u64, + // pub scope: String, + pub access_token: String, + pub refresh_token: String, + // pub user_id: String, + // pub foci: String, +} + +#[tracing::instrument] +async fn oauth_token( + code: &str, + verify: &str, +) -> Result, MinecraftAuthError> { + let core = Core::get(); + let mut query = HashMap::new(); + query.insert("client_id", core.msa_client_id.as_str()); + query.insert("code", code); + query.insert("code_verifier", verify); + query.insert("grant_type", "authorization_code"); + query.insert("redirect_uri", core.msa_redirect_uri.as_str()); + query.insert("scope", MINECRAFT_SCOPES); + let res = auth_retry(|| { + crate::utils::http::REQWEST_CLIENT + .post("https://login.live.com/oauth20_token.srf") + .header("Accept", "application/json") + .form(&query) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + source, + step: MinecraftAuthStep::OAuthToken, + })?; + + let status = res.status(); + let current_date = get_date_header(res.headers()); + let text = res + .text() + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::OAuthToken, + source, + })?; + + let body = + serde_json::from_str(&text).map_err(|source| MinecraftAuthError::DeserializeError { + step: MinecraftAuthStep::OAuthToken, + raw: text, + source, + status_code: status, + })?; + + Ok(RequestWithDate { + date: current_date, + value: body, + }) +} + +#[tracing::instrument] +async fn oauth_refresh( + refresh_token: &str, +) -> Result, MinecraftAuthError> { + let core = Core::get(); + let mut query = HashMap::new(); + query.insert("client_id", core.msa_client_id.as_str()); + query.insert("refresh_token", refresh_token); + query.insert("grant_type", "refresh_token"); + query.insert("redirect_uri", core.msa_redirect_uri.as_str()); + query.insert("scope", MINECRAFT_SCOPES); + let res = auth_retry(|| { + crate::utils::http::REQWEST_CLIENT + .post("https://login.live.com/oauth20_token.srf") + .header("Accept", "application/json") + .form(&query) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::RefreshOAuthToken, + source, + })?; + + let status = res.status(); + let current_date = get_date_header(res.headers()); + let text = res + .text() + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::RefreshOAuthToken, + source, + })?; + + let body = + serde_json::from_str(&text).map_err(|source| MinecraftAuthError::DeserializeError { + step: MinecraftAuthStep::RefreshOAuthToken, + raw: text, + source, + status_code: status, + })?; + + Ok(RequestWithDate { + date: current_date, + value: body, + }) +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +struct SisuAuthorize { + // pub authorization_token: DeviceToken, + // pub device_token: String, + // pub sandbox: String, + pub title_token: DeviceToken, + pub user_token: DeviceToken, + // pub web_page: string, +} + +#[tracing::instrument(skip(key))] +async fn sisu_authorize( + session_id: Option<&str>, + access_token: &str, + device_token: &str, + key: &DeviceKey, + current_date: DateTime, +) -> Result, MinecraftAuthError> { + let core = Core::get(); + let res = send_signed_request( + None, + "https://sisu.xboxlive.com/authorize", + "/authorize", + json!({ + "AccessToken": format!("t={access_token}"), + "AppId": core.msa_client_id, + "DeviceToken": device_token, + "ProofKey": { + // kibty (kty) + "kty": "EC", + "x": key.x, + "y": key.y, + "crv": "P-256", + "alg": "ES256", + "use": "sig" + }, + "Sandbox": "RETAIL", + "SessionId": session_id, + "SiteName": "user.auth.xboxlive.com", + "RelyingParty": "http://xboxlive.com", + "UseModernGamertag": true + }), + key, + MinecraftAuthStep::SisuAuthorize, + current_date, + ) + .await?; + + Ok(RequestWithDate { + date: res.current_date, + value: res.body, + }) +} + +#[tracing::instrument(skip(key))] +async fn xsts_authorize( + authorize: SisuAuthorize, + device_token: &str, + key: &DeviceKey, + current_date: DateTime, +) -> Result, MinecraftAuthError> { + let res = send_signed_request( + None, + "https://xsts.auth.xboxlive.com/xsts/authorize", + "/xsts/authorize", + json!({ + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT", + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [authorize.user_token.token], + "DeviceToken": device_token, + "TitleToken": authorize.title_token.token, + }, + }), + key, + MinecraftAuthStep::XstsAuthorize, + current_date, + ) + .await?; + + Ok(RequestWithDate { + date: res.current_date, + value: res.body, + }) +} + +#[derive(Deserialize)] +struct MinecraftToken { + // pub username: String, + pub access_token: String, + // pub token_type: String, + // pub expires_in: u64, +} + +#[tracing::instrument] +async fn minecraft_token(token: DeviceToken) -> Result { + let uhs = token + .display_claims + .get("xui") + .and_then(|x| x.get(0)) + .and_then(|x| x.get("uhs")) + .and_then(|x| x.as_str().map(String::from)) + .ok_or_else(|| MinecraftAuthError::HashError)?; + + let token = token.token; + let res = auth_retry(|| { + crate::utils::http::REQWEST_CLIENT + .post("https://api.minecraftservices.com/launcher/login") + .header("Accept", "application/json") + .json(&json!({ + "platform": "PC_LAUNCHER", + "xtoken": format!("XBL3.0 x={uhs};{token}"), + })) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftToken, + source, + })?; + + let status = res.status(); + let text = res + .text() + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftToken, + source, + })?; + + serde_json::from_str(&text).map_err(|source| MinecraftAuthError::DeserializeError { + step: MinecraftAuthStep::MinecraftToken, + raw: text, + source, + status_code: status, + }) +} + +#[derive(Deserialize)] +struct MinecraftProfile { + pub id: Option, + pub name: String, +} + +#[tracing::instrument] +async fn minecraft_profile(token: &str) -> Result { + let res = auth_retry(|| { + crate::utils::http::REQWEST_CLIENT + .get("https://api.minecraftservices.com/minecraft/profile") + .header("Accept", "application/json") + .bearer_auth(token) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftProfile, + source, + })?; + + let status = res.status(); + let text = res + .text() + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftProfile, + source, + })?; + + serde_json::from_str(&text).map_err(|source| MinecraftAuthError::DeserializeError { + step: MinecraftAuthStep::MinecraftProfile, + raw: text, + source, + status_code: status, + }) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MinecraftEntitlements {} + +#[tracing::instrument] +async fn minecraft_entitlements(token: &str) -> Result { + let res = auth_retry(|| { + crate::utils::http::REQWEST_CLIENT + .get("https://api.minecraftservices.com/entitlements/mcstore") + .header("Accept", "application/json") + .bearer_auth(token) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftEntitlements, + source, + })?; + + let status = res.status(); + let text = res + .text() + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftEntitlements, + source, + })?; + + serde_json::from_str(&text).map_err(|source| MinecraftAuthError::DeserializeError { + step: MinecraftAuthStep::MinecraftEntitlements, + raw: text, + source, + status_code: status, + }) +} + +#[tracing::instrument(skip(request_fn))] +async fn auth_retry(request_fn: impl Fn() -> F) -> Result +where + F: Future>, +{ + const RETRY_COUNT: usize = 5; + const RETRY_WAIT: tokio::time::Duration = tokio::time::Duration::from_millis(250); + let mut resp = request_fn().await; + + for i in 0..RETRY_COUNT { + match &resp { + Ok(_) => { + break; + } + Err(err) => { + if err.is_connect() || err.is_timeout() { + if i < RETRY_COUNT - 1 { + tracing::debug!( + "request failed due to a connection error, retrying request..." + ); + tokio::time::sleep(RETRY_WAIT).await; + resp = request_fn().await; + } else { + break; + } + } + } + } + } + + resp +} + +#[tracing::instrument] +fn device_key() -> Result { + let id = Uuid::new_v4().to_string().to_uppercase(); + let signing_key = SigningKey::random(&mut rand::rngs::OsRng); + let public_key = p256::ecdsa::VerifyingKey::from(&signing_key); + let encoded_point = public_key.to_encoded_point(false); + + Ok(DeviceKey { + id, + key: signing_key, + x: base64::prelude::BASE64_STANDARD_NO_PAD.encode( + encoded_point + .x() + .ok_or_else(|| MinecraftAuthError::PublicKeyReading)?, + ), + y: base64::prelude::BASE64_STANDARD_NO_PAD.encode( + encoded_point + .y() + .ok_or_else(|| MinecraftAuthError::PublicKeyReading)?, + ), + }) +} + +struct SignedRequestResponse { + pub headers: reqwest::header::HeaderMap, + pub current_date: DateTime, + pub body: T, +} + +#[allow(clippy::cast_possible_wrap)] +#[allow(clippy::cast_possible_truncation)] +#[allow(clippy::cast_sign_loss)] +async fn send_signed_request( + authorization: Option<&str>, + url: &str, + url_path: &str, + raw_body: serde_json::Value, + key: &DeviceKey, + step: MinecraftAuthStep, + current_date: DateTime, +) -> Result, MinecraftAuthError> { + use byteorder::WriteBytesExt; + let auth = authorization.map_or(Vec::new(), |v| v.as_bytes().to_vec()); + let body = serde_json::to_vec(&raw_body) + .map_err(|source| MinecraftAuthError::SerializeError { step, source })?; + let time: u128 = { ((current_date.timestamp() as u128) + 11_644_473_600) * 10_000_000 }; + + let mut buffer = Vec::new(); + buffer + .write_u32::(1) + .map_err(|source| MinecraftAuthError::SigningError { step, source })?; + buffer + .write_u8(0) + .map_err(|source| MinecraftAuthError::SigningError { step, source })?; + buffer + .write_u64::(time as u64) + .map_err(|source| MinecraftAuthError::SigningError { step, source })?; + buffer + .write_u8(0) + .map_err(|source| MinecraftAuthError::SigningError { step, source })?; + buffer.extend_from_slice(b"POST"); + buffer + .write_u8(0) + .map_err(|source| MinecraftAuthError::SigningError { step, source })?; + buffer.extend_from_slice(url_path.as_bytes()); + buffer + .write_u8(0) + .map_err(|source| MinecraftAuthError::SigningError { step, source })?; + buffer.extend_from_slice(&auth); + buffer + .write_u8(0) + .map_err(|source| MinecraftAuthError::SigningError { step, source })?; + buffer.extend_from_slice(&body); + buffer + .write_u8(0) + .map_err(|source| MinecraftAuthError::SigningError { step, source })?; + + let ecdsa_sig: p256::ecdsa::Signature = key.key.sign(&buffer); + let mut sig_buffer = Vec::new(); + + sig_buffer + .write_i32::(1) + .map_err(|source| MinecraftAuthError::SigningError { step, source })?; + sig_buffer + .write_u64::(time as u64) + .map_err(|source| MinecraftAuthError::SigningError { step, source })?; + sig_buffer.extend_from_slice(&ecdsa_sig.r().to_bytes()); + sig_buffer.extend_from_slice(&ecdsa_sig.s().to_bytes()); + + let signature = base64::prelude::BASE64_STANDARD.encode(&sig_buffer); + + let res = auth_retry(|| { + let mut request = crate::utils::http::REQWEST_CLIENT + .post(url) + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .header("Signature", &signature); + + if url != "https://sisu.xboxlive.com/authorize" { + request = request.header("x-xbl-contract-version", "1"); + } + + if let Some(auth) = authorization { + request = request.header("Authorization", auth); + } + + request.body(body.clone()).send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { step, source })?; + + let status = res.status(); + let headers = res.headers().clone(); + let current_date = get_date_header(&headers); + let body = res + .text() + .await + .map_err(|source| MinecraftAuthError::RequestError { step, source })?; + + let body = + serde_json::from_str(&body).map_err(|source| MinecraftAuthError::DeserializeError { + step, + raw: body, + source, + status_code: status, + })?; + Ok(SignedRequestResponse { + headers, + current_date, + body, + }) +} + +#[tracing::instrument] +fn get_date_header(headers: &reqwest::header::HeaderMap) -> DateTime { + headers + .get(reqwest::header::DATE) + .and_then(|x| x.to_str().ok()) + .and_then(|x| DateTime::parse_from_rfc2822(x).ok()) + .map_or_else(Utc::now, |x| x.with_timezone(&Utc)) +} + +#[tracing::instrument] +fn generate_oauth_challenge() -> String { + let mut rng = rand::thread_rng(); + let bytes: Vec = (0..64).map(|_| rng.r#gen::()).collect(); + bytes.iter().fold(String::new(), |mut output, b| { + let _ = write!(output, "{b:02x}"); + output + }) +} + +/// An ordered list of all MSA authentication steps. +#[derive(Debug, Clone, Copy)] +pub enum MinecraftAuthStep { + DeviceToken, + SisuAuthenicate, + OAuthToken, + RefreshOAuthToken, + SisuAuthorize, + XstsAuthorize, + MinecraftToken, + MinecraftEntitlements, + MinecraftProfile, +} + +/// Wrapper around all `Error`s that can occur during the Microsoft authentication process. +#[derive(thiserror::Error, Debug)] +pub enum MinecraftAuthError { + #[error("failed to read public key during key generation")] + PublicKeyReading, + #[error("failed to serialize private key using PKCS8: {0}")] + PKCS8Error(#[from] p256::pkcs8::Error), + #[error("failed to serialize JSON during MSA step {step:?}: {source}")] + SerializeError { + step: MinecraftAuthStep, + #[source] + source: serde_json::Error, + }, + #[error("failed to deserialize JSON during MSA step {step:?}: {source}! status code {status_code} - body: {raw}")] + DeserializeError { + step: MinecraftAuthStep, + raw: String, + #[source] + source: serde_json::Error, + status_code: reqwest::StatusCode, + }, + #[error("failed to request using HTTP during MSA step {step:?}: {source}")] + RequestError { + step: MinecraftAuthStep, + #[source] + source: reqwest::Error, + }, + #[error("failed to create signed buffer during MSA step {step:?}: {source}")] + SigningError { + step: MinecraftAuthStep, + #[source] + source: std::io::Error, + }, + #[error("failed to read user hash")] + HashError, + #[error("failed to read user xbox session ID")] + SessionIdError, +} diff --git a/packages/core/src/store/dirs.rs b/packages/core/src/store/dirs.rs index ef2245e0..b0eb90e6 100644 --- a/packages/core/src/store/dirs.rs +++ b/packages/core/src/store/dirs.rs @@ -70,6 +70,7 @@ macro_rules! dirs_impl { dirs_impl! { db_file => |this| this.base_dir.join("user_data.db"), settings_file => |this| this.base_dir.join("settings.json"), + auth_file => |this| this.base_dir.join("auth.json"), launcher_logs_dir => |this| this.base_dir.join("logs"), clusters_dir => |this| this.base_dir.join("clusters"), diff --git a/packages/core/src/store/mod.rs b/packages/core/src/store/mod.rs index d05025c5..6a4c5974 100644 --- a/packages/core/src/store/mod.rs +++ b/packages/core/src/store/mod.rs @@ -4,6 +4,7 @@ pub mod ingress; pub mod proxy; pub mod semaphore; pub mod metadata; +pub mod credentials; mod core_options; mod dirs; mod settings; diff --git a/packages/core/src/store/settings.rs b/packages/core/src/store/settings.rs index 356d5970..07cb1fad 100644 --- a/packages/core/src/store/settings.rs +++ b/packages/core/src/store/settings.rs @@ -1,7 +1,7 @@ use onelauncher_entity::setting_profiles; use serde::{Deserialize, Serialize}; -use crate::{constants, send_warning, utils::io, LauncherResult}; +use crate::{send_warning, utils::io, LauncherResult}; use super::Dirs; @@ -25,7 +25,7 @@ impl Default for Settings { discord_enabled: false, enable_gamemode: false, max_concurrent_requests: 25, - settings_version: constants::CURRENT_SETTINGS_FORMAT_VERSION, + settings_version: 1, } } } diff --git a/packages/core/src/store/state.rs b/packages/core/src/store/state.rs index 6e31ac8c..a4f4d754 100644 --- a/packages/core/src/store/state.rs +++ b/packages/core/src/store/state.rs @@ -5,6 +5,7 @@ use tokio::sync::{OnceCell, RwLock}; use crate::LauncherResult; +use super::credentials::CredentialsStore; use super::Settings; use super::discord::DiscordRPC; use super::ingress::IngressProcessor; @@ -17,6 +18,7 @@ pub struct State { pub ingress_processor: IngressProcessor, pub settings: RwLock, pub db: DatabaseConnection, + pub credentials: RwLock, pub metadata: RwLock, pub rpc: Option, } @@ -40,8 +42,8 @@ impl State { let ingress_processor = IngressProcessor::new(); let settings = Settings::new().await; let db = super::db::create_pool().await?; + let credentials = CredentialsStore::initialize().await?; let metadata = Metadata::new(); - let rpc = match DiscordRPC::initialize() { Ok(rpc) => Some(rpc), Err(err) => { @@ -54,6 +56,7 @@ impl State { ingress_processor, settings: RwLock::new(settings), db, + credentials: RwLock::new(credentials), metadata: RwLock::new(metadata), rpc, })) diff --git a/packages/core/src/utils/http.rs b/packages/core/src/utils/http.rs index 0b50126b..b9466e7f 100644 --- a/packages/core/src/utils/http.rs +++ b/packages/core/src/utils/http.rs @@ -29,7 +29,7 @@ impl FetchSemaphore { } } -static REQWEST_CLIENT: LazyLock = LazyLock::new(|| { +pub(crate) static REQWEST_CLIENT: LazyLock = LazyLock::new(|| { reqwest::ClientBuilder::new() .tcp_keepalive(Some(std::time::Duration::from_secs(15))) .use_rustls_tls()