From a49c793cdc39a6f4596ed81744897b42295a606f Mon Sep 17 00:00:00 2001 From: tuxuser <462620+tuxuser@users.noreply.github.com> Date: Sun, 13 Nov 2022 04:40:00 +0100 Subject: [PATCH] Initial code import --- Cargo.toml | 36 ++- src/app_params.rs | 152 +++++++++++++ src/authenticator.rs | 401 +++++++++++++++++++++++++++++++++ src/bin/auth-cli.rs | 146 ++++++++++++ src/bin/auth-webview.rs | 200 +++++++++++++++++ src/lib.rs | 20 +- src/models.rs | 338 ++++++++++++++++++++++++++++ src/request_signer.rs | 481 ++++++++++++++++++++++++++++++++++++++++ src/utils.rs | 32 +++ 9 files changed, 1792 insertions(+), 14 deletions(-) create mode 100644 src/app_params.rs create mode 100644 src/authenticator.rs create mode 100644 src/bin/auth-cli.rs create mode 100644 src/bin/auth-webview.rs create mode 100644 src/models.rs create mode 100644 src/request_signer.rs create mode 100644 src/utils.rs diff --git a/Cargo.toml b/Cargo.toml index ce5a6b5..3f44d5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,44 @@ [package] name = "xal" version = "0.1.0" -edition = "2021" +edition = "2018" description = "Xbox Authentication library" license = "MIT" repository = "https://github.com/OpenXbox/xal-rs" homepage = "https://openxbox.org" +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + [dependencies] +reqwest = { version = "0.11", features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +cvlib = "0.1.2" +filetime_type = "0.1" +base64 = "0.13.0" +chrono = "0.4" +josekit = "0.8" +uuid = { version = "1", features = ["v4"] } +oauth2 = "4.3" + +# common for bins +tokio = { version = "1", features = ["full"], optional = true } + +# auth_webview +tauri = { version = "1.1.1", optional = true } +wry = { version = "0.21.1", optional = true } + +[dev-dependencies] +hex-literal = "0.3.4" + +[features] +webview = ["dep:tauri", "dep:wry"] +tokio = ["dep:tokio"] + +[[bin]] +name = "auth-cli" +required-features = ["tokio"] + +[[bin]] +name = "auth-webview" +required-features = ["webview"] diff --git a/src/app_params.rs b/src/app_params.rs new file mode 100644 index 0000000..7d10cbc --- /dev/null +++ b/src/app_params.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] +pub enum DeviceType { + IOS, + ANDROID, + WIN32, +} + +impl FromStr for DeviceType { + type Err = Box; + + fn from_str(s: &str) -> Result { + let enm = match s.to_lowercase().as_ref() { + "android" => DeviceType::ANDROID, + "ios" => DeviceType::IOS, + "win32" => DeviceType::WIN32, + val => { + return Err(format!("Unhandled device type: '{}'", val).into()); + } + }; + Ok(enm) + } +} + +impl ToString for DeviceType { + fn to_string(&self) -> String { + let str = match self { + DeviceType::ANDROID => "Android", + DeviceType::IOS => "iOS", + DeviceType::WIN32 => "Win32", + }; + str.to_owned() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct XalAppParameters { + pub app_id: String, + pub title_id: String, + pub redirect_uri: String, +} + +impl XalAppParameters { + pub fn xbox_app_beta() -> Self { + Self { + app_id: "000000004415494b".into(), + title_id: "177887386".into(), + redirect_uri: "ms-xal-000000004415494b://auth".into(), + } + } + + pub fn xbox_app() -> Self { + Self { + app_id: "000000004c12ae6f".into(), + title_id: "328178078".into(), + redirect_uri: "ms-xal-000000004c12ae6f://auth".into(), + } + } + + pub fn gamepass() -> Self { + Self { + app_id: "000000004c20a908".into(), + title_id: "1016898439".into(), + redirect_uri: "ms-xal-000000004c20a908://auth".into(), + } + } + + pub fn gamepass_beta() -> Self { + Self { + app_id: "000000004c20a908".into(), + title_id: "1016898439".into(), + redirect_uri: "ms-xal-public-beta-000000004c20a908://auth".into(), + } + } + + /// Family settings is somewhat special + /// Uses default oauth20_desktop.srf redirect uri + pub fn family_settings() -> Self { + Self { + app_id: "00000000482C8F49".into(), + title_id: "1618633878".into(), + redirect_uri: "https://login.live.com/oauth20_desktop.srf".into(), + } + } +} + +impl Default for XalAppParameters { + fn default() -> Self { + Self::gamepass_beta() + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct XalClientParameters { + pub user_agent: String, + pub device_type: DeviceType, + pub client_version: String, + pub query_display: String, +} + +impl XalClientParameters { + pub fn ios() -> Self { + Self { + user_agent: "XAL iOS 2021.11.20211021.000".into(), + device_type: DeviceType::IOS, + client_version: "15.6.1".into(), + query_display: "ios_phone".into(), + } + } + + pub fn android() -> Self { + Self { + user_agent: "XAL Android 2020.07.20200714.000".into(), + device_type: DeviceType::ANDROID, + client_version: "8.0.0".into(), + query_display: "android_phone".into(), + } + } +} + +impl Default for XalClientParameters { + fn default() -> Self { + Self::android() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn devicetype_enum_into() { + assert_eq!(DeviceType::WIN32.to_string(), "Win32"); + assert_eq!(DeviceType::ANDROID.to_string(), "Android"); + assert_eq!(DeviceType::IOS.to_string(), "iOS"); + } + + #[test] + fn str_into_devicetype_enum() { + assert_eq!(DeviceType::from_str("win32").unwrap(), DeviceType::WIN32); + assert_eq!(DeviceType::from_str("Win32").unwrap(), DeviceType::WIN32); + assert_eq!(DeviceType::from_str("WIN32").unwrap(), DeviceType::WIN32); + assert_eq!( + DeviceType::from_str("android").unwrap(), + DeviceType::ANDROID + ); + assert_eq!(DeviceType::from_str("ios").unwrap(), DeviceType::IOS); + assert!(DeviceType::from_str("androidx").is_err()); + } +} diff --git a/src/authenticator.rs b/src/authenticator.rs new file mode 100644 index 0000000..b2b3527 --- /dev/null +++ b/src/authenticator.rs @@ -0,0 +1,401 @@ +use crate::app_params::XalAppParameters; + +use super::{ + app_params::{DeviceType, XalClientParameters}, + models::request, + models::response, + request_signer::{self, SigningReqwestBuilder}, +}; +use base64; +use cvlib; +use oauth2::{ + basic::{ + BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, + BasicTokenType, + }, + reqwest::async_http_client, + url, AccessToken, AuthType, AuthUrl, AuthorizationCode, Client as OAuthClient, ClientId, + EmptyExtraTokenFields, ExtraTokenFields, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, + RefreshToken, Scope, StandardRevocableToken, TokenResponse, TokenType, TokenUrl, +}; +use reqwest; +use std::time::Duration; +use url::Url; +use uuid; + +type Error = Box; +type Result = std::result::Result; + +pub type SpecialTokenResponse = response::WindowsLiveTokenResponse; +type SpecialClient = OAuthClient< + BasicErrorResponse, + SpecialTokenResponse, + BasicTokenType, + BasicTokenIntrospectionResponse, + StandardRevocableToken, + BasicRevocationErrorResponse, +>; + +impl TokenResponse for response::WindowsLiveTokenResponse +where + EF: ExtraTokenFields, + BasicTokenType: TokenType, +{ + /// + /// REQUIRED. The access token issued by the authorization server. + /// + fn access_token(&self) -> &AccessToken { + &self.access_token + } + /// + /// REQUIRED. The type of the token issued as described in + /// [Section 7.1](https://tools.ietf.org/html/rfc6749#section-7.1). + /// Value is case insensitive and deserialized to the generic `TokenType` parameter. + /// But in this particular case as the service is non compliant, it has a default value + /// + fn token_type(&self) -> &BasicTokenType { + match &self.token_type { + Some(t) => t, + None => &BasicTokenType::Bearer, + } + } + /// + /// RECOMMENDED. The lifetime in seconds of the access token. For example, the value 3600 + /// denotes that the access token will expire in one hour from the time the response was + /// generated. If omitted, the authorization server SHOULD provide the expiration time via + /// other means or document the default value. + /// + fn expires_in(&self) -> Option { + self.expires_in.map(Duration::from_secs) + } + /// + /// OPTIONAL. The refresh token, which can be used to obtain new access tokens using the same + /// authorization grant as described in + /// [Section 6](https://tools.ietf.org/html/rfc6749#section-6). + /// + fn refresh_token(&self) -> Option<&RefreshToken> { + self.refresh_token.as_ref() + } + /// + /// OPTIONAL, if identical to the scope requested by the client; otherwise, REQUIRED. The + /// scipe of the access token as described by + /// [Section 3.3](https://tools.ietf.org/html/rfc6749#section-3.3). If included in the response, + /// this space-delimited field is parsed into a `Vec` of individual scopes. If omitted from + /// the response, this field is `None`. + /// + fn scopes(&self) -> Option<&Vec> { + self.scopes.as_ref() + } +} + +#[derive(Debug)] +pub struct XalAuthenticator { + device_id: uuid::Uuid, + app_params: XalAppParameters, + client_params: XalClientParameters, + ms_cv: cvlib::CorrelationVector, + client: reqwest::Client, + client2: SpecialClient, + request_signer: request_signer::RequestSigner, +} + +impl Default for XalAuthenticator { + fn default() -> Self { + let client_params = XalClientParameters::default(); + let app_params = XalAppParameters::default(); + let client_id = ClientId::new(app_params.app_id.clone()); + let client_secret = None; + + let auth_url = AuthUrl::new("https://login.live.com/oauth20_authorize.srf".into()) + .expect("Invalid authorization endpoint URL"); + let token_url = TokenUrl::new("https://login.live.com/oauth20_token.srf".into()) + .expect("Invalid token endpoint URL"); + let redirect_url = + RedirectUrl::new(app_params.redirect_uri.clone()).expect("Invalid redirect URL"); + + let client2 = OAuthClient::new(client_id, client_secret, auth_url, Some(token_url)) + .set_auth_type(AuthType::RequestBody) + .set_redirect_uri(redirect_url); + + Self { + device_id: uuid::Uuid::new_v4(), + app_params, + client_params, + ms_cv: cvlib::CorrelationVector::new(), + client: reqwest::Client::new(), + client2, + request_signer: request_signer::RequestSigner::default(), + } + } +} + +impl XalAuthenticator { + pub fn get_code_challenge() -> (PkceCodeChallenge, PkceCodeVerifier) { + PkceCodeChallenge::new_random_sha256() + } + + pub fn generate_random_state() -> String { + let state = uuid::Uuid::new_v4().hyphenated().to_string(); + + base64::encode(state) + } +} + +impl XalAuthenticator { + pub fn app_params(&self) -> XalAppParameters { + self.app_params.clone() + } + + pub fn client_params(&self) -> XalClientParameters { + self.client_params.clone() + } + + pub fn get_redirect_uri(&self) -> Url { + self.client2.redirect_url().unwrap().url().to_owned() + } + + fn next_cv(&mut self) -> String { + self.ms_cv.increment(); + self.ms_cv.to_string() + } + + pub async fn exchange_code_for_token( + &mut self, + authorization_code: &str, + code_verifier: PkceCodeVerifier, + ) -> Result { + let code = AuthorizationCode::new(authorization_code.into()); + let token = self + .client2 + .exchange_code(code) + .set_pkce_verifier(code_verifier) + .add_extra_param("scope", "service::user.auth.xboxlive.com::MBI_SSL") + .request_async(async_http_client) + .await?; + + Ok(token) + } + + pub async fn exchange_refresh_token_for_xcloud_transfer_token( + &mut self, + refresh_token: &RefreshToken, + ) -> Result { + let form_body = request::WindowsLiveTokenRequest { + client_id: &self.app_params.app_id.clone(), + grant_type: "refresh_token", + scope: + "service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN", + refresh_token: Some(refresh_token.secret()), + code: None, + code_verifier: None, + redirect_uri: None, + }; + + self.client + .post("https://login.live.com/oauth20_token.srf") + .header("MS-CV", self.next_cv()) + .form(&form_body) + .send() + .await? + .json::() + .await + .map_err(|e| e.into()) + } + + pub async fn refresh_token( + &mut self, + refresh_token: &RefreshToken, + ) -> Result { + let token = self + .client2 + .exchange_refresh_token(refresh_token) + .add_scope(Scope::new( + "service::user.auth.xboxlive.com::MBI_SSL".into(), + )) + .request_async(async_http_client) + .await?; + + Ok(token) + } +} + +impl XalAuthenticator { + pub async fn get_endpoints(&self) -> Result { + let resp = self + .client + .get("https://title.mgt.xboxlive.com/titles/default/endpoints") + .header("x-xbl-contract-version", "1") + .query(&[("type", 1)]) + .send() + .await? + .json::() + .await?; + + Ok(resp) + } + + pub async fn get_device_token(&mut self) -> Result { + let client_uuid: String = match self.client_params.device_type { + // {decf45e4-945d-4379-b708-d4ee92c12d99} + DeviceType::ANDROID => [ + "{".to_string(), + self.device_id.hyphenated().to_string(), + "}".to_string(), + ] + .concat(), + + // DECF45E4-945D-4379-B708-D4EE92C12D99 + DeviceType::IOS => self.device_id.hyphenated().to_string().to_uppercase(), + // Unknown + _ => self.device_id.hyphenated().to_string(), + }; + + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("x-xbl-contract-version", "1".parse()?); + headers.insert("MS-CV", self.next_cv().parse()?); + + let json_body = request::XADRequest { + relying_party: "http://auth.xboxlive.com", + token_type: "JWT", + properties: request::XADProperties { + auth_method: "ProofOfPossession", + id: client_uuid.as_str(), + device_type: &self.client_params.device_type.to_string(), + version: &self.client_params.client_version, + proof_key: self.request_signer.get_proof_key(), + }, + }; + + self.client + .post("https://device.auth.xboxlive.com/device/authenticate") + .headers(headers) + .json(&json_body) + .sign(&self.request_signer, None)? + .send() + .await? + .json::() + .await + .map_err(|e| e.into()) + } + + /// Sisu authentication + /// Returns tuple: + /// 1. Part: Response that contains authorization URL + /// 2. Part: Session ID from response headers (X-SessionId) + pub async fn do_sisu_authentication( + &mut self, + device_token: &str, + code_challenge: PkceCodeChallenge, + state: &str, + ) -> Result<(response::SisuAuthenticationResponse, String)> { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("x-xbl-contract-version", "1".parse()?); + headers.insert("MS-CV", self.next_cv().parse()?); + + let json_body = request::SisuAuthenticationRequest { + app_id: &self.app_params.app_id, + title_id: &self.app_params.title_id, + redirect_uri: &self.app_params.redirect_uri, + device_token, + sandbox: "RETAIL", + token_type: "code", + offers: vec!["service::user.auth.xboxlive.com::MBI_SSL"], + query: request::SisuQuery { + display: &self.client_params.query_display, + code_challenge: code_challenge.as_str(), + code_challenge_method: code_challenge.method(), + state, + }, + }; + + let resp = self + .client + .post("https://sisu.xboxlive.com/authenticate") + .headers(headers) + .json(&json_body) + .sign(&self.request_signer, None)? + .send() + .await?; + + let session_id = resp + .headers() + .get("X-SessionId") + .ok_or("Failed to fetch session id")? + .to_str()? + .to_owned(); + + let resp_json = resp.json::().await?; + + Ok((resp_json, session_id)) + } + + pub async fn do_sisu_authorization( + &mut self, + sisu_session_id: &str, + access_token: &str, + device_token: &str, + ) -> Result { + let json_body = request::SisuAuthorizationRequest { + access_token: &format!("t={}", access_token), + app_id: &self.app_params.app_id.clone(), + device_token, + sandbox: "RETAIL", + site_name: "user.auth.xboxlive.com", + session_id: sisu_session_id, + proof_key: self.request_signer.get_proof_key(), + }; + + self.client + .post("https://sisu.xboxlive.com/authorize") + .header("MS-CV", self.next_cv()) + .json(&json_body) + .sign(&self.request_signer, None)? + .send() + .await? + .json::() + .await + .map_err(|e| e.into()) + } + + pub async fn do_xsts_authorization( + &mut self, + device_token: &str, + title_token: &str, + user_token: &str, + relying_party: &str, + ) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("x-xbl-contract-version", "1".parse()?); + headers.insert("MS-CV", self.next_cv().parse()?); + + let json_body = request::XSTSRequest { + relying_party, + token_type: "JWT", + properties: request::XSTSProperties { + sandbox_id: "RETAIL", + device_token, + title_token, + user_tokens: vec![user_token], + }, + }; + + self.client + .post("https://xsts.auth.xboxlive.com/xsts/authorize") + .headers(headers) + .json(&json_body) + .sign(&self.request_signer, None)? + .send() + .await? + .json::() + .await + .map_err(|e| e.into()) + } +} + +#[cfg(test)] +mod test { + #[test] + fn test() { + assert_eq!(true, true); + } +} diff --git a/src/bin/auth-cli.rs b/src/bin/auth-cli.rs new file mode 100644 index 0000000..f66b55a --- /dev/null +++ b/src/bin/auth-cli.rs @@ -0,0 +1,146 @@ +use chrono::Utc; +use reqwest::Url; +use std::io; +use xal::authenticator::XalAuthenticator; +use xal::oauth2::PkceCodeVerifier; +use xal::utils::TokenStore; + +const TOKENS_FILEPATH: &str = "tokens.json"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut xal = XalAuthenticator::default(); + + if let Ok(mut ts) = TokenStore::load(TOKENS_FILEPATH) { + let refreshed_xcoud = xal + .exchange_refresh_token_for_xcloud_transfer_token(&ts.xcloud_transfer_token.into()) + .await?; + println!("{:?}", refreshed_xcoud); + + ts.xcloud_transfer_token = refreshed_xcoud; + ts.updated = Utc::now(); + ts.save(TOKENS_FILEPATH)?; + + return Ok(()); + } + + let (code_challenge, code_verifier) = XalAuthenticator::get_code_challenge(); + + println!("Getting device token..."); + let device_token = xal.get_device_token().await?; + println!("Device token={:?}", device_token); + + let state = XalAuthenticator::generate_random_state(); + + println!("Fetching SISU authentication URL..."); + let (sisu_response, sisu_session_id) = xal + .do_sisu_authentication(&device_token.token_data.token, code_challenge, &state) + .await?; + + println!( + r#"!!! ACTION REQUIRED !!! +Navigate to this URL and authenticate: {} +When finished, paste the Redirect URL and hit [ENTER]"#, + sisu_response.msa_oauth_redirect + ); + + let mut redirect_uri = String::new(); + let _ = io::stdin().read_line(&mut redirect_uri)?; + + // Check if redirect URI has expected scheme + println!("Checking redirect URI..."); + let expected_scheme = xal.get_redirect_uri().scheme().to_owned(); + if !redirect_uri.starts_with(&expected_scheme) { + return Err(format!( + "Invalid redirect URL, expecting scheme: {}", + expected_scheme + ) + .into()); + } + + // Parse redirect URI + let parsed_url = Url::parse(&redirect_uri)?; + // Extract query parameters {code, state} + let mut code_query: Option = None; + let mut state_query: Option = None; + + for i in parsed_url.query_pairs() { + if i.0 == "code" { + code_query = Some(i.1.into_owned()) + } else if i.0 == "state" { + state_query = Some(i.1.into_owned()) + } + } + + println!("Verifying state..."); + if let Some(returned_state) = &state_query { + let valid_state = &state == returned_state; + println!( + "State valid: {} ({} vs. {})", + valid_state, state, returned_state + ); + } else { + println!("WARN: No state query returned!"); + } + + if let Some(authorization_code) = code_query { + println!("Authorization Code: {}", &authorization_code); + let local_code_verifier = PkceCodeVerifier::new(code_verifier.secret().clone()); + + println!("Getting WL tokens..."); + let wl_token = xal + .exchange_code_for_token(&authorization_code, local_code_verifier) + .await + .expect("Failed exchanging code for token"); + let wl_token_clone = wl_token.clone(); + println!("WL={:?}", wl_token); + + println!("Attempting SISU authorization..."); + let auth_response = xal + .do_sisu_authorization( + &sisu_session_id, + wl_token.access_token.secret(), + &device_token.token_data.token, + ) + .await?; + println!("SISU={:?}", auth_response); + + println!("Getting GSSV token..."); + // Fetch GSSV (gamestreaming) token + let gssv_token = xal + .do_xsts_authorization( + &auth_response.device_token, + &auth_response.title_token.token_data.token, + &auth_response.user_token.token_data.token, + "http://gssv.xboxlive.com/", + ) + .await?; + println!("GSSV={:?}", gssv_token); + + println!("Getting XCloud transfer token..."); + // Fetch XCloud transfer token + let transfer_token = xal + .exchange_refresh_token_for_xcloud_transfer_token( + &wl_token + .refresh_token + .expect("Failed to unwrap refresh token"), + ) + .await?; + println!("Transfer token={:?}", transfer_token); + + let ts = TokenStore { + app_params: xal.app_params(), + client_params: xal.client_params(), + wl_token: wl_token_clone, + sisu_tokens: auth_response, + gssv_token, + xcloud_transfer_token: transfer_token, + updated: Utc::now(), + }; + ts.save(TOKENS_FILEPATH)? + } else { + println!("No authorization code fetched :("); + } + + Ok(()) +} diff --git a/src/bin/auth-webview.rs b/src/bin/auth-webview.rs new file mode 100644 index 0000000..52f59a0 --- /dev/null +++ b/src/bin/auth-webview.rs @@ -0,0 +1,200 @@ +// Copyright 2020-2022 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use chrono::Utc; +use tauri::async_runtime; +use wry::{ + application::{ + event::{Event, StartCause, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + window::WindowBuilder, + }, + webview::{Url, WebViewBuilder}, +}; +use xal::oauth2::PkceCodeVerifier; +use xal::{authenticator::XalAuthenticator, utils::TokenStore}; + +const TOKENS_FILEPATH: &str = "tokens.json"; + +async fn continue_auth( + xal: &mut XalAuthenticator, + code_verifier: &PkceCodeVerifier, + authorization_code: &str, + sisu_session_id: &str, + device_token: &str, +) -> std::result::Result<(), Box> { + println!("Authorization Code: {}", &authorization_code); + let local_code_verifier = PkceCodeVerifier::new(code_verifier.secret().clone()); + let wl_token = xal + .exchange_code_for_token(authorization_code, local_code_verifier) + .await + .expect("Failed exchanging code for token"); + let wl_token_clone = wl_token.clone(); + println!("WL={:?}", wl_token); + + let auth_response = xal + .do_sisu_authorization( + sisu_session_id, + wl_token.access_token.secret(), + device_token, + ) + .await?; + println!("SISU={:?}", auth_response); + + // Fetch GSSV (gamestreaming) token + let gssv_token = xal + .do_xsts_authorization( + &auth_response.device_token, + &auth_response.title_token.token_data.token, + &auth_response.user_token.token_data.token, + "http://gssv.xboxlive.com/", + ) + .await?; + println!("GSSV={:?}", gssv_token); + + // Fetch XCloud transfer token + let transfer_token = xal + .exchange_refresh_token_for_xcloud_transfer_token( + &wl_token + .refresh_token + .expect("Failed to unwrap refresh token"), + ) + .await?; + println!("Transfer token={:?}", transfer_token); + + let ts = TokenStore { + app_params: xal.app_params(), + client_params: xal.client_params(), + wl_token: wl_token_clone, + sisu_tokens: auth_response, + gssv_token, + xcloud_transfer_token: transfer_token, + updated: Utc::now(), + }; + ts.save(TOKENS_FILEPATH) +} + +enum UserEvent { + Navigation(String), +} + +fn main() -> wry::Result<()> { + let mut xal = XalAuthenticator::default(); + + if let Ok(mut ts) = TokenStore::load(TOKENS_FILEPATH) { + let refreshed_xcoud = async_runtime::block_on( + xal.exchange_refresh_token_for_xcloud_transfer_token(&ts.xcloud_transfer_token.into()), + ) + .expect("Failed to exchange refresh token for fresh XCloud transfer token"); + + println!("{:?}", refreshed_xcoud); + ts.xcloud_transfer_token = refreshed_xcoud; + ts.updated = Utc::now(); + ts.save(TOKENS_FILEPATH) + .expect("Failed to save refreshed XCloud token"); + + return Ok(()); + } + + let (code_challenge, code_verifier) = XalAuthenticator::get_code_challenge(); + let device_token = + async_runtime::block_on(xal.get_device_token()).expect("Failed to fetch device token"); + + println!("Device token={:?}", device_token); + + let state = XalAuthenticator::generate_random_state(); + + let (sisu_response, sisu_session_id) = async_runtime::block_on(xal.do_sisu_authentication( + &device_token.token_data.token, + code_challenge, + &state, + )) + .unwrap(); + + let redirect_uri = xal.get_redirect_uri(); + let auth_url = sisu_response.msa_oauth_redirect; + + let event_loop: EventLoop = EventLoop::with_user_event(); + let proxy = event_loop.create_proxy(); + let window = WindowBuilder::new() + .with_title("Hello World") + .build(&event_loop) + .unwrap(); + + let webview = WebViewBuilder::new(window) + .unwrap() + // tell the webview to load the custom protocol + .with_url(&auth_url)? + .with_devtools(true) + .with_navigation_handler(move |uri: String| { + proxy.send_event(UserEvent::Navigation(uri)).is_ok() + }) + .build()?; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::NewEvents(StartCause::Init) => println!("Wry application started!"), + Event::WindowEvent { + event: WindowEvent::Moved { .. }, + .. + } => { + let _ = webview.evaluate_script("console.log('hello');"); + } + Event::UserEvent(UserEvent::Navigation(uri)) => { + if uri.starts_with(redirect_uri.scheme()) { + let url = Url::parse(&uri).expect("Failed to parse redirect URL"); + + let mut code_query: Option = None; + let mut state_query: Option = None; + + for i in url.query_pairs() { + if i.0 == "code" { + code_query = Some(i.1.into_owned()) + } else if i.0 == "state" { + state_query = Some(i.1.into_owned()) + } + } + + if let Some(returned_state) = &state_query { + let valid_state = &state == returned_state; + println!( + "State valid: {} ({} vs. {})", + valid_state, state, returned_state + ); + } else { + println!("WARN: No state query returned!"); + } + + if let Some(authorization_code) = code_query { + match async_runtime::block_on(continue_auth( + &mut xal, + &code_verifier, + &authorization_code, + &sisu_session_id, + &device_token.token_data.token, + )) { + Ok(_) => { + println!("SISU authentication succeeded! :)"); + } + Err(err) => { + println!("Failed SISU auth :( - details: {}", err); + } + } + } else { + println!("No authorization code fetched :("); + } + + *control_flow = ControlFlow::Exit; + } + } + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => *control_flow = ControlFlow::Exit, + _ => (), + } + }); +} diff --git a/src/lib.rs b/src/lib.rs index 7d12d9a..a63513a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,8 @@ -pub fn add(left: usize, right: usize) -> usize { - left + right -} +pub use cvlib; +pub use oauth2; -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub mod app_params; +pub mod authenticator; +pub mod models; +pub mod request_signer; +pub mod utils; diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..12e3957 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,338 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Serialize, Deserialize, Copy, Clone, PartialEq, Eq)] +pub enum SigningAlgorithm { + ES256, + ES384, + ES521, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SigningPolicy { + pub version: i32, + pub supported_algorithms: Vec, + pub max_body_bytes: usize, +} + +impl Default for SigningPolicy { + fn default() -> Self { + Self { + version: 1, + supported_algorithms: vec![SigningAlgorithm::ES256], + max_body_bytes: 8192, + } + } +} + +pub mod request { + use josekit::jwk::Jwk; + + use super::{Deserialize, Serialize}; + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XADProperties<'a> { + pub auth_method: &'a str, + pub id: &'a str, + pub device_type: &'a str, + pub version: &'a str, + pub proof_key: Jwk, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XADRequest<'a> { + pub relying_party: &'a str, + pub token_type: &'a str, + pub properties: XADProperties<'a>, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct SisuQuery<'a> { + pub display: &'a str, + pub code_challenge: &'a str, + pub code_challenge_method: &'a str, + pub state: &'a str, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct SisuAuthenticationRequest<'a> { + pub app_id: &'a str, + pub title_id: &'a str, + pub redirect_uri: &'a str, + pub device_token: &'a str, + pub sandbox: &'a str, + pub token_type: &'a str, + pub offers: Vec<&'a str>, + pub query: SisuQuery<'a>, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct SisuAuthorizationRequest<'a> { + pub access_token: &'a str, + pub app_id: &'a str, + pub device_token: &'a str, + pub sandbox: &'a str, + pub site_name: &'a str, + pub session_id: &'a str, + pub proof_key: Jwk, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct WindowsLiveTokenRequest<'a> { + pub client_id: &'a str, + pub refresh_token: Option<&'a str>, + pub grant_type: &'a str, + pub scope: &'a str, + pub redirect_uri: Option<&'a str>, + pub code: Option<&'a str>, + pub code_verifier: Option<&'a str>, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XSTSProperties<'a> { + pub sandbox_id: &'a str, + pub device_token: &'a str, + pub title_token: &'a str, + pub user_tokens: Vec<&'a str>, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XSTSRequest<'a> { + pub relying_party: &'a str, + pub token_type: &'a str, + pub properties: XSTSProperties<'a>, + } +} + +pub mod response { + use oauth2::{ + basic::BasicTokenType, helpers, AccessToken, ExtraTokenFields, RefreshToken, Scope, + }; + + use super::{Deserialize, HashMap, Serialize, SigningPolicy}; + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct TokenData { + pub issue_instant: String, + pub not_after: String, + pub token: String, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct XADDisplayClaims { + /// {"xdi": {"did": "F.....", "dcs": "0"}} + pub xdi: HashMap, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XADResponse { + #[serde(flatten)] + pub token_data: TokenData, + pub display_claims: XADDisplayClaims, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct XATDisplayClaims { + pub xti: HashMap, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XATResponse { + #[serde(flatten)] + pub token_data: TokenData, + pub display_claims: XATDisplayClaims, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct XAUDisplayClaims { + pub xui: Vec>, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XAUResponse { + #[serde(flatten)] + pub token_data: TokenData, + pub display_claims: XAUDisplayClaims, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct XSTSDisplayClaims { + pub xui: Vec>, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct XSTSResponse { + #[serde(flatten)] + pub token_data: TokenData, + pub display_claims: XSTSDisplayClaims, + } + + impl XSTSResponse { + pub fn userhash(&self) -> String { + self.display_claims.xui[0]["uhs"].clone() + } + pub fn authorization_header_value(&self) -> String { + format!("XBL3.0 x={};{}", self.userhash(), self.token_data.token) + } + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct SisuAuthenticationResponse { + pub msa_oauth_redirect: String, + pub msa_request_parameters: HashMap, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct SisuAuthorizationResponse { + pub device_token: String, + pub title_token: XATResponse, + pub user_token: XAUResponse, + pub authorization_token: XSTSResponse, + pub web_page: String, + pub sandbox: String, + pub use_modern_gamertag: Option, + } + + #[derive(Debug, Serialize, Deserialize, Clone)] + pub struct WindowsLiveTokenResponse { + pub token_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_in: Option, + #[serde(rename = "scope")] + #[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")] + #[serde(serialize_with = "helpers::serialize_space_delimited_vec")] + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub scopes: Option>, + pub access_token: AccessToken, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + pub user_id: String, + + #[serde(bound = "EF: ExtraTokenFields")] + #[serde(flatten)] + pub extra_fields: EF, + } + + #[derive(Debug, Serialize, Deserialize)] + pub struct XCloudTokenResponse { + pub lpt: String, + pub refresh_token: String, + pub user_id: String, + } + + impl From for RefreshToken { + fn from(t: XCloudTokenResponse) -> Self { + Self::new(t.refresh_token) + } + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct TitleEndpointCertificate { + pub thumbprint: String, + pub is_issuer: Option, + pub root_cert_index: i32, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct TitleEndpointsResponse { + pub end_points: Vec, + pub signature_policies: Vec, + pub certs: Vec, + pub root_certs: Vec, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct TitleEndpoint { + pub protocol: String, + pub host: String, + pub host_type: String, + pub path: Option, + pub relying_party: Option, + pub sub_relying_party: Option, + pub token_type: Option, + pub signature_policy_index: Option, + pub server_cert_index: Option>, + } +} + +#[cfg(test)] +mod test { + use super::{response, SigningAlgorithm, SigningPolicy}; + use serde_json; + + #[test] + fn deserialize_xsts() { + let data = r#" + { + "IssueInstant": "2010-10-10T03:06:35.5251155Z", + "NotAfter": "2999-10-10T19:06:35.5251155Z", + "Token": "123456789", + "DisplayClaims": { + "xui": [ + { + "gtg": "e", + "xid": "2669321029139235", + "uhs": "abcdefg", + "agg": "Adult", + "usr": "", + "utr": "", + "prv": "" + } + ] + } + } + "#; + + let bla: response::XSTSResponse = + serde_json::from_str(data).expect("BUG: Failed to deserialize XSTS response"); + + assert_eq!(bla.userhash(), "abcdefg"); + assert_eq!( + bla.authorization_header_value(), + "XBL3.0 x=abcdefg;123456789" + ); + assert_eq!(bla.token_data.token, "123456789".to_owned()); + assert_eq!(bla.display_claims.xui[0].get("gtg"), Some(&"e".to_owned())); + assert_ne!( + bla.display_claims.xui[0].get("uhs"), + Some(&"invalid".to_owned()) + ); + } + + #[test] + fn deserialize_signing_policy() { + let json_resp = r#"{ + "Version": 99, + "SupportedAlgorithms": ["ES521"], + "MaxBodyBytes": 1234 + }"#; + + let deserialized: SigningPolicy = + serde_json::from_str(json_resp).expect("Failed to deserialize SigningPolicy"); + + assert_eq!(deserialized.version, 99); + assert_eq!(deserialized.max_body_bytes, 1234); + assert_eq!( + deserialized.supported_algorithms, + vec![SigningAlgorithm::ES521] + ) + } +} diff --git a/src/request_signer.rs b/src/request_signer.rs new file mode 100644 index 0000000..536bbb7 --- /dev/null +++ b/src/request_signer.rs @@ -0,0 +1,481 @@ +use crate::models::SigningPolicy; + +use filetime_type::FileTime; +use oauth2::url::Position; +use super::models; +use base64::{self, DecodeError}; +use chrono::prelude::*; +use josekit::{ + self, + jwk::{alg::ec::EcKeyPair, Jwk}, +}; +use reqwest::{self, Method}; +use std::{option::Option, str::FromStr}; + +type Error = Box; +type Result = std::result::Result; + +#[derive(Debug)] +pub struct XboxWebSignatureBytes { + signing_policy_version: Vec, + timestamp: Vec, + signed_digest: Vec, +} + +impl From<&XboxWebSignatureBytes> for Vec { + fn from(obj: &XboxWebSignatureBytes) -> Self { + let mut bytes: Vec = Vec::new(); + bytes.extend_from_slice(obj.signing_policy_version.as_slice()); + bytes.extend_from_slice(obj.timestamp.as_slice()); + bytes.extend_from_slice(obj.signed_digest.as_slice()); + + bytes + } +} + +impl FromStr for XboxWebSignatureBytes { + type Err = DecodeError; + + fn from_str(s: &str) -> std::result::Result { + let bytes = base64::decode(s)?; + Ok(bytes.into()) + } +} +impl From> for XboxWebSignatureBytes { + fn from(bytes: Vec) -> Self { + Self { + signing_policy_version: bytes[..4].to_vec(), + timestamp: bytes[4..12].to_vec(), + signed_digest: bytes[12..].to_vec(), + } + } +} + +impl ToString for XboxWebSignatureBytes { + fn to_string(&self) -> String { + let bytes: Vec = self.into(); + base64::encode(bytes) + } +} + +#[derive(Debug)] +pub struct HttpRequestToSign { + method: String, + path_and_query: String, + authorization: String, + body: Vec, +} + +impl From for HttpRequestToSign { + fn from(request: reqwest::Request) -> Self { + let url = request.url(); + + let auth_header_val = match request.headers().get(reqwest::header::AUTHORIZATION) { + Some(val) => val + .to_str() + .expect("Failed serializing Authentication header to string"), + None => "", + }; + + let body = match *request.method() { + Method::GET => { + vec![] + } + Method::POST => request + .body() + .expect("Failed to get body from HTTP request") + .as_bytes() + .expect("Failed to convert HTTP body to bytes") + .to_vec(), + _ => panic!("Unhandled HTTP method: {:?}", request.method()), + }; + + HttpRequestToSign { + method: request.method().to_string().to_uppercase(), + path_and_query: url[Position::BeforePath..].to_owned(), + authorization: auth_header_val.to_owned(), + body, + } + } +} + +#[derive(Debug)] +pub struct RequestSigner { + pub keypair: EcKeyPair, + pub signing_policy: models::SigningPolicy, +} + +impl Default for RequestSigner { + fn default() -> Self { + Self::new(SigningPolicy::default()) + } +} + +pub trait SigningReqwestBuilder { + fn sign( + self, + signer: &RequestSigner, + timestamp: Option>, + ) -> Result; +} + +impl SigningReqwestBuilder for reqwest::RequestBuilder { + fn sign( + self, + signer: &RequestSigner, + timestamp: Option>, + ) -> Result { + match self.try_clone() { + Some(rb) => { + let request = rb.build()?; + // Fallback to Utc::now() internally + let signed = signer.sign_request(request, timestamp)?; + let body_bytes = signed + .body() + .ok_or("Failed getting request body")? + .as_bytes() + .ok_or("Failed getting bytes from request body")? + .to_vec(); + let headers = signed.headers().to_owned(); + + Ok(self.headers(headers).body(body_bytes)) + } + None => Err("Failed to clone RequestBuilder for signing".into()), + } + } +} + +impl RequestSigner { + pub fn new(policy: models::SigningPolicy) -> Self { + Self { + keypair: josekit::jws::ES256.generate_key_pair().unwrap(), + signing_policy: policy, + } + } + + pub fn get_proof_key(&self) -> Jwk { + let mut jwk = self.keypair.to_jwk_public_key(); + jwk.set_key_use("sig"); + + jwk + } + + pub fn sign_request( + &self, + request: reqwest::Request, + timestamp: Option>, + ) -> Result { + let mut clone_request = request.try_clone().unwrap(); + // Gather data from request used for signing + let to_sign = request.into(); + + // Create signature + let signature = self + .sign( + self.signing_policy.version, + timestamp.unwrap_or_else(Utc::now), + &to_sign, + ) + .expect("Signing request failed!"); + + // Replace request body with byte representation (so signature creation is deterministic) + clone_request.body_mut().replace(to_sign.body.into()); + + // Assign Signature-header in request + clone_request + .headers_mut() + .insert("Signature", signature.to_string().parse()?); + + Ok(clone_request) + } + + /// Sign + pub fn sign( + &self, + signing_policy_version: i32, + timestamp: DateTime, + request: &HttpRequestToSign, + ) -> Result { + self.sign_raw( + signing_policy_version, + timestamp, + request.method.to_owned(), + request.path_and_query.to_owned(), + request.authorization.to_owned(), + &request.body, + ) + } + + fn sign_raw( + &self, + signing_policy_version: i32, + timestamp: DateTime, + method: String, + path_and_query: String, + authorization: String, + body: &[u8], + ) -> Result { + let signer = josekit::jws::ES256.signer_from_jwk(&self.keypair.to_jwk_private_key())?; + + let filetime_bytes = FileTime::from(timestamp).filetime().to_be_bytes(); + let signing_policy_version_bytes = signing_policy_version.to_be_bytes(); + + // Assemble the message to sign + let message = self + .assemble_message_data( + &signing_policy_version_bytes, + &filetime_bytes, + method, + path_and_query, + authorization, + body, + self.signing_policy.max_body_bytes, + ) + .expect("Failed to assemble message data !"); + + // Sign the message + let signed_digest: Vec = signer.sign(&message)?; + + // Return final signature + Ok(XboxWebSignatureBytes { + signing_policy_version: signing_policy_version_bytes.to_vec(), + timestamp: filetime_bytes.to_vec(), + signed_digest, + }) + } + + pub fn verify_request(&self, request: reqwest::Request) -> Result<()> { + let signature = request + .try_clone() + .ok_or("Failed to clone request")? + .headers() + .get("Signature") + .ok_or("Failed to get signature header")? + .to_str()? + .to_owned(); + + self.verify( + XboxWebSignatureBytes::from_str(&signature)?, + &request.into(), + ) + } + + pub fn verify( + &self, + signature: XboxWebSignatureBytes, + request: &HttpRequestToSign, + ) -> Result<()> { + let verifier = josekit::jws::ES256.verifier_from_jwk(&self.keypair.to_jwk_public_key())?; + let message = self.assemble_message_data( + &signature.signing_policy_version, + &signature.timestamp, + request.method.to_owned(), + request.path_and_query.to_owned(), + request.authorization.to_owned(), + &request.body, + self.signing_policy.max_body_bytes, + )?; + verifier + .verify(&message, &signature.signed_digest) + .map_err(|err| err.into()) + } + + #[allow(clippy::too_many_arguments)] + fn assemble_message_data( + &self, + signing_policy_version: &[u8], + timestamp: &[u8], + method: String, + path_and_query: String, + authorization: String, + body: &[u8], + max_body_bytes: usize, + ) -> Result> { + const NULL_BYTE: &[u8; 1] = &[0x00]; + + let mut data = Vec::::new(); + // Signature version + null + data.extend_from_slice(signing_policy_version); + data.extend_from_slice(NULL_BYTE); + + // Timestamp + null + data.extend_from_slice(timestamp); + data.extend_from_slice(NULL_BYTE); + + // Method (uppercase) + null + data.extend_from_slice(method.to_uppercase().as_bytes()); + data.extend_from_slice(NULL_BYTE); + + // Path and query + null + data.extend_from_slice(path_and_query.as_bytes()); + data.extend_from_slice(NULL_BYTE); + + // Authorization (even if an empty string) + data.extend_from_slice(authorization.as_bytes()); + data.extend_from_slice(NULL_BYTE); + + // Body + let body_size_to_hash = std::cmp::min(max_body_bytes, body.len()); + data.extend_from_slice(&body[..body_size_to_hash]); + data.extend_from_slice(NULL_BYTE); + + Ok(data) + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use super::{ + reqwest, FileTime, HttpRequestToSign, RequestSigner, SigningReqwestBuilder, + XboxWebSignatureBytes, + }; + use chrono::prelude::*; + use hex_literal::hex; + use reqwest::{Body, Client}; + + fn get_request_signer() -> RequestSigner { + const PRIVATE_KEY_PEM: &str = "-----BEGIN EC PRIVATE KEY-----\n + MHcCAQEEIObr5IVtB+DQcn25+R9n4K/EyUUSbVvxIJY7WhVeELUuoAoGCCqGSM49\n + AwEHoUQDQgAEOKyCQ9qH5U4lZcS0c5/LxIyKvOpKe0l3x4Eg5OgDbzezKNLRgT28\n + fd4Fq3rU/1OQKmx6jSq0vTB5Ao/48m0iGg==\n + -----END EC PRIVATE KEY-----\n"; + + RequestSigner { + keypair: josekit::jws::ES256 + .key_pair_from_pem(PRIVATE_KEY_PEM) + .unwrap(), + signing_policy: Default::default(), + } + } + + #[test] + fn sign() { + let signer = get_request_signer(); + let dt = Utc.timestamp_opt(1586999965, 0).unwrap(); + + let request = HttpRequestToSign { + method: "POST".to_owned(), + path_and_query: "/path?query=1".to_owned(), + authorization: "XBL3.0 x=userid;jsonwebtoken".to_owned(), + body: b"thebodygoeshere".to_vec(), + }; + + let signature = signer + .sign_raw( + 1, + dt, + request.method.to_owned(), + request.path_and_query.to_owned(), + request.authorization.to_owned(), + &request.body, + ) + .expect("Signing failed!"); + + signer + .verify(signature, &request) + .expect("Verification failed") + } + + #[test] + fn data_to_hash() { + let signer = get_request_signer(); + let signing_policy_version: i32 = 1; + let ts_bytes = FileTime::from(Utc.timestamp_opt(1586999965, 0).unwrap()).filetime().to_be_bytes(); + + let message_data = signer + .assemble_message_data( + &signing_policy_version.to_be_bytes(), + &ts_bytes, + "POST".to_owned(), + "/path?query=1".to_owned(), + "XBL3.0 x=userid;jsonwebtoken".to_owned(), + "thebodygoeshere".as_bytes(), + 8192, + ) + .expect("Failed to assemble message data"); + + assert_eq!( + message_data, + hex!("000000010001d6138d10f7cc8000504f5354002f706174683f71756572793d310058424c332e3020783d7573657269643b6a736f6e776562746f6b656e00746865626f6479676f65736865726500").to_vec() + ); + } + + #[test] + fn sign_reqwest() { + let signer = get_request_signer(); + let timestamp = Utc.timestamp_opt(1586999965, 0).unwrap(); + + let client = reqwest::Client::new(); + let mut request = client + .post("https://example.com/path") + .query(&[("query", 1)]) + .header( + reqwest::header::AUTHORIZATION, + "XBL3.0 x=userid;jsonwebtoken", + ) + .body("thebodygoeshere") + .build() + .unwrap(); + + request = signer + .sign_request(request, Some(timestamp)) + .expect("FAILED signing request"); + + let signature = request.headers().get("Signature"); + + assert!(signature.is_some()); + assert!(signer.verify_request(request).is_ok()); + } + + #[test] + fn verify_real_request() { + let pem_priv_key = r#"-----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYhW3PQAibijp6X71 + Uua4a45KoHHpQZaUIef+gPeWOu2hRANCAAQYlLUACGI9jDRlJAkMIXyRxmQoBza1 + FZcA3pjD6j+ExFAECR1HP8lSIVEICL6BA95LdCQ8/xvI4F8rP10drPl3 + -----END PRIVATE KEY-----"#; + + let signer = RequestSigner { + keypair: josekit::jws::ES256.key_pair_from_pem(pem_priv_key).unwrap(), + signing_policy: Default::default(), + }; + + let request = HttpRequestToSign { + method: "POST".to_owned(), + path_and_query: "/device/authenticate".to_owned(), + authorization: "".to_owned(), + body: br#"{"RelyingParty":"http://auth.xboxlive.com","TokenType":"JWT","Properties":{"AuthMethod":"ProofOfPossession","Id":"{e51d4344-196a-4550-9e27-f6c5006a9949}","DeviceType":"Android","Version":"8.0.0","ProofKey":{"kty":"EC","alg":"ES256","crv":"P-256","x":"GJS1AAhiPYw0ZSQJDCF8kcZkKAc2tRWXAN6Yw-o_hMQ","y":"UAQJHUc_yVIhUQgIvoED3kt0JDz_G8jgXys_XR2s-Xc","use":"sig"}}}"#.to_vec(), + }; + let signature = XboxWebSignatureBytes::from_str("AAAAAQHY4xgs5DyIujFG5E5MZ4D1xjd9Up+H4AKLoyBHd95MAUZcabUN//Y/gijed4vvKtlfp4Cd4dJzVhpK0m+sYZcYRqQjBEKAZw==") + .expect("Failed to deserialize into XboxWebSignatureBytes"); + + assert!(signer.verify(signature, &request).is_ok()); + } + + #[test] + fn build_signed_get_request() { + let signer = get_request_signer(); + let request = Client::new() + .get("https://example.com") + .sign(&signer, None) + .expect("Failed to sign HTTP GET request") + .build(); + + assert!(request.is_ok()); + } + + #[test] + fn build_signed_post_request() { + let signer = get_request_signer(); + let request = Client::new() + .post("https://example.com") + .body(Body::from(b"somedata".to_vec())) + .sign(&signer, None) + .expect("Failed to sign HTTP POST request") + .build(); + + assert!(request.is_ok()); + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..572a765 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; + +use crate::authenticator::SpecialTokenResponse; +use crate::{ + app_params::{XalAppParameters, XalClientParameters}, + models::response::{SisuAuthorizationResponse, XCloudTokenResponse, XSTSResponse}, +}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct TokenStore { + pub app_params: XalAppParameters, + pub client_params: XalClientParameters, + pub wl_token: SpecialTokenResponse, + pub sisu_tokens: SisuAuthorizationResponse, + pub gssv_token: XSTSResponse, + pub xcloud_transfer_token: XCloudTokenResponse, + pub updated: DateTime, +} + +impl TokenStore { + pub fn load(filepath: &str) -> Result> { + let s = fs::read_to_string(filepath)?; + serde_json::from_str(&s).map_err(|e| e.into()) + } + + pub fn save(&self, filepath: &str) -> Result<(), Box> { + let s = serde_json::to_string_pretty(self)?; + fs::write(filepath, s).map_err(|e| e.into()) + } +}