From 2fe44db773bbf8ee7c4e306e08973ba25e6af10e Mon Sep 17 00:00:00 2001 From: Hansie Odendaal <39146854+hansieodendaal@users.noreply.github.com> Date: Fri, 10 Nov 2023 10:01:18 +0200 Subject: [PATCH] feat: add constant time comparison for grpc authentication (#5902) Description --- - Added constant-time username comparison for gRPC authentication. This will largely mitigate side-channel attacks to uncover the gRPC username. (See `BasicAuthCredentials::constant_time_compare_username`) - **Edit:** Credential validation for the combined username and password will now also run in constant time and not return if the username did not match as it did previously. - **Edit:** Fixed an issue where the `BasicAuthCredentials` from header did not pass validation, impacted in `fn it_generates_a_valid_header()` and `it_decodes_from_well_formed_header`. (Closes #5810) Motivation and Context --- See #5810 How Has This Been Tested? --- - Added two unit tests to compare constant time performance with varying length username guesses. The unit test was performed in release mode with a no-load and fully loaded CPU. - `fn it_compares_user_names_in_constant_time()` ``` // This unit test asserts that the minimum variance is less than 10% (chosen to be robust for running the unit // test with CI), indicating that the function behaves within acceptable constant-time constraints. // // Some consecutive results running in release mode on a Core i7-12700H (with no other processes running): // // Minimum variance: 0.247 % // Average variance: 4.65738 % // Average short username time: 1.17486 microseconds // Average long username time: 1.17344 microseconds // Average actual username time: 1.18388 microseconds // // Minimum variance: 0.10214 % // Average variance: 4.32226 % // Average short username time: 1.1619 microseconds // Average long username time: 1.16591 microseconds // Average actual username time: 1.18157 microseconds // // Minimum variance: 0.17953 % // Average variance: 5.51519 % // Average short username time: 1.17974 microseconds // Average long username time: 1.19232 microseconds // Average actual username time: 1.18709 microseconds // // Some consecutive results running in release mode on a Core i7-12700H (while entire CPU fully stressed): // // Minimum variance: 0.60357 % // Average variance: 6.30167 % // Average short username time: 1.81708 microseconds // Average long username time: 1.77562 microseconds // Average actual username time: 1.74824 microseconds // // Minimum variance: 0.28176 % // Average variance: 6.47136 % // Average short username time: 1.8317 microseconds // Average long username time: 1.8304 microseconds // Average actual username time: 1.80362 microseconds // // Minimum variance: 0.53593 % // Average variance: 6.99394 % // Average short username time: 1.82322 microseconds // Average long username time: 1.81431 microseconds // Average actual username time: 1.78002 microseconds ``` - `fn it_compares_credentials_in_constant_time()` ``` // This unit test asserts that the minimum variance is less than 10% (chosen to be robust for running the unit // test with CI), indicating that the function behaves within acceptable constant-time constraints. // // Some consecutive results running in release mode on a Core i7-12700H (with no other processes running): // // Minimum variance: 0.43478 % // Average variance: 2.08995 % // Average short username time: 34.580 microseconds // Average long username time: 34.315 microseconds // Average actual username time: 34.260 microseconds // // Minimum variance: 0.43731 % // Average variance: 1.77209 % // Average short username time: 34.560 microseconds // Average long username time: 34.755 microseconds // Average actual username time: 34.690 microseconds // // Minimum variance: 0.43988 % // Average variance: 1.61299 % // Average short username time: 34.33999 microseconds // Average long username time: 34.38500 microseconds // Average actual username time: 34.28500 microseconds // // Some consecutive results running in release mode on a Core i7-12700H (while entire CPU fully stressed): // // Minimum variance: 0.30326 % // Average variance: 2.29341 % // Average short username time: 64.87500 microseconds // Average long username time: 65.55499 microseconds // Average actual username time: 65.81000 microseconds // // Minimum variance: 1.18168 % // Average variance: 2.99206 % // Average short username time: 67.970 microseconds // Average long username time: 68.000 microseconds // Average actual username time: 68.005 microseconds // // Minimum variance: 1.01083 % // Average variance: 2.31316 % // Average short username time: 68.715 microseconds // Average long username time: 69.675 microseconds // Average actual username time: 69.715 microseconds ``` What process can a PR reviewer use to test or verify this change? --- Code walk through Run the unit tests Breaking Changes --- - [x] None - [ ] Requires data directory on base node to be deleted - [ ] Requires hard fork - [ ] Other - Please specify --- Cargo.lock | 1 + applications/minotari_app_grpc/Cargo.toml | 1 + .../src/authentication/basic_auth.rs | 457 ++++++++++++++++-- .../src/authentication/server_interceptor.rs | 2 +- 4 files changed, 432 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a37e412a6..c6563074e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3035,6 +3035,7 @@ dependencies = [ "prost 0.9.0", "prost-types 0.9.0", "rand", + "subtle", "tari_common_types", "tari_comms", "tari_core", diff --git a/applications/minotari_app_grpc/Cargo.toml b/applications/minotari_app_grpc/Cargo.toml index ceef79728a..b020837a53 100644 --- a/applications/minotari_app_grpc/Cargo.toml +++ b/applications/minotari_app_grpc/Cargo.toml @@ -26,6 +26,7 @@ rand = "0.8" thiserror = "1" tonic = "0.6.2" zeroize = "1" +subtle = { version = "2.5.0", features = ["core_hint_black_box"] } [build-dependencies] tonic-build = "0.6.2" diff --git a/applications/minotari_app_grpc/src/authentication/basic_auth.rs b/applications/minotari_app_grpc/src/authentication/basic_auth.rs index 0a2a99a24f..43cdc9fb1a 100644 --- a/applications/minotari_app_grpc/src/authentication/basic_auth.rs +++ b/applications/minotari_app_grpc/src/authentication/basic_auth.rs @@ -20,24 +20,66 @@ // WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE // USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -use std::{borrow::Cow, ops::Deref, string::FromUtf8Error}; +use std::{ + borrow::Cow, + cmp::min, + ops::{BitAnd, BitOr, Deref, Not}, + string::FromUtf8Error, +}; use argon2::{password_hash::Encoding, Argon2, PasswordHash, PasswordVerifier}; -use tari_utilities::SafePassword; +use rand::RngCore; +use subtle::{Choice, ConstantTimeEq}; +use tari_utilities::{ByteArray, SafePassword}; use tonic::metadata::{errors::InvalidMetadataValue, Ascii, MetadataValue}; use zeroize::{Zeroize, Zeroizing}; +use crate::authentication::salted_password::create_salted_hashed_password; + +const MAX_USERNAME_LEN: usize = 256; + /// Implements [RFC 2617](https://www.ietf.org/rfc/rfc2617.txt#:~:text=The%20%22basic%22%20authentication%20scheme%20is,other%20realms%20on%20that%20server.) /// Represents the username and password contained within a Authenticate header. #[derive(Debug)] pub struct BasicAuthCredentials { - pub user_name: String, - pub password: SafePassword, + /// The username bytes length + pub user_name_bytes_length: usize, + /// The username in bytes representation for constant time comparison + pub user_name_bytes: [u8; MAX_USERNAME_LEN], + /// The hashed password + pub phc_password_hash: SafePassword, + /// Random bytes to help with constant time username comparison + pub random_bytes: [u8; MAX_USERNAME_LEN], } impl BasicAuthCredentials { - pub fn new(user_name: String, password: SafePassword) -> Self { - Self { user_name, password } + /// Creates a new `Credentials` instance from a username and password (PHC string bytes). + pub fn new(user_name: String, phc_password_hash: SafePassword) -> Result { + // Validate the username is well formed + if user_name.as_bytes().len() > MAX_USERNAME_LEN { + return Err(BasicAuthError::InvalidUsername); + } + // Validate the password is a well formed byte representation of a PHC string + let bytes = phc_password_hash.reveal().to_vec(); + let _parse_result = PasswordHash::parse(&String::from_utf8(bytes)?, Encoding::B64)?; + // Random bytes are used for constant time username comparison to ensure that the compiler does not do any + // funny optimizations and to ensure that comparison for the same username for every new credentials instance + // forces a different bitwise comparison. + let mut random_bytes = [0u8; MAX_USERNAME_LEN]; + let mut rng = rand::thread_rng(); + rng.fill_bytes(&mut random_bytes); + // Prepare the username bytes for constant time comparison ahead of time + let bytes = user_name.as_bytes(); + let mut user_name_bytes = [0u8; MAX_USERNAME_LEN]; + user_name_bytes[0..bytes.len()].clone_from_slice(bytes); + user_name_bytes[bytes.len()..MAX_USERNAME_LEN].clone_from_slice(&random_bytes[bytes.len()..MAX_USERNAME_LEN]); + + Ok(Self { + user_name_bytes_length: user_name.as_bytes().len(), + user_name_bytes, + phc_password_hash, + random_bytes, + }) } /// Creates a `Credentials` instance from a base64 `String` @@ -47,7 +89,8 @@ impl BasicAuthCredentials { let as_utf8 = Zeroizing::new(String::from_utf8(decoded)?); if let Some((user_name, password)) = as_utf8.split_once(':') { - let credentials = Self::new(user_name.into(), password.to_string().into()); + let hashed_password = create_salted_hashed_password(password.as_bytes())?; + let credentials = Self::new(user_name.into(), hashed_password.to_string().into())?; return Ok(credentials); } @@ -77,19 +120,57 @@ impl BasicAuthCredentials { Ok(credentials) } - pub fn validate(&self, username: &str, password: &[u8]) -> Result<(), BasicAuthError> { - if self.user_name.as_bytes() != username.as_bytes() { - return Err(BasicAuthError::InvalidUsername); - } + // This function provides a constant time comparison of the given username with the registered username. + fn constant_time_verify_username(&self, username: &str) -> Choice { + // The username is valid if the lengths are equal and the length is not greater than the maximum allowed length; + // any error here will only be factored in after the bitwise comparison has been done to force constant time. + let bytes = username.as_bytes(); + let valid_username = (Choice::from(u8::from(self.user_name_bytes_length != bytes.len())) + .bitor(Choice::from(u8::from(bytes.len() > MAX_USERNAME_LEN)))) + .not(); + + // We start with an empty default buffer + let mut compare_bytes = [0u8; MAX_USERNAME_LEN]; + + // Add the username bytes to the buffer + let bytes_len_clipped = min(bytes.len(), MAX_USERNAME_LEN); + compare_bytes[0..bytes_len_clipped].clone_from_slice(&bytes[..bytes_len_clipped]); + + // The remaining bytes are padded afterwards (and not initialized at the start) to ensure that this function + // always does the same amount of work irrespective of the username length. + compare_bytes[bytes.len()..MAX_USERNAME_LEN] + .clone_from_slice(&self.random_bytes[bytes.len()..MAX_USERNAME_LEN]); + + // Perform the bitwise comparison and combine the result with the valid username result. + // The use of `Choice` logic here is by design to hide the boolean logic from compiler optimizations. + self.user_name_bytes.ct_eq(&compare_bytes).bitand(valid_username) + } + + /// Validates the given username and password against the registered username and password. The function will always + /// do the same amount of work irrespective if the username or password is correct or not. This is to prevent timing + /// attacks. Also, no distinction is made between a non-existent username or an incorrect password in the error + /// that is returned. + pub fn constant_time_validate(&self, username: &str, password: &[u8]) -> Result<(), BasicAuthError> { + let valid_username = self.constant_time_verify_username(username); + // These bytes can leak if the password is not utf-8, but since argon encoding is utf-8 the given // password must be incorrect if conversion to utf-8 fails. - let bytes = self.password.reveal().to_vec(); + let bytes = self.phc_password_hash.reveal().to_vec(); let str_password = Zeroizing::new(String::from_utf8(bytes)?); let header_password = PasswordHash::parse(&str_password, Encoding::B64)?; - Argon2::default().verify_password(password, &header_password)?; - Ok(()) + let valid_password = Choice::from(u8::from( + Argon2::default().verify_password(password, &header_password).is_ok(), + )); + + // The use of `Choice` logic here is by design to hide the boolean logic from compiler optimizations. + if valid_username.bitand(valid_password).into() { + Ok(()) + } else { + Err(BasicAuthError::InvalidUsernameOrPassword) + } } + /// Generates a `Basic` HTTP Authorization header value from the given username and password. pub fn generate_header(username: &str, password: &[u8]) -> Result, BasicAuthError> { let password_str = String::from_utf8_lossy(password); let token_str = Zeroizing::new(format!("{}:{}", username, password_str)); @@ -110,6 +191,8 @@ impl BasicAuthCredentials { pub enum BasicAuthError { #[error("Invalid username")] InvalidUsername, + #[error("Invalid username or password")] + InvalidUsernameOrPassword, #[error("The HTTP Authorization header value is invalid")] InvalidAuthorizationHeader, #[error("The HTTP Authorization header contains an invalid scheme {0} but only `Basic` is supported")] @@ -126,6 +209,8 @@ pub enum BasicAuthError { #[cfg(test)] mod tests { + use std::time::Instant; + use super::*; mod from_header { @@ -134,8 +219,14 @@ mod tests { #[test] fn it_decodes_from_well_formed_header() { let credentials = BasicAuthCredentials::from_header("Basic YWRtaW46c2VjcmV0").unwrap(); - assert_eq!(credentials.user_name, "admin"); - assert_eq!(credentials.password.reveal(), b"secret"); + assert_eq!(credentials.user_name_bytes_length, "admin".as_bytes().len()); + let bytes = "admin".as_bytes(); + let mut user_name_bytes = [0u8; MAX_USERNAME_LEN]; + user_name_bytes[0..bytes.len()].clone_from_slice(bytes); + user_name_bytes[bytes.len()..MAX_USERNAME_LEN] + .clone_from_slice(&credentials.random_bytes[bytes.len()..MAX_USERNAME_LEN]); + assert_eq!(credentials.user_name_bytes, user_name_bytes); + assert!(credentials.constant_time_validate("admin", b"secret").is_ok()); } #[test] @@ -156,26 +247,330 @@ mod tests { } mod validate { + use std::{ + cmp::{max, min}, + thread::sleep, + }; + + use rand::RngCore; + use tari_utilities::{hex::Hex, ByteArray}; + use super::*; use crate::authentication::salted_password::create_salted_hashed_password; #[test] - fn it_validates_for_matching_credentials() { - let hashed = create_salted_hashed_password(b"secret").unwrap(); - let credentials = BasicAuthCredentials::new("admin".to_string(), hashed.to_string().into()); - credentials.validate("admin", b"secret").unwrap(); + fn it_validates_for_matching_salted_credentials() { + let hashed_password = create_salted_hashed_password(b"secret").unwrap(); + // Typical username + let credentials = + BasicAuthCredentials::new("admin".to_string(), hashed_password.to_string().into()).unwrap(); + credentials.constant_time_validate("admin", b"secret").unwrap(); + // Empty username is also fine + let credentials = BasicAuthCredentials::new("".to_string(), hashed_password.to_string().into()).unwrap(); + credentials.constant_time_validate("", b"secret").unwrap(); + } + + #[test] + fn it_rejects_registering_unsalted_password_credentials() { + let err = BasicAuthCredentials::new("admin".to_string(), "secret".to_string().into()).unwrap_err(); + assert!(matches!( + err, + BasicAuthError::InvalidPassword(argon2::password_hash::Error::PhcStringInvalid) + )); + } + + #[test] + fn it_rejects_validating_mismatching_credentials() { + let hashed_password = create_salted_hashed_password(b"secret").unwrap(); + let credentials = + BasicAuthCredentials::new("admin".to_string(), hashed_password.to_string().into()).unwrap(); + + // Wrong password + let err = credentials.constant_time_validate("admin", b"bruteforce").unwrap_err(); + assert!(matches!(err, BasicAuthError::InvalidUsernameOrPassword)); + + // Wrong username + let err = credentials.constant_time_validate("wrong_user", b"secret").unwrap_err(); + assert!(matches!(err, BasicAuthError::InvalidUsernameOrPassword)); + + // Wrong username and password + let err = credentials + .constant_time_validate("wrong_user", b"bruteforce") + .unwrap_err(); + assert!(matches!(err, BasicAuthError::InvalidUsernameOrPassword)); } #[test] - fn it_rejects_for_mismatching_credentials() { - let credentials = BasicAuthCredentials::new("admin".to_string(), "bruteforce".to_string().into()); - let err = credentials.validate("admin", b"secret").unwrap_err(); - assert!(matches!(err, BasicAuthError::InvalidPassword(_))); + fn it_rejects_registering_over_sized_username_credentials() { + let hashed_password = create_salted_hashed_password(b"secret").unwrap(); + + // Maximum length username is ok + let username = [0u8; MAX_USERNAME_LEN / 2].to_hex(); + assert!(BasicAuthCredentials::new(username, hashed_password.to_string().into()).is_ok()); + + // Empty length username is ok + let username = [].to_hex(); + assert!(BasicAuthCredentials::new(username, hashed_password.to_string().into()).is_ok()); - let credentials = BasicAuthCredentials::new("bruteforce".to_string(), "secret".to_string().into()); - let err = credentials.validate("admin", b"secret").unwrap_err(); + // Do not accept username that is too long + let username = [0u8; MAX_USERNAME_LEN / 2 + 1].to_hex(); + let err = BasicAuthCredentials::new(username, hashed_password.to_string().into()).unwrap_err(); assert!(matches!(err, BasicAuthError::InvalidUsername)); } + + // This unit test asserts that the minimum variance is less than 10% (chosen to be robust for running the unit + // test with CI), indicating that the function behaves within acceptable constant-time constraints. + // + // Some consecutive results running in release mode on a Core i7-12700H (with no other processes running): + // + // Minimum variance: 0.12574 % + // Average variance: 5.51684 % + // Average short username time: 1.2922 microseconds + // Average long username time: 1.27837 microseconds + // Average actual username time: 1.28199 microseconds + // + // Minimum variance: 0.06754 % + // Average variance: 3.64757 % + // Average short username time: 1.27054 microseconds + // Average long username time: 1.26604 microseconds + // Average actual username time: 1.2615 microseconds + // + // Minimum variance: 0.13508 % + // Average variance: 5.97782 % + // Average short username time: 1.26488 microseconds + // Average long username time: 1.27111 microseconds + // Average actual username time: 1.26225 microseconds + // + // Some consecutive results running in release mode on a Core i7-12700H (while entire CPU fully stressed): + // + // Minimum variance: 0.7276 % + // Average variance: 7.50704 % + // Average short username time: 1.7147 microseconds + // Average long username time: 1.6953 microseconds + // Average actual username time: 1.6494 microseconds + // + // Minimum variance: 0.41439 % + // Average variance: 7.17822 % + // Average short username time: 1.80315 microseconds + // Average long username time: 1.75904 microseconds + // Average actual username time: 1.71591 microseconds + // + // Minimum variance: 0.44736 % + // Average variance: 5.48951 % + // Average short username time: 1.81177 microseconds + // Average long username time: 1.78756 microseconds + // Average actual username time: 1.73798 microseconds + // + #[test] + fn it_compares_user_names_in_constant_time() { + #[allow(clippy::cast_possible_truncation)] + fn round_to_6_decimals(num: f64) -> f64 { + ((num * 100000.0) as u128) as f64 / 100000.0 + } + + const ITERATIONS: usize = 100; + let mut variances = Vec::with_capacity(ITERATIONS); + let mut short = Vec::with_capacity(ITERATIONS); + let mut long = Vec::with_capacity(ITERATIONS); + let mut actual = Vec::with_capacity(ITERATIONS); + const COUNTS: usize = 2500; + let username_actual = "admin"; + let hashed_password = create_salted_hashed_password(b"secret").unwrap(); + for i in 1..=ITERATIONS { + let credentials = + BasicAuthCredentials::new(username_actual.to_string(), hashed_password.to_string().into()).unwrap(); + assert!(bool::from(credentials.constant_time_verify_username(username_actual))); + assert!(!bool::from(credentials.constant_time_verify_username(""))); + + let mut short_usernames = Vec::with_capacity(COUNTS); + let mut long_usernames = Vec::with_capacity(COUNTS); + for _ in 0..COUNTS { + let mut bytes_long = [0u8; MAX_USERNAME_LEN / 2]; + let mut rng = rand::thread_rng(); + rng.fill_bytes(&mut bytes_long); + let username = bytes_long.to_vec().to_hex(); + long_usernames.push(username); + let mut bytes_short = [0u8; 12]; + bytes_short.copy_from_slice(&bytes_long[..12]); + let username = bytes_short.to_vec().to_hex(); + short_usernames.push(username); + } + + let start = Instant::now(); + for short in &short_usernames { + let res = credentials.constant_time_verify_username(short); + assert!(!bool::from(res)); + } + let time_taken_1 = start.elapsed().as_micros(); + + let start = Instant::now(); + for long in &long_usernames { + let res = credentials.constant_time_verify_username(long); + assert!(!bool::from(res)); + } + let time_taken_2 = start.elapsed().as_micros(); + + let start = Instant::now(); + for _ in 0..COUNTS { + let res = credentials.constant_time_verify_username(username_actual); + assert!(bool::from(res)); + } + let time_taken_3 = start.elapsed().as_micros(); + + let max_time = max(time_taken_1, max(time_taken_2, time_taken_3)); + let min_time = min(time_taken_1, min(time_taken_2, time_taken_3)); + let variance = round_to_6_decimals((max_time - min_time) as f64 / min_time as f64 * 100.0); + variances.push(variance); + short.push(time_taken_1); + long.push(time_taken_2); + actual.push(time_taken_3); + + // The use of sleep between iterations helps ensure that the tests are run under different conditions, + // simulating real-world scenarios. + if i < ITERATIONS { + sleep(std::time::Duration::from_millis(100)); + } + } + + let min_variance = variances.iter().min_by(|x, y| x.partial_cmp(y).unwrap()).unwrap(); + let avg_variance = round_to_6_decimals(variances.iter().sum::() / variances.len() as f64); + let avg_short = round_to_6_decimals(short.iter().sum::() as f64 / short.len() as f64 / COUNTS as f64); + let avg_long = round_to_6_decimals(long.iter().sum::() as f64 / long.len() as f64 / COUNTS as f64); + let avg_actual = + round_to_6_decimals(actual.iter().sum::() as f64 / actual.len() as f64 / COUNTS as f64); + println!("Minimum variance: {} %", min_variance); + println!("Average variance: {} %", avg_variance); + println!("Average short username time: {} microseconds", avg_short); + println!("Average long username time: {} microseconds", avg_long); + println!("Average actual username time: {} microseconds", avg_actual); + assert!(*min_variance < 10.0); + } + + // This unit test asserts that the minimum variance is less than 10% (chosen to be robust for running the unit + // test with CI), indicating that the function behaves within acceptable constant-time constraints. + // + // Some consecutive results running in release mode on a Core i7-12700H (with no other processes running): + // + // Minimum variance: 0.43731 % + // Average variance: 2.66751 % + // Average short username time: 35.04999 microseconds + // Average long username time: 34.95 microseconds + // Average actual username time: 34.9 microseconds + // + // Minimum variance: 1.1713 % + // Average variance: 2.82044 % + // Average short username time: 34.605 microseconds + // Average long username time: 34.69 microseconds + // Average actual username time: 34.67499 microseconds + // + // Minimum variance: 0.9929 % + // Average variance: 2.35816 % + // Average short username time: 35.285 microseconds + // Average long username time: 35.285 microseconds + // Average actual username time: 34.94 microseconds + // + // Some consecutive results running in release mode on a Core i7-12700H (while entire CPU fully stressed): + // + // Minimum variance: 0.43668 % + // Average variance: 1.61542 % + // Average short username time: 68.45 microseconds + // Average long username time: 68.245 microseconds + // Average actual username time: 68.81 microseconds + // + // Minimum variance: 0.86268 % + // Average variance: 1.58273 % + // Average short username time: 69.925 microseconds + // Average long username time: 70.34999 microseconds + // Average actual username time: 69.965 microseconds + // + // Minimum variance: 0.4961 % + // Average variance: 1.61912 % + // Average short username time: 69.85499 microseconds + // Average long username time: 70.08 microseconds + // Average actual username time: 70.645 microseconds + // + #[test] + fn it_compares_credentials_in_constant_time() { + #[allow(clippy::cast_possible_truncation)] + fn round_to_6_decimals(num: f64) -> f64 { + ((num * 100000.0) as u128) as f64 / 100000.0 + } + + const ITERATIONS: usize = 10; + let mut variances = Vec::with_capacity(ITERATIONS); + let mut short = Vec::with_capacity(ITERATIONS); + let mut long = Vec::with_capacity(ITERATIONS); + let mut actual = Vec::with_capacity(ITERATIONS); + const COUNTS: usize = 20; + let username_actual = "admin"; + let hashed_password = create_salted_hashed_password(b"secret").unwrap(); + for i in 1..=ITERATIONS { + let credentials = + BasicAuthCredentials::new(username_actual.to_string(), hashed_password.to_string().into()).unwrap(); + + let mut short_usernames = Vec::with_capacity(COUNTS); + let mut long_usernames = Vec::with_capacity(COUNTS); + for _ in 0..COUNTS { + let mut bytes_long = [0u8; MAX_USERNAME_LEN / 2]; + let mut rng = rand::thread_rng(); + rng.fill_bytes(&mut bytes_long); + let username = bytes_long.to_vec().to_hex(); + long_usernames.push(username); + let mut bytes_short = [0u8; 12]; + bytes_short.copy_from_slice(&bytes_long[..12]); + let username = bytes_short.to_vec().to_hex(); + short_usernames.push(username); + } + + let start = Instant::now(); + for short in &short_usernames { + let res = credentials.constant_time_validate(short, b"bruteforce"); + assert!(res.is_err()); + } + let time_taken_1 = start.elapsed().as_millis(); + + let start = Instant::now(); + for long in &long_usernames { + let res = credentials.constant_time_validate(long, b"bruteforce"); + assert!(res.is_err()); + } + let time_taken_2 = start.elapsed().as_millis(); + + let start = Instant::now(); + for _ in 0..COUNTS { + let res = credentials.constant_time_validate(username_actual, b"secret"); + assert!(res.is_ok()); + } + let time_taken_3 = start.elapsed().as_millis(); + + let max_time = max(time_taken_1, max(time_taken_2, time_taken_3)); + let min_time = min(time_taken_1, min(time_taken_2, time_taken_3)); + let variance = round_to_6_decimals((max_time - min_time) as f64 / min_time as f64 * 100.0); + variances.push(variance); + short.push(time_taken_1); + long.push(time_taken_2); + actual.push(time_taken_3); + + // The use of sleep between iterations helps ensure that the tests are run under different conditions, + // simulating real-world scenarios. + if i < ITERATIONS { + sleep(std::time::Duration::from_millis(100)); + } + } + + let min_variance = variances.iter().min_by(|x, y| x.partial_cmp(y).unwrap()).unwrap(); + let avg_variance = round_to_6_decimals(variances.iter().sum::() / variances.len() as f64); + let avg_short = round_to_6_decimals(short.iter().sum::() as f64 / short.len() as f64 / COUNTS as f64); + let avg_long = round_to_6_decimals(long.iter().sum::() as f64 / long.len() as f64 / COUNTS as f64); + let avg_actual = + round_to_6_decimals(actual.iter().sum::() as f64 / actual.len() as f64 / COUNTS as f64); + println!("Minimum variance: {} %", min_variance); + println!("Average variance: {} %", avg_variance); + println!("Average short username time: {} microseconds", avg_short); + println!("Average long username time: {} microseconds", avg_long); + println!("Average actual username time: {} microseconds", avg_actual); + assert!(*min_variance < 10.0); + } } mod generate_header { @@ -185,8 +580,14 @@ mod tests { fn it_generates_a_valid_header() { let header = BasicAuthCredentials::generate_header("admin", b"secret").unwrap(); let cred = BasicAuthCredentials::from_header(header.to_str().unwrap()).unwrap(); - assert_eq!(cred.user_name, "admin"); - assert_eq!(cred.password.reveal(), &b"secret"[..]); + assert_eq!(cred.user_name_bytes_length, "admin".as_bytes().len()); + let bytes = "admin".as_bytes(); + let mut user_name_bytes = [0u8; MAX_USERNAME_LEN]; + user_name_bytes[0..bytes.len()].clone_from_slice(bytes); + user_name_bytes[bytes.len()..MAX_USERNAME_LEN] + .clone_from_slice(&cred.random_bytes[bytes.len()..MAX_USERNAME_LEN]); + assert_eq!(cred.user_name_bytes, user_name_bytes); + assert!(cred.constant_time_validate("admin", b"secret").is_ok()); } } } diff --git a/applications/minotari_app_grpc/src/authentication/server_interceptor.rs b/applications/minotari_app_grpc/src/authentication/server_interceptor.rs index 9e653f6869..44466ee8de 100644 --- a/applications/minotari_app_grpc/src/authentication/server_interceptor.rs +++ b/applications/minotari_app_grpc/src/authentication/server_interceptor.rs @@ -49,7 +49,7 @@ impl ServerAuthenticationInterceptor { let val = t.to_str().map_err(unauthenticated)?; let credentials = BasicAuthCredentials::from_header(val).map_err(unauthenticated)?; credentials - .validate(valid_username, valid_password) + .constant_time_validate(valid_username, valid_password) .map_err(unauthenticated)?; Ok(req) },