diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..97d3f7b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 + +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1df57da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: + - main + - master + pull_request: + +env: + CARGO_TERM_COLOR: always + +permissions: + contents: read + +jobs: + rustfmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: rustfmt + - name: Rustfmt Check + uses: actions-rust-lang/rustfmt@v1 + + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: clippy + - name: Clippy Check + run: cargo clippy --all-targets --all-features + + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + concurrency: + # Cancel intermediate builds + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Run tests + run: | + cargo test --all-features diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ac17549 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish + +on: + workflow_dispatch: + push: + tags: + - "*.*.*" + +env: + CARGO_TERM_COLOR: always + +permissions: + contents: read + +jobs: + build: + name: Build + Publish + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e6c0ac3..0000000 --- a/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -language: rust - -install: - - >- - curl -H 'Cache-Control: no-cache' - https://raw.githubusercontent.com/mdsol/fossa_ci_scripts/master/travis_ci/fossa_install.sh | - bash -s -- -b $TRAVIS_BUILD_DIR - -after_success: - - >- - curl -H 'Cache-Control: no-cache' - https://raw.githubusercontent.com/mdsol/fossa_ci_scripts/master/travis_ci/fossa_run.sh | - bash -s -- -b $TRAVIS_BUILD_DIR diff --git a/Cargo.toml b/Cargo.toml index 1db8e3a..4281e98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mauth-client" -version = "0.3.0" +version = "0.4.0" authors = ["Mason Gup "] edition = "2021" documentation = "https://docs.rs/mauth-client/" @@ -13,31 +13,25 @@ keywords = ["security", "authentication", "web"] categories = ["authentication", "web-programming"] [dependencies] -ring = ">= 0.17.7" -reqwest = { version = ">= 0.11.23", features = ["json"] } -url = ">= 2.5.0" -serde = { version = ">= 1.0.85", features = ["derive"] } -serde_json = ">= 1.0.0" -serde_yaml = ">= 0.8.0" -uuid = { version = ">= 0.21.0", features = ["v4"] } -dirs = ">= 2.0.0" -base64 = ">= 0.10.0" -chrono = ">= 0.4.0" -percent-encoding = ">= 2.0.0" -tokio = { version = ">= 1.0.1", features = ["fs"] } -sha2 = ">= 0.9.0" -hex = ">= 0.4.0" -openssl = ">= 0.10.0" -regex = { version = "1", default_features = false, features = ["std"] } -bytes = ">= 1.0.0" -http = ">= 1.0.0" -tower = { version = ">= 0.4.13", optional = true } +reqwest = { version = "0.12", features = ["json"] } +url = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yml = "0.0.10" +uuid = { version = "1", features = ["v4"] } +dirs = "5" +chrono = "0.4" +tokio = { version = "1", features = ["fs"] } +tower = { version = "0.4", optional = true } axum = { version = ">= 0.7.2", optional = true } -futures-core = { version = ">= 0.3.25", optional = true } -thiserror = ">= 1.0.37" +futures-core = { version = "0.3", optional = true } +http = { version = "1", optional = true } +bytes = { version = "1", optional = true } +thiserror = "1" +mauth-core = "0.5" [dev-dependencies] -tokio = { version = ">= 1.0.1", features = ["rt-multi-thread", "macros"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [features] -axum-service = ["tower", "futures-core", "axum"] +axum-service = ["tower", "futures-core", "axum", "http", "bytes"] diff --git a/README.md b/README.md index 3c2b4e9..f3530ba 100644 --- a/README.md +++ b/README.md @@ -10,25 +10,28 @@ release any code to Production or deploy in a Client-accessible environment with approval for the full stack used through the Architecture and Security groups. ```rust +use mauth_client::MAuthInfo; +use reqwest::Client; let mauth_info = MAuthInfo::from_default_file().unwrap(); let client = Client::new(); -let uri: Url = "https://www.example.com/".parse().unwrap(); -let (body, body_digest) = MAuthInfo::build_body_with_digest("".to_string()); -let mut req = Request::new(Method::GET, uri); -*req.body_mut() = Some(body); -mauth_info.sign_request(&mut req, &body_digest); +let mut req = client.get("https://www.example.com/").build().unwrap(); +mauth_info.sign_request(&mut req); match client.execute(req).await { Err(err) => println!("Got error {}", err), - Ok(response) => match mauth_info.validate_response(response).await { - Ok(resp_body) => println!( - "Got validated response with body {}", - &String::from_utf8(resp_body).unwrap() - ), - Err(err) => println!("Error validating response: {:?}", err), - } + Ok(response) => println!("Got validated response with body {}", response.text().await.unwrap()), } ``` + +The above code will read your mauth configuration from a file in `~/.mauth_config.yml` which format is: +```yaml +common: &common + mauth_baseurl: https:// + mauth_api_version: v1 + app_uuid: + private_key_file: +``` + The optional `axum-service` feature provides for a Tower Layer and Service that will authenticate incoming requests via MAuth V2 or V1 and provide to the lower layers a validated app_uuid from the request via the ValidatedRequestDetails struct. diff --git a/build.rs b/build.rs index 6877b21..bfc4b21 100644 --- a/build.rs +++ b/build.rs @@ -25,16 +25,6 @@ fn main() { let formatted_name = name.replace('-', "_"); code_str.push_str(&format!( r#" -#[tokio::test] -async fn {formatted_name}_string_to_sign() {{ - test_string_to_sign("{name}".to_string()).await; -}} - -#[tokio::test] -async fn {formatted_name}_sign_string() {{ - test_sign_string("{name}".to_string()).await; -}} - #[tokio::test] async fn {formatted_name}_generate_headers() {{ test_generate_headers("{name}".to_string()).await; diff --git a/src/axum_service.rs b/src/axum_service.rs index 966ccd0..f5fa971 100644 --- a/src/axum_service.rs +++ b/src/axum_service.rs @@ -2,7 +2,7 @@ use axum::extract::Request; use futures_core::future::BoxFuture; -use openssl::{pkey::Public, rsa::Rsa}; +use mauth_core::verifier::Verifier; use std::collections::HashMap; use std::error::Error; use std::sync::{Arc, RwLock}; @@ -10,7 +10,10 @@ use std::task::{Context, Poll}; use tower::{Layer, Service}; use uuid::Uuid; -use crate::{ConfigFileSection, ConfigReadError, MAuthInfo}; +use crate::{ + config::{ConfigFileSection, ConfigReadError}, + MAuthInfo, +}; /// This is a Tower Service which validates that incoming requests have a valid /// MAuth signature. It only passes the request down to the next layer if the @@ -69,7 +72,7 @@ impl Clone for MAuthValidationService { #[derive(Clone)] pub struct MAuthValidationLayer { config_info: ConfigFileSection, - remote_key_store: Arc>>>, + remote_key_store: Arc>>, } impl Layer for MAuthValidationLayer { diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..29c2cf4 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,211 @@ +use crate::MAuthInfo; +use mauth_core::{signer::Signer, verifier::Verifier}; +use reqwest::Url; +use serde::Deserialize; +use std::collections::HashMap; +use std::io; +use std::sync::{Arc, RwLock}; +use thiserror::Error; +use uuid::Uuid; + +const CONFIG_FILE: &str = ".mauth_config.yml"; + +impl MAuthInfo { + /// Construct the MAuthInfo struct based on the contents of the config file `.mauth_config.yml` + /// present in the current user's home directory. Returns an enum error type that includes the + /// error types of all crates used. + pub fn from_default_file() -> Result { + Self::from_config_section(&Self::config_section_from_default_file()?, None) + } + + pub(crate) fn config_section_from_default_file() -> Result { + let mut home = dirs::home_dir().unwrap(); + home.push(CONFIG_FILE); + let config_data = std::fs::read_to_string(&home)?; + + let config_data_value: serde_yml::Value = serde_yml::from_slice(&config_data.into_bytes())?; + let common_section = config_data_value + .get("common") + .ok_or(ConfigReadError::InvalidFile(None))?; + let common_section_typed: ConfigFileSection = + serde_yml::from_value(common_section.clone())?; + Ok(common_section_typed) + } + + /// Construct the MAuthInfo struct based on a passed-in ConfigFileSection instance. The + /// optional input_keystore is present to support internal cloning and need not be provided + /// if being used outside of the crate. + pub fn from_config_section( + section: &ConfigFileSection, + input_keystore: Option>>>, + ) -> Result { + let full_uri: Url = format!( + "{}/mauth/{}/security_tokens/", + §ion.mauth_baseurl, §ion.mauth_api_version + ) + .parse()?; + + let mut pk_data = section.private_key_data.clone(); + if pk_data.is_none() && section.private_key_file.is_some() { + pk_data = Some(std::fs::read_to_string( + section.private_key_file.as_ref().unwrap(), + )?); + } + if pk_data.is_none() { + return Err(ConfigReadError::NoPrivateKey); + } + + Ok(MAuthInfo { + app_id: Uuid::parse_str(§ion.app_uuid)?, + mauth_uri_base: full_uri, + remote_key_store: input_keystore + .unwrap_or_else(|| Arc::new(RwLock::new(HashMap::new()))), + sign_with_v1_also: !section.v2_only_sign_requests.unwrap_or(false), + allow_v1_auth: !section.v2_only_authenticate.unwrap_or(false), + signer: Signer::new(section.app_uuid.clone(), pk_data.unwrap())?, + }) + } +} + +/// All of the configuration data needed to set up a MAuthInfo struct. Implements Deserialize +/// to be read from a YAML file easily, or can be created manually. +#[derive(Deserialize, Clone)] +pub struct ConfigFileSection { + pub app_uuid: String, + pub mauth_baseurl: String, + pub mauth_api_version: String, + pub private_key_file: Option, + pub private_key_data: Option, + pub v2_only_sign_requests: Option, + pub v2_only_authenticate: Option, +} + +impl Default for ConfigFileSection { + fn default() -> Self { + Self { + app_uuid: "".to_string(), + mauth_baseurl: "".to_string(), + mauth_api_version: "v1".to_string(), + private_key_file: None, + private_key_data: None, + v2_only_sign_requests: Some(true), + v2_only_authenticate: Some(true), + } + } +} + +/// All of the possible errors that can take place when attempting to read a config file. Errors +/// are specific to the libraries that created them, and include the details from those libraries. +#[derive(Debug, Error)] +pub enum ConfigReadError { + #[error("File Read Error: {0}")] + FileReadError(#[from] io::Error), + #[error("Not a valid maudit config file: {0:?}")] + InvalidFile(Option), + #[error("MAudit URI not valid: {0}")] + InvalidUri(#[from] url::ParseError), + #[error("App UUID not valid: {0}")] + InvalidAppUuid(#[from] uuid::Error), + #[error("Unable to parse RSA private key: {0}")] + PrivateKeyDecodeError(String), + #[error("Neither private_key_file nor private_key_data were provided")] + NoPrivateKey, +} + +impl From for ConfigReadError { + fn from(err: mauth_core::error::Error) -> ConfigReadError { + match err { + mauth_core::error::Error::PrivateKeyDecodeError(pkey_err) => { + ConfigReadError::PrivateKeyDecodeError(format!("{}", pkey_err)) + } + _ => panic!("should not be possible to get this error type from signer construction"), + } + } +} + +impl From for ConfigReadError { + fn from(err: serde_yml::Error) -> ConfigReadError { + ConfigReadError::InvalidFile(Some(err)) + } +} + +#[cfg(test)] +mod test { + use super::*; + use tokio::fs; + + #[tokio::test] + async fn invalid_uri_returns_right_error() { + let bad_config = ConfigFileSection { + app_uuid: "".to_string(), + mauth_baseurl: "dfaedfaewrfaew".to_string(), + mauth_api_version: "".to_string(), + private_key_file: Some("".to_string()), + private_key_data: None, + v2_only_sign_requests: None, + v2_only_authenticate: None, + }; + let load_result = MAuthInfo::from_config_section(&bad_config, None); + assert!(matches!(load_result, Err(ConfigReadError::InvalidUri(_)))); + } + + #[tokio::test] + async fn bad_file_path_returns_right_error() { + let bad_config = ConfigFileSection { + app_uuid: "".to_string(), + mauth_baseurl: "https://example.com/".to_string(), + mauth_api_version: "v1".to_string(), + private_key_file: Some("no_such_file".to_string()), + private_key_data: None, + v2_only_sign_requests: None, + v2_only_authenticate: None, + }; + let load_result = MAuthInfo::from_config_section(&bad_config, None); + assert!(matches!( + load_result, + Err(ConfigReadError::FileReadError(_)) + )); + } + + #[tokio::test] + async fn bad_key_file_returns_right_error() { + let filename = "dummy_file"; + fs::write(&filename, b"definitely not a key").await.unwrap(); + let bad_config = ConfigFileSection { + app_uuid: "c7db7fde-2448-11ef-b358-125eb8485a60".to_string(), + mauth_baseurl: "https://example.com/".to_string(), + mauth_api_version: "v1".to_string(), + private_key_file: Some(filename.to_string()), + private_key_data: None, + v2_only_sign_requests: None, + v2_only_authenticate: None, + }; + let load_result = MAuthInfo::from_config_section(&bad_config, None); + fs::remove_file(&filename).await.unwrap(); + assert!(matches!( + load_result, + Err(ConfigReadError::PrivateKeyDecodeError(_)) + )); + } + + #[tokio::test] + async fn bad_uuid_returns_right_error() { + let filename = "valid_key_file"; + fs::write(&filename, "invalid data").await.unwrap(); + let bad_config = ConfigFileSection { + app_uuid: "".to_string(), + mauth_baseurl: "https://example.com/".to_string(), + mauth_api_version: "v1".to_string(), + private_key_file: Some(filename.to_string()), + private_key_data: None, + v2_only_sign_requests: None, + v2_only_authenticate: None, + }; + let load_result = MAuthInfo::from_config_section(&bad_config, None); + fs::remove_file(&filename).await.unwrap(); + assert!(matches!( + load_result, + Err(ConfigReadError::InvalidAppUuid(_)) + )); + } +} diff --git a/src/config_test.rs b/src/config_test.rs deleted file mode 100644 index f2a04e1..0000000 --- a/src/config_test.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::{ConfigFileSection, ConfigReadError, MAuthInfo}; -use openssl::pkey::PKey; -use openssl::rsa::Rsa; -use tokio::fs; - -#[tokio::test] -async fn invalid_uri_returns_right_error() { - let bad_config = ConfigFileSection { - app_uuid: "".to_string(), - mauth_baseurl: "dfaedfaewrfaew".to_string(), - mauth_api_version: "".to_string(), - private_key_file: "".to_string(), - v2_only_sign_requests: None, - v2_only_authenticate: None, - }; - let load_result = MAuthInfo::from_config_section(&bad_config, None); - assert!(matches!(load_result, Err(ConfigReadError::InvalidUri(_)))); -} - -#[tokio::test] -async fn bad_file_path_returns_right_error() { - let bad_config = ConfigFileSection { - app_uuid: "".to_string(), - mauth_baseurl: "https://example.com/".to_string(), - mauth_api_version: "v1".to_string(), - private_key_file: "no_such_file".to_string(), - v2_only_sign_requests: None, - v2_only_authenticate: None, - }; - let load_result = MAuthInfo::from_config_section(&bad_config, None); - assert!(matches!( - load_result, - Err(ConfigReadError::FileReadError(_)) - )); -} - -#[tokio::test] -async fn bad_key_file_returns_right_error() { - let filename = "dummy_file"; - fs::write(&filename, b"definitely not a key").await.unwrap(); - let bad_config = ConfigFileSection { - app_uuid: "".to_string(), - mauth_baseurl: "https://example.com/".to_string(), - mauth_api_version: "v1".to_string(), - private_key_file: filename.to_string(), - v2_only_sign_requests: None, - v2_only_authenticate: None, - }; - let load_result = MAuthInfo::from_config_section(&bad_config, None); - fs::remove_file(&filename).await.unwrap(); - assert!(matches!(load_result, Err(ConfigReadError::OpenSSLError(_)))); -} - -#[tokio::test] -async fn bad_uuid_returns_right_error() { - let filename = "valid_key_file"; - let rsa_obj = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); - fs::write(&filename, rsa_obj.private_key_to_pem_pkcs8().unwrap()) - .await - .unwrap(); - let bad_config = ConfigFileSection { - app_uuid: "".to_string(), - mauth_baseurl: "https://example.com/".to_string(), - mauth_api_version: "v1".to_string(), - private_key_file: filename.to_string(), - v2_only_sign_requests: None, - v2_only_authenticate: None, - }; - let load_result = MAuthInfo::from_config_section(&bad_config, None); - fs::remove_file(&filename).await.unwrap(); - assert!(matches!( - load_result, - Err(ConfigReadError::InvalidAppUuid(_)) - )); -} diff --git a/src/lib.rs b/src/lib.rs index f639383..c30e5bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +#![forbid(unsafe_code)] //! # mauth-client //! //! This crate allows users of the Reqwest crate for making HTTP requests to sign those requests with @@ -8,759 +9,65 @@ //! approval for the full stack used through the Architecture and Security groups. //! //! ```no_run -//! # use mauth_client::MAuthInfo; -//! # use reqwest::{Client, Request, Body, Url, Method, header::HeaderValue, Response}; +//! use mauth_client::MAuthInfo; +//! use reqwest::Client; //! # async fn make_signed_request() { //! let mauth_info = MAuthInfo::from_default_file().unwrap(); //! let client = Client::new(); -//! let uri: Url = "https://www.example.com/".parse().unwrap(); -//! let (body, body_digest) = MAuthInfo::build_body_with_digest("".to_string()); -//! let mut req = Request::new(Method::GET, uri); -//! *req.body_mut() = Some(body); -//! mauth_info.sign_request(&mut req, &body_digest); +//! let mut req = client.get("https://www.example.com/").build().unwrap(); +//! mauth_info.sign_request(&mut req); //! match client.execute(req).await { //! Err(err) => println!("Got error {}", err), -//! Ok(response) => match mauth_info.validate_response(response).await { -//! Ok(resp_body) => println!( -//! "Got validated response with body {}", -//! &String::from_utf8(resp_body).unwrap() -//! ), -//! Err(err) => println!("Error validating response: {:?}", err), -//! } +//! Ok(response) => println!("Got validated response with body {}", response.text().await.unwrap()), //! } //! # } //! ``` //! +//! +//! The above code will read your mauth configuration from a file in `~/.mauth_config.yml` which format is: +//! ```yaml +//! common: &common +//! mauth_baseurl: https:// +//! mauth_api_version: v1 +//! app_uuid: +//! private_key_file: +//! ``` +//! //! The optional `axum-service` feature provides for a Tower Layer and Service that will //! authenticate incoming requests via MAuth V2 or V1 and provide to the lower layers a //! validated app_uuid from the request via the ValidatedRequestDetails struct. +use mauth_core::signer::Signer; +use mauth_core::verifier::Verifier; +use reqwest::Url; use std::collections::HashMap; use std::sync::{Arc, RwLock}; - -use base64::Engine; -use chrono::prelude::*; -use percent_encoding::{percent_decode_str, percent_encode, AsciiSet, NON_ALPHANUMERIC}; -use regex::{Captures, Regex}; -use reqwest::{header::HeaderValue, Body, Client, Method, Request, Response, Url}; -use ring::rand::SystemRandom; -use ring::signature::{ - RsaKeyPair, UnparsedPublicKey, RSA_PKCS1_2048_8192_SHA512, RSA_PKCS1_SHA512, -}; -use serde::Deserialize; -use sha2::{Digest, Sha512}; -use thiserror::Error; -use tokio::io; use uuid::Uuid; -use openssl::pkey::{PKey, Private, Public}; -use openssl::rsa::{Padding, Rsa}; - -const CONFIG_FILE: &str = ".mauth_config.yml"; - /// This is the primary struct of this class. It contains all of the information /// required to sign requests using the MAuth protocol and verify the responses. /// /// Note that it contains a cache of response keys for verifying response signatures. This cache /// makes the struct non-Sync. +#[allow(dead_code)] pub struct MAuthInfo { app_id: Uuid, - private_key: RsaKeyPair, - openssl_private_key: Rsa, - remote_key_store: Arc>>>, - mauth_uri_base: Url, sign_with_v1_also: bool, + signer: Signer, + remote_key_store: Arc>>, + mauth_uri_base: Url, allow_v1_auth: bool, } -/// This struct holds the digest information required to perform the signing operation. It is a -/// custom struct to enforce the requirement that the -/// [`build_body_with_digest`](#method.build_body_with_digest) function's output be passed to the -/// signing methods. -pub struct BodyDigest { - digest_str: String, - body_data: Vec, -} - -/// This struct holds the app UUID for a validated request. It is meant to be used with the -/// Extension setup in Hyper requests, where it is placed in requests that passed authentication. -/// The custom struct makes it clearer that the request has passed and this is an authenticated -/// app UUID and not some random UUID that some other component put in place for some other -/// purpose. +/// Tower Service and Layer to allow Tower-integrated servers to validate incoming request #[cfg(feature = "axum-service")] -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct ValidatedRequestDetails { - pub app_uuid: Uuid, -} - -/// All of the configuration data needed to set up a MAuthInfo struct. Implements Deserialize -/// to be read from a YAML file easily, or can be created manually. -#[derive(Deserialize, Clone)] -pub struct ConfigFileSection { - pub app_uuid: String, - pub mauth_baseurl: String, - pub mauth_api_version: String, - pub private_key_file: String, - pub v2_only_sign_requests: Option, - pub v2_only_authenticate: Option, -} - -impl MAuthInfo { - /// Construct the MAuthInfo struct based on the contents of the config file `.mauth_config.yml` - /// present in the current user's home directory. Returns an enum error type that includes the - /// error types of all crates used. - pub fn from_default_file() -> Result { - Self::from_config_section(&Self::config_section_from_default_file()?, None) - } - - fn config_section_from_default_file() -> Result { - let mut home = dirs::home_dir().unwrap(); - home.push(CONFIG_FILE); - let config_data = std::fs::read_to_string(&home)?; - - let config_data_value: serde_yaml::Value = - serde_yaml::from_slice(&config_data.into_bytes())?; - let common_section = config_data_value - .get("common") - .ok_or(ConfigReadError::InvalidFile(None))?; - let common_section_typed: ConfigFileSection = - serde_yaml::from_value(common_section.clone())?; - Ok(common_section_typed) - } - - /// Construct the MAuthInfo struct based on a passed-in ConfigFileSection instance. The - /// optional input_keystore is present to support internal cloning and need not be provided - /// if being used outside of the crate. - pub fn from_config_section( - section: &ConfigFileSection, - input_keystore: Option>>>>, - ) -> Result { - let full_uri: Url = format!( - "{}/mauth/{}/security_tokens/", - §ion.mauth_baseurl, §ion.mauth_api_version - ) - .parse()?; - - let pk_data = std::fs::read_to_string(§ion.private_key_file)?; - let openssl_key = PKey::private_key_from_pem(&pk_data.into_bytes())?; - let der_key_data = openssl_key.private_key_to_der()?; - - Ok(MAuthInfo { - app_id: Uuid::parse_str(§ion.app_uuid)?, - mauth_uri_base: full_uri, - remote_key_store: input_keystore - .unwrap_or_else(|| Arc::new(RwLock::new(HashMap::new()))), - private_key: RsaKeyPair::from_der(&der_key_data)?, - openssl_private_key: openssl_key.rsa()?, - sign_with_v1_also: !section.v2_only_sign_requests.unwrap_or(false), - allow_v1_auth: !section.v2_only_authenticate.unwrap_or(false), - }) - } - - /// The MAuth Protocol requires computing a digest of the full text body of the request to be - /// sent. This is incompatible with the Reqwest crate's structs, which do not allow the body of a - /// constructed Request to be read. To solve this, use this function to compute both the body to - /// be used to build the Request struct, and the digest struct to be passed to the - /// [`sign_request_v2`](#method.sign_request_v2) function. - /// - /// Note that this method must be used with all empty-body requests, including GET requests. - pub fn build_body_with_digest(body: String) -> (Body, BodyDigest) { - let mut hasher = Sha512::default(); - hasher.update(body.as_bytes()); - ( - Body::from(body.clone()), - BodyDigest { - digest_str: hex::encode(hasher.finalize()), - body_data: body.into_bytes(), - }, - ) - } - - /// The MAuth Protocol requires computing a digest of the full text body of the request to be - /// sent. This is incompatible with the Reqwest crate's structs, which do not allow the body of a - /// constructed Request to be read. To solve this, use this function to compute both the body to - /// be used to build the Request struct, and the digest struct to be passed to the - /// [`sign_request_v2`](#method.sign_request_v2) function. - /// - /// This function is an alternate version of the build_body_with_digest function that allows - /// the user to build request bodies from data that does not meet the Rust String type - /// requirements of being valid UTF8. Any binary data can be transformed into the appropriate - /// objects and signed using this function. - /// - /// Note that this method must be used with all empty-body requests, including GET requests. - pub fn build_body_with_digest_from_bytes(body: Vec) -> (Body, BodyDigest) { - let mut hasher = Sha512::default(); - hasher.update(body.clone()); - ( - Body::from(body.clone()), - BodyDigest { - digest_str: hex::encode(hasher.finalize()), - body_data: body, - }, - ) - } - - /// This method determines how to sign the request automatically while respecting the - /// `v2_only_sign_requests` flag in the config file. It always signs with the V2 algorithm and - /// signature, and will also sign with the V1 algorithm, if the configuration permits. - pub fn sign_request(&self, req: &mut Request, body_digest: &BodyDigest) { - self.sign_request_v2(req, body_digest); - if self.sign_with_v1_also { - self.sign_request_v1(req, body_digest); - } - } - - /// Validate that a Reqwest Response contains a valid MAuth signature. Returns either the - /// validated response body, or an error with details on why the signature was invalid. - /// - /// This method will attempt to validate a V2 signature first. If that fails, and if the - /// flag `allow_v1_response_auth` is set in the configuration, it will then attempt to validate - /// a V1 signature. It will return `Ok(body)` if the request successfully authenticates, - /// otherwise, it will return the most recent validation error. - /// - /// This function requires a mutable borrow of the response and will consume the body contents, - /// as that is the only way to get the body out and perform the necessary hashing on it. Once - /// the validation is complete, the other properties of the response may be inspected as - /// needed. - /// - /// This method is `async` because it may make a HTTP request to the MAuth server in order to - /// retrieve the public key for the application that signed the response. Application keys are - /// cached in the MAuth struct, so the request only needs to be made once. - pub async fn validate_response( - &self, - response: Response, - ) -> Result, MAuthValidationError> { - let status = response.status(); - let headers = response.headers().clone(); - let body_raw = response.bytes().await.unwrap(); - match self - .validate_response_v2(&status, &headers, &body_raw) - .await - { - Ok(body) => Ok(body), - Err(v2_error) => { - if self.allow_v1_auth { - self.validate_response_v1(&status, &headers, &body_raw) - .await - } else { - Err(v2_error) - } - } - } - } - - #[cfg(feature = "axum-service")] - async fn validate_request( - &self, - req: axum::extract::Request, - ) -> Result { - let (mut parts, body) = req.into_parts(); - let body_bytes = axum::body::to_bytes(body, usize::MAX) - .await - .map_err(|_| MAuthValidationError::InvalidBody)?; - match self.validate_request_v2(&parts, &body_bytes).await { - Ok(host_app_uuid) => { - parts.extensions.insert(ValidatedRequestDetails { - app_uuid: host_app_uuid, - }); - let new_body = axum::body::Body::from(body_bytes); - let new_request = axum::extract::Request::from_parts(parts, new_body); - Ok(new_request) - } - Err(err) => { - if self.allow_v1_auth { - match self.validate_request_v1(&parts, &body_bytes).await { - Ok(host_app_uuid) => { - parts.extensions.insert(ValidatedRequestDetails { - app_uuid: host_app_uuid, - }); - let new_body = axum::body::Body::from(body_bytes); - let new_request = axum::extract::Request::from_parts(parts, new_body); - Ok(new_request) - } - Err(err) => Err(err), - } - } else { - Err(err) - } - } - } - } - - /// Sign a provided request using the MAuth V2 protocol. The signature consists of 2 headers - /// containing both a timestamp and a signature string, and will be added to the headers of the - /// request. It is required to pass a `body_digest` computed by the - /// [`build_body_with_digest`](#method.build_body_with_digest) method, even if the request is - /// an empty-body GET. - /// - /// Note that, as the request signature includes a timestamp, the request must be sent out - /// shortly after the signature takes place. - pub fn sign_request_v2(&self, req: &mut Request, body_digest: &BodyDigest) { - let timestamp_str = Utc::now().timestamp().to_string(); - let string_to_sign = self.get_signing_string_v2(req, body_digest, ×tamp_str); - let signature = self.sign_string_v2(string_to_sign); - self.set_headers_v2(req, signature, ×tamp_str); - } - - #[cfg(feature = "axum-service")] - async fn validate_request_v2( - &self, - req: &http::request::Parts, - body_bytes: &bytes::Bytes, - ) -> Result { - let mut hasher = Sha512::default(); - hasher.update(body_bytes); - - //retrieve and parse auth string - let sig_header = req - .headers - .get("MCC-Authentication") - .ok_or(MAuthValidationError::NoSig)? - .to_str() - .map_err(|_| MAuthValidationError::InvalidSignature)?; - let (host_app_uuid, raw_signature) = Self::split_auth_string(sig_header, "MWSV2")?; - - //retrieve and validate timestamp - let ts_str = req - .headers - .get("MCC-Time") - .ok_or(MAuthValidationError::NoTime)? - .to_str() - .map_err(|_| MAuthValidationError::InvalidTime)?; - Self::validate_timestamp(ts_str)?; - - //Compute response signing string - let encoded_query: String = req.uri.query().map_or("".to_string(), Self::encode_query); - - let string_to_sign = format!( - "{}\n{}\n{}\n{}\n{}\n{}", - req.method, - Self::normalize_url(req.uri.path()), - &hex::encode(hasher.finalize()), - &host_app_uuid, - &ts_str, - &encoded_query - ); - - match self.get_app_pub_key(&host_app_uuid).await { - None => Err(MAuthValidationError::KeyUnavailable), - Some(pub_key) => { - let ring_key = UnparsedPublicKey::new( - &RSA_PKCS1_2048_8192_SHA512, - bytes::Bytes::from(pub_key.public_key_to_der_pkcs1().unwrap()), - ); - match ring_key.verify(&string_to_sign.into_bytes(), &raw_signature) { - Err(_) => Err(MAuthValidationError::SignatureVerifyFailure), - Ok(()) => Ok(host_app_uuid), - } - } - } - } - - #[cfg(feature = "axum-service")] - async fn validate_request_v1( - &self, - req: &http::request::Parts, - body_bytes: &bytes::Bytes, - ) -> Result { - //retrieve and parse auth string - let sig_header = req - .headers - .get("X-MWS-Authentication") - .ok_or(MAuthValidationError::NoSig)? - .to_str() - .map_err(|_| MAuthValidationError::InvalidSignature)?; - let (host_app_uuid, raw_signature) = Self::split_auth_string(sig_header, "MWS")?; - - //retrieve and validate timestamp - let ts_str = req - .headers - .get("X-MWS-Time") - .ok_or(MAuthValidationError::NoTime)? - .to_str() - .map_err(|_| MAuthValidationError::InvalidTime)?; - Self::validate_timestamp(ts_str)?; - - //Compute response signing string - let mut hasher = Sha512::default(); - let string_to_sign1 = format!("{}\n{}\n", req.method, req.uri.path()); - hasher.update(string_to_sign1.into_bytes()); - hasher.update(body_bytes); - let string_to_sign2 = format!("\n{}\n{}", &host_app_uuid, &ts_str); - hasher.update(string_to_sign2.into_bytes()); - let sign_input: Vec = hex::encode(hasher.finalize()).into_bytes(); - - match self.get_app_pub_key(&host_app_uuid).await { - None => Err(MAuthValidationError::KeyUnavailable), - Some(pub_key) => { - let mut sign_output: Vec = vec![0; pub_key.size() as usize]; - let len = pub_key - .public_decrypt(&raw_signature, &mut sign_output, Padding::PKCS1) - .unwrap(); - if *sign_input.as_slice() == sign_output[0..len] { - Ok(host_app_uuid) - } else { - Err(MAuthValidationError::SignatureVerifyFailure) - } - } - } - } - - fn get_signing_string_v2( - &self, - req: &Request, - body_digest: &BodyDigest, - timestamp_str: &str, - ) -> String { - let encoded_query: String = req.url().query().map_or("".to_string(), Self::encode_query); - format!( - "{}\n{}\n{}\n{}\n{}\n{}", - req.method(), - Self::normalize_url(req.url().path()), - &body_digest.digest_str, - &self.app_id, - ×tamp_str, - &encoded_query - ) - } - - fn sign_string_v2(&self, string: String) -> String { - let mut signature = vec![0; self.private_key.public().modulus_len()]; - self.private_key - .sign( - &RSA_PKCS1_SHA512, - &SystemRandom::new(), - &string.into_bytes(), - &mut signature, - ) - .unwrap(); - let b64 = base64::engine::general_purpose::STANDARD; - b64.encode(&signature) - } - - fn set_headers_v2(&self, req: &mut Request, signature: String, timestamp_str: &str) { - let sig_head_str = format!("MWSV2 {}:{};", self.app_id, &signature); - let headers = req.headers_mut(); - headers.insert("MCC-Time", HeaderValue::from_str(timestamp_str).unwrap()); - headers.insert( - "MCC-Authentication", - HeaderValue::from_str(&sig_head_str).unwrap(), - ); - } - - const MAUTH_ENCODE_CHARS: &'static AsciiSet = &NON_ALPHANUMERIC - .remove(b'-') - .remove(b'_') - .remove(b'%') - .remove(b'.') - .remove(b'~'); - - fn encode_query(qstr: &str) -> String { - let mut temp_param_list: Vec>> = qstr - .split('&') - .map(|p| { - p.split('=') - .map(|x| percent_decode_str(&x.replace('+', " ")).collect()) - .collect() - }) - .collect(); - - temp_param_list.sort(); - temp_param_list - .iter() - .map(|p| { - p.iter() - .map(|x| percent_encode(x, Self::MAUTH_ENCODE_CHARS).to_string()) - .collect::>() - .join("=") - }) - .collect::>() - .join("&") - } - - fn normalize_url(urlstr: &str) -> String { - let squeeze_regex = Regex::new(r"/+").unwrap(); - let url = squeeze_regex.replace_all(urlstr, "/"); - let percent_case_regex = Regex::new(r"%[a-f0-9]{2}").unwrap(); - let url = percent_case_regex.replace_all(&url, |c: &Captures| c[0].to_uppercase()); - let mut url = url.replace("/./", "/"); - let path_regex2 = Regex::new(r"/[^/]+/\.\./?").unwrap(); - loop { - let new_url = path_regex2.replace_all(&url, "/").to_string(); - if new_url == url { - return new_url; - } else { - url = new_url; - } - } - } - - /// Sign a provided request using the MAuth V1 protocol. The signature consists of 2 headers - /// containing both a timestamp and a signature string, and will be added to the headers of the - /// request. It is required to pass a `body`, even if the request is an empty-body GET. - /// - /// Note that, as the request signature includes a timestamp, the request must be sent out - /// shortly after the signature takes place. - pub fn sign_request_v1(&self, req: &mut Request, body: &BodyDigest) { - let timestamp_str = Utc::now().timestamp().to_string(); - let mut hasher = Sha512::default(); - let string_to_sign1 = format!("{}\n{}\n", req.method(), req.url().path()); - hasher.update(string_to_sign1.into_bytes()); - hasher.update(body.body_data.clone()); - let string_to_sign2 = format!("\n{}\n{}", &self.app_id, ×tamp_str); - hasher.update(string_to_sign2.into_bytes()); - - let mut sign_output = vec![0; self.openssl_private_key.size() as usize]; - self.openssl_private_key - .private_encrypt( - &hex::encode(hasher.finalize()).into_bytes(), - &mut sign_output, - Padding::PKCS1, - ) - .unwrap(); - let b64 = base64::engine::general_purpose::STANDARD; - let signature = format!("MWS {}:{}", self.app_id, b64.encode(&sign_output)); - - let headers = req.headers_mut(); - headers.insert("X-MWS-Time", HeaderValue::from_str(×tamp_str).unwrap()); - headers.insert( - "X-MWS-Authentication", - HeaderValue::from_str(&signature).unwrap(), - ); - } - - fn validate_timestamp(timestamp_str: &str) -> Result<(), MAuthValidationError> { - let ts_num: i64 = timestamp_str - .parse() - .map_err(|_| MAuthValidationError::InvalidTime)?; - let ts_diff = ts_num - Utc::now().timestamp(); - if !(-300..=300).contains(&ts_diff) { - Err(MAuthValidationError::InvalidTime) - } else { - Ok(()) - } - } - - fn split_auth_string( - auth_str: &str, - expected_prefix: &str, - ) -> Result<(Uuid, Vec), MAuthValidationError> { - let header_pattern = vec![' ', ':', ';']; - let mut header_split = auth_str.split(header_pattern.as_slice()); - - let start_str = header_split - .next() - .ok_or(MAuthValidationError::InvalidSignature)?; - if start_str != expected_prefix { - return Err(MAuthValidationError::InvalidSignature); - } - let host_uuid_str = header_split - .next() - .ok_or(MAuthValidationError::InvalidSignature)?; - let host_app_uuid = - Uuid::parse_str(host_uuid_str).map_err(|_| MAuthValidationError::InvalidSignature)?; - let signature_encoded_string = header_split - .next() - .ok_or(MAuthValidationError::InvalidSignature)?; - let b64 = base64::engine::general_purpose::STANDARD; - let raw_signature: Vec = b64 - .decode(signature_encoded_string) - .map_err(|_| MAuthValidationError::InvalidSignature)?; - Ok((host_app_uuid, raw_signature)) - } - - async fn validate_response_v2( - &self, - status: &reqwest::StatusCode, - headers: &reqwest::header::HeaderMap, - body_raw: &[u8], - ) -> Result, MAuthValidationError> { - //retrieve and validate timestamp - let ts_str = headers - .get("MCC-Time") - .ok_or(MAuthValidationError::NoTime)? - .to_str() - .map_err(|_| MAuthValidationError::InvalidTime)?; - Self::validate_timestamp(ts_str)?; - - //retrieve and parse auth string - let sig_header = headers - .get("MCC-Authentication") - .ok_or(MAuthValidationError::NoSig)? - .to_str() - .map_err(|_| MAuthValidationError::InvalidSignature)?; - let (host_app_uuid, raw_signature) = Self::split_auth_string(sig_header, "MWSV2")?; - - //Compute response signing string - let mut hasher = Sha512::default(); - hasher.update(body_raw); - let string_to_sign = format!( - "{}\n{}\n{}\n{}", - &status.as_u16(), - hex::encode(hasher.finalize()), - &host_app_uuid, - &ts_str, - ); - - match self.get_app_pub_key(&host_app_uuid).await { - None => Err(MAuthValidationError::KeyUnavailable), - Some(pub_key) => { - let ring_key = UnparsedPublicKey::new( - &RSA_PKCS1_2048_8192_SHA512, - bytes::Bytes::from(pub_key.public_key_to_der_pkcs1().unwrap()), - ); - match ring_key.verify(&string_to_sign.into_bytes(), &raw_signature) { - Ok(()) => Ok(body_raw.to_vec()), - Err(_) => Err(MAuthValidationError::SignatureVerifyFailure), - } - } - } - } - - async fn validate_response_v1( - &self, - status: &reqwest::StatusCode, - headers: &reqwest::header::HeaderMap, - body_raw: &[u8], - ) -> Result, MAuthValidationError> { - //retrieve and validate timestamp - let ts_str = headers - .get("X-MWS-Time") - .ok_or(MAuthValidationError::NoTime)? - .to_str() - .map_err(|_| MAuthValidationError::InvalidTime)?; - Self::validate_timestamp(ts_str)?; - - //retrieve and parse auth string - let sig_header = headers - .get("X-MWS-Authentication") - .ok_or(MAuthValidationError::NoSig)? - .to_str() - .map_err(|_| MAuthValidationError::InvalidSignature)?; - let (host_app_uuid, raw_signature) = Self::split_auth_string(sig_header, "MWS")?; - - //Build signature string and hash to final format - let mut hasher = Sha512::default(); - let string1 = format!("{}\n", &status.as_u16()); - hasher.update(&string1.into_bytes()); - hasher.update(body_raw); - let string2 = format!("\n{}\n{}", &host_app_uuid, &ts_str); - hasher.update(&string2.into_bytes()); - let sign_input: Vec = hex::encode(hasher.finalize()).into_bytes(); - - //Decrypt signature from server - let pub_key = self - .get_app_pub_key(&host_app_uuid) - .await - .ok_or(MAuthValidationError::KeyUnavailable)?; - let mut sign_output: Vec = vec![0; pub_key.size() as usize]; - let len = pub_key - .public_decrypt(&raw_signature, &mut sign_output, Padding::PKCS1) - .unwrap(); - - if *sign_input.as_slice() == sign_output[0..len] { - Ok(body_raw.to_vec()) - } else { - Err(MAuthValidationError::SignatureVerifyFailure) - } - } - - async fn get_app_pub_key(&self, app_uuid: &Uuid) -> Option> { - { - let key_store = self.remote_key_store.read().unwrap(); - if let Some(pub_key) = key_store.get(app_uuid) { - return Some(pub_key.clone()); - } - } - let client = Client::new(); - let (get_body, body_digest) = MAuthInfo::build_body_with_digest("".to_string()); - let uri = self.mauth_uri_base.join(&format!("{}", &app_uuid)).unwrap(); - let mut req = Request::new(Method::GET, uri); - *req.body_mut() = Some(get_body); - self.sign_request_v2(&mut req, &body_digest); - let mauth_response = client.execute(req).await; - match mauth_response { - Err(_) => None, - Ok(response) => { - let response_obj = response.json::().await.unwrap(); - let pub_key_str = response_obj - .pointer("/security_token/public_key_str") - .and_then(|s| s.as_str()) - .unwrap(); - let pub_key = Rsa::public_key_from_pem(pub_key_str.as_bytes()).unwrap(); - let mut key_store = self.remote_key_store.write().unwrap(); - key_store.insert(*app_uuid, pub_key.clone()); - Some(pub_key) - } - } - } -} - -#[cfg(test)] -mod config_test; +pub mod axum_service; +/// Helpers to parse configuration files or supply structs and construct instances of the main struct +pub mod config; #[cfg(test)] mod protocol_test_suite; - -/// All of the possible errors that can take place when attempting to read a config file. Errors -/// are specific to the libraries that created them, and include the details from those libraries. -#[derive(Debug, Error)] -pub enum ConfigReadError { - #[error("File Read Error: {0}")] - FileReadError(#[from] io::Error), - #[error("Not a valid maudit config file: {0:?}")] - InvalidFile(Option), - #[error("MAudit URI not valid: {0}")] - InvalidUri(#[from] url::ParseError), - #[error("App UUID not valid: {0}")] - InvalidAppUuid(#[from] uuid::Error), - #[error("Key error: {0}")] - OpenSSLError(#[from] openssl::error::ErrorStack), - #[error("Key error")] - RingKeyError(ring::error::KeyRejected), -} - -impl From for ConfigReadError { - fn from(err: serde_yaml::Error) -> ConfigReadError { - ConfigReadError::InvalidFile(Some(err)) - } -} - -impl From for ConfigReadError { - fn from(err: ring::error::KeyRejected) -> ConfigReadError { - ConfigReadError::RingKeyError(err) - } -} - -/// All of the possible errors that can take place when attempting to verify a response signature -#[derive(Debug, Error)] -pub enum MAuthValidationError { - /// The timestamp of the response was either invalid or outside of the permitted - /// range - #[error("The timestamp of the response was either invalid or outside of the permitted range")] - InvalidTime, - /// The MAuth signature of the response was either missing or incorrectly formatted - #[error("The MAuth signature of the response was either missing or incorrectly formatted")] - InvalidSignature, - /// The timestamp header of the response was missing - #[error("The timestamp header of the response was missing")] - NoTime, - /// The signature header of the response was missing - #[error("The signature header of the response was missing")] - NoSig, - /// An error occurred while attempting to retrieve part of the response body - #[error("An error occurred while attempting to retrieve part of the response body")] - ResponseProblem, - /// The response body failed to parse - #[error("The response body failed to parse")] - InvalidBody, - /// Attempt to retrieve a key to verify the response failed - #[error("Attempt to retrieve a key to verify the response failed")] - KeyUnavailable, - /// The body of the response did not match the signature - #[error("The body of the response did not match the signature")] - SignatureVerifyFailure, -} - +/// Implementation of code to sign outgoing requests +pub mod sign_outgoing; +/// Implementation of code to validate incoming requests #[cfg(feature = "axum-service")] -pub mod axum_service; +pub mod validate_incoming; diff --git a/src/protocol_test_suite.rs b/src/protocol_test_suite.rs index 19e8cb9..751ac07 100644 --- a/src/protocol_test_suite.rs +++ b/src/protocol_test_suite.rs @@ -1,18 +1,10 @@ -use crate::{ConfigFileSection, MAuthInfo}; +use crate::{config::ConfigFileSection, MAuthInfo}; use reqwest::{Method, Request}; use serde::Deserialize; use tokio::fs; use std::path::{Path, PathBuf}; -#[derive(Deserialize)] -struct RequestShape { - verb: String, - url: String, - body: Option, - body_filepath: Option, -} - #[derive(Deserialize)] struct TestSignConfig { app_uuid: String, @@ -20,7 +12,7 @@ struct TestSignConfig { private_key_file: String, } -const BASE_PATH: &'static str = "mauth-protocol-test-suite/protocols/MWSV2/"; +const BASE_PATH: &str = "mauth-protocol-test-suite/protocols/MWSV2/"; async fn setup_mauth_info() -> (MAuthInfo, u64) { let config_path = Path::new("mauth-protocol-test-suite/signing-config.json"); @@ -30,10 +22,11 @@ async fn setup_mauth_info() -> (MAuthInfo, u64) { app_uuid: sign_config.app_uuid, mauth_baseurl: "https://www.example.com/".to_string(), mauth_api_version: "v1".to_string(), - private_key_file: format!( + private_key_file: Some(format!( "mauth-protocol-test-suite{}", sign_config.private_key_file.replace('.', "") - ), + )), + private_key_data: None, v2_only_sign_requests: None, v2_only_authenticate: None, }; @@ -43,55 +36,6 @@ async fn setup_mauth_info() -> (MAuthInfo, u64) { ) } -async fn test_string_to_sign(file_name: String) { - let (mauth_info, req_time) = setup_mauth_info().await; - let mut req_file_path = PathBuf::from(&BASE_PATH); - req_file_path.push(format!("{name}/{name}.req", name = &file_name)); - let request_shape: RequestShape = - serde_json::from_slice(&fs::read(req_file_path).await.unwrap()).unwrap(); - - let mut sts_file_path = PathBuf::from(&BASE_PATH); - sts_file_path.push(format!("{name}/{name}.sts", name = &file_name)); - let expected_string_to_sign = - String::from_utf8(fs::read(sts_file_path).await.unwrap()).unwrap(); - - let body_data = match (request_shape.body, request_shape.body_filepath) { - (Some(direct_str), None) => direct_str.as_bytes().to_vec(), - (None, Some(filename_str)) => { - let mut body_file_path = PathBuf::from(&BASE_PATH); - body_file_path.push(&file_name); - body_file_path.push(filename_str); - fs::read(body_file_path).await.unwrap() - } - _ => vec![], - }; - - let (body, digest) = MAuthInfo::build_body_with_digest_from_bytes(body_data); - // It seems the Url class really doesn't like relative URLs - let fixed_url = format!("http://a.com{}", request_shape.url.replace(" ", "%20")); - let method = Method::from_bytes(request_shape.verb.as_bytes()).unwrap(); - let mut req = Request::new(method, fixed_url.parse().unwrap()); - *req.body_mut() = Some(body); - let sts = mauth_info.get_signing_string_v2(&req, &digest, &req_time.to_string()); - - assert_eq!(expected_string_to_sign, sts); -} - -async fn test_sign_string(file_name: String) { - let (mauth_info, _) = setup_mauth_info().await; - let mut sts_file_path = PathBuf::from(&BASE_PATH); - sts_file_path.push(format!("{name}/{name}.sts", name = &file_name)); - let string_to_sign = String::from_utf8(fs::read(sts_file_path).await.unwrap()).unwrap(); - - let mut sig_file_path = PathBuf::from(&BASE_PATH); - sig_file_path.push(format!("{name}/{name}.sig", name = &file_name)); - let expected_sig = String::from_utf8(fs::read(sig_file_path).await.unwrap()).unwrap(); - - let signed = mauth_info.sign_string_v2(string_to_sign); - - assert_eq!(expected_sig, signed); -} - async fn test_generate_headers(file_name: String) { let (mauth_info, req_time) = setup_mauth_info().await; diff --git a/src/sign_outgoing.rs b/src/sign_outgoing.rs new file mode 100644 index 0000000..998c08b --- /dev/null +++ b/src/sign_outgoing.rs @@ -0,0 +1,112 @@ +use crate::MAuthInfo; +use chrono::prelude::*; +use reqwest::{header::HeaderValue, Request}; +use thiserror::Error; + +impl MAuthInfo { + /// This method determines how to sign the request automatically while respecting the + /// `v2_only_sign_requests` flag in the config file. It always signs with the V2 algorithm and + /// signature, and will also sign with the V1 algorithm, if the configuration permits. + /// + /// Note that, as the request signature includes a timestamp, the request must be sent out + /// shortly after the signature takes place. + /// + /// Note that it will need to read the entire body in order to sign it, so it will not + /// work properly if any of the streaming body types are used. + pub fn sign_request(&self, req: &mut Request) -> Result<(), SigningError> { + self.sign_request_v2(req)?; + if self.sign_with_v1_also { + self.sign_request_v1(req)?; + } + Ok(()) + } + + /// Sign a provided request using the MAuth V2 protocol. The signature consists of 2 headers + /// containing both a timestamp and a signature string, and will be added to the headers of the + /// request. It is required to pass a `body_digest` computed by the + /// [`build_body_with_digest`](#method.build_body_with_digest) method, even if the request is + /// an empty-body GET. + /// + /// Note that, as the request signature includes a timestamp, the request must be sent out + /// shortly after the signature takes place. + /// + /// Also note that it will need to read the entire body in order to sign it, so it will not + /// work properly if any of the streaming body types are used. + pub fn sign_request_v2(&self, req: &mut Request) -> Result<(), SigningError> { + let timestamp_str = Utc::now().timestamp().to_string(); + let body_data = match req.body() { + None => &[], + Some(reqwest_body) => reqwest_body.as_bytes().unwrap_or(&[]), + }; + let some_string = self.signer.sign_string( + 2, + req.method().as_str(), + req.url().path(), + req.url().query().unwrap_or(""), + body_data, + timestamp_str.clone(), + )?; + self.set_headers_v2(req, some_string, ×tamp_str); + Ok(()) + } + + pub(crate) fn set_headers_v2(&self, req: &mut Request, signature: String, timestamp_str: &str) { + let sig_head_str = format!("MWSV2 {}:{};", self.app_id, &signature); + let headers = req.headers_mut(); + headers.insert("MCC-Time", HeaderValue::from_str(timestamp_str).unwrap()); + headers.insert( + "MCC-Authentication", + HeaderValue::from_str(&sig_head_str).unwrap(), + ); + } + + /// Sign a provided request using the MAuth V1 protocol. The signature consists of 2 headers + /// containing both a timestamp and a signature string, and will be added to the headers of the + /// request. It is required to pass a `body`, even if the request is an empty-body GET. + /// + /// Note that, as the request signature includes a timestamp, the request must be sent out + /// shortly after the signature takes place. + /// + /// Also note that it will need to read the entire body in order to sign it, so it will not + /// work properly if any of the streaming body types are used. + pub fn sign_request_v1(&self, req: &mut Request) -> Result<(), SigningError> { + let timestamp_str = Utc::now().timestamp().to_string(); + + let body_data = match req.body() { + None => &[], + Some(reqwest_body) => reqwest_body.as_bytes().unwrap_or(&[]), + }; + + let sig = self.signer.sign_string( + 1, + req.method().as_str(), + req.url().path(), + req.url().query().unwrap_or(""), + body_data, + timestamp_str.clone(), + )?; + + let headers = req.headers_mut(); + headers.insert("X-MWS-Time", HeaderValue::from_str(×tamp_str).unwrap()); + headers.insert("X-MWS-Authentication", HeaderValue::from_str(&sig).unwrap()); + Ok(()) + } +} + +/// All of the errors that can take place while attempting to sign a request +#[derive(Debug, Error)] +pub enum SigningError { + #[error("Unable to handle the URL as the format was invalid: {0}")] + UrlEncodingError(std::string::FromUtf8Error), +} + +impl From for SigningError { + fn from(err: mauth_core::error::Error) -> SigningError { + match err { + mauth_core::error::Error::UrlEncodingError(url_err) => { + SigningError::UrlEncodingError(url_err) + } + _ => panic!("should not be possible to get this error type from signing a request"), + } + } +} diff --git a/src/validate_incoming.rs b/src/validate_incoming.rs new file mode 100644 index 0000000..9061974 --- /dev/null +++ b/src/validate_incoming.rs @@ -0,0 +1,253 @@ +use crate::MAuthInfo; +use chrono::prelude::*; +use mauth_core::verifier::Verifier; +use reqwest::{Client, Method, Request}; +use thiserror::Error; +use uuid::Uuid; + +/// This struct holds the app UUID for a validated request. It is meant to be used with the +/// Extension setup in Hyper requests, where it is placed in requests that passed authentication. +/// The custom struct makes it clearer that the request has passed and this is an authenticated +/// app UUID and not some random UUID that some other component put in place for some other +/// purpose. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct ValidatedRequestDetails { + pub app_uuid: Uuid, +} + +impl MAuthInfo { + pub(crate) async fn validate_request( + &self, + req: axum::extract::Request, + ) -> Result { + let (mut parts, body) = req.into_parts(); + let body_bytes = axum::body::to_bytes(body, usize::MAX) + .await + .map_err(|_| MAuthValidationError::InvalidBody)?; + match self.validate_request_v2(&parts, &body_bytes).await { + Ok(host_app_uuid) => { + parts.extensions.insert(ValidatedRequestDetails { + app_uuid: host_app_uuid, + }); + let new_body = axum::body::Body::from(body_bytes); + let new_request = axum::extract::Request::from_parts(parts, new_body); + Ok(new_request) + } + Err(err) => { + if self.allow_v1_auth { + match self.validate_request_v1(&parts, &body_bytes).await { + Ok(host_app_uuid) => { + parts.extensions.insert(ValidatedRequestDetails { + app_uuid: host_app_uuid, + }); + let new_body = axum::body::Body::from(body_bytes); + let new_request = axum::extract::Request::from_parts(parts, new_body); + Ok(new_request) + } + Err(err) => Err(err), + } + } else { + Err(err) + } + } + } + } + + async fn validate_request_v2( + &self, + req: &http::request::Parts, + body_bytes: &bytes::Bytes, + ) -> Result { + //retrieve and parse auth string + let sig_header = req + .headers + .get("MCC-Authentication") + .ok_or(MAuthValidationError::NoSig)? + .to_str() + .map_err(|_| MAuthValidationError::InvalidSignature)?; + let (host_app_uuid, raw_signature) = Self::split_auth_string(sig_header, "MWSV2")?; + + //retrieve and validate timestamp + let ts_str = req + .headers + .get("MCC-Time") + .ok_or(MAuthValidationError::NoTime)? + .to_str() + .map_err(|_| MAuthValidationError::InvalidTime)?; + Self::validate_timestamp(ts_str)?; + + match self.get_app_pub_key(&host_app_uuid).await { + None => Err(MAuthValidationError::KeyUnavailable), + Some(verifier) => { + if let Ok(signature) = String::from_utf8(raw_signature) { + match verifier.verify_signature( + 2, + req.method.as_str(), + req.uri.path(), + req.uri.query().unwrap_or(""), + body_bytes, + ts_str, + signature, + ) { + Ok(()) => Ok(host_app_uuid), + Err(_) => Err(MAuthValidationError::SignatureVerifyFailure), + } + } else { + Err(MAuthValidationError::SignatureVerifyFailure) + } + } + } + } + + async fn validate_request_v1( + &self, + req: &http::request::Parts, + body_bytes: &bytes::Bytes, + ) -> Result { + //retrieve and parse auth string + let sig_header = req + .headers + .get("X-MWS-Authentication") + .ok_or(MAuthValidationError::NoSig)? + .to_str() + .map_err(|_| MAuthValidationError::InvalidSignature)?; + let (host_app_uuid, raw_signature) = Self::split_auth_string(sig_header, "MWS")?; + + //retrieve and validate timestamp + let ts_str = req + .headers + .get("X-MWS-Time") + .ok_or(MAuthValidationError::NoTime)? + .to_str() + .map_err(|_| MAuthValidationError::InvalidTime)?; + Self::validate_timestamp(ts_str)?; + + match self.get_app_pub_key(&host_app_uuid).await { + None => Err(MAuthValidationError::KeyUnavailable), + Some(verifier) => { + if let Ok(signature) = String::from_utf8(raw_signature) { + match verifier.verify_signature( + 1, + req.method.as_str(), + req.uri.path(), + req.uri.query().unwrap_or(""), + body_bytes, + ts_str, + signature, + ) { + Ok(()) => Ok(host_app_uuid), + Err(_) => Err(MAuthValidationError::SignatureVerifyFailure), + } + } else { + Err(MAuthValidationError::SignatureVerifyFailure) + } + } + } + } + + fn validate_timestamp(timestamp_str: &str) -> Result<(), MAuthValidationError> { + let ts_num: i64 = timestamp_str + .parse() + .map_err(|_| MAuthValidationError::InvalidTime)?; + let ts_diff = ts_num - Utc::now().timestamp(); + if !(-300..=300).contains(&ts_diff) { + Err(MAuthValidationError::InvalidTime) + } else { + Ok(()) + } + } + + fn split_auth_string( + auth_str: &str, + expected_prefix: &str, + ) -> Result<(Uuid, Vec), MAuthValidationError> { + let header_pattern = vec![' ', ':', ';']; + let mut header_split = auth_str.split(header_pattern.as_slice()); + + let start_str = header_split + .next() + .ok_or(MAuthValidationError::InvalidSignature)?; + if start_str != expected_prefix { + return Err(MAuthValidationError::InvalidSignature); + } + let host_uuid_str = header_split + .next() + .ok_or(MAuthValidationError::InvalidSignature)?; + let host_app_uuid = + Uuid::parse_str(host_uuid_str).map_err(|_| MAuthValidationError::InvalidSignature)?; + let signature_encoded_string = header_split + .next() + .ok_or(MAuthValidationError::InvalidSignature)?; + Ok((host_app_uuid, signature_encoded_string.into())) + } + + async fn get_app_pub_key(&self, app_uuid: &Uuid) -> Option { + { + let key_store = self.remote_key_store.read().unwrap(); + if let Some(pub_key) = key_store.get(app_uuid) { + return Some(pub_key.clone()); + } + } + let client = Client::new(); + let uri = self.mauth_uri_base.join(&format!("{}", &app_uuid)).unwrap(); + let mut req = Request::new(Method::GET, uri); + // This can only error with invalid UTF8 format, which is impossible here + self.sign_request_v2(&mut req).unwrap(); + let mauth_response = client.execute(req).await; + match mauth_response { + Err(_) => None, + Ok(response) => { + if let Ok(response_obj) = response.json::().await { + if let Some(pub_key_str) = response_obj + .pointer("/security_token/public_key_str") + .and_then(|s| s.as_str()) + .map(|st| st.to_owned()) + { + if let Ok(verifier) = Verifier::new(*app_uuid, pub_key_str) { + let mut key_store = self.remote_key_store.write().unwrap(); + key_store.insert(*app_uuid, verifier.clone()); + Some(verifier) + } else { + None + } + } else { + None + } + } else { + None + } + } + } + } +} + +/// All of the possible errors that can take place when attempting to verify a response signature +#[derive(Debug, Error)] +pub enum MAuthValidationError { + /// The timestamp of the response was either invalid or outside of the permitted + /// range + #[error("The timestamp of the response was either invalid or outside of the permitted range")] + InvalidTime, + /// The MAuth signature of the response was either missing or incorrectly formatted + #[error("The MAuth signature of the response was either missing or incorrectly formatted")] + InvalidSignature, + /// The timestamp header of the response was missing + #[error("The timestamp header of the response was missing")] + NoTime, + /// The signature header of the response was missing + #[error("The signature header of the response was missing")] + NoSig, + /// An error occurred while attempting to retrieve part of the response body + #[error("An error occurred while attempting to retrieve part of the response body")] + ResponseProblem, + /// The response body failed to parse + #[error("The response body failed to parse")] + InvalidBody, + /// Attempt to retrieve a key to verify the response failed + #[error("Attempt to retrieve a key to verify the response failed")] + KeyUnavailable, + /// The body of the response did not match the signature + #[error("The body of the response did not match the signature")] + SignatureVerifyFailure, +}