diff --git a/Cargo.lock b/Cargo.lock index 51837298..de9dd4ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2827,6 +2827,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "subtle", "temp-env", "tokio", "tokio-test", diff --git a/Cargo.toml b/Cargo.toml index 424c357d..00655e1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,7 @@ regex = "1.12.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.149" sha2 = "0.10.9" +subtle = "2.6" temp-env = "0.3.6" tokio = { version = "1.49", features = ["sync", "macros", "io-util", "rt", "time"] } tokio-test = "0.4" diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 76b1106d..6a321fd2 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -41,6 +41,7 @@ regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } +subtle = { workspace = true } tokio = { workspace = true } toml = { workspace = true } trusted-server-js = { path = "../js" } diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index 816891f6..e23fd06d 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -1,6 +1,8 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use fastly::http::{header, StatusCode}; use fastly::{Request, Response}; +use sha2::{Digest as _, Sha256}; +use subtle::ConstantTimeEq as _; use crate::settings::Settings; @@ -30,7 +32,19 @@ pub fn enforce_basic_auth(settings: &Settings, req: &Request) -> Option return Some(unauthorized_response()), }; - if handler.username == username && handler.password == password { + // Hash before comparing to normalise lengths — `ct_eq` on raw byte slices + // short-circuits when lengths differ, which would leak credential length. + // SHA-256 produces fixed-size digests so the comparison is truly constant-time. + // + // Note: constant-time guarantees are best-effort on WASM targets because the + // runtime optimiser/JIT may re-introduce variable-time paths. This is an + // inherent limitation of all constant-time code in managed runtimes. + let username_match = Sha256::digest(handler.username.expose().as_bytes()) + .ct_eq(&Sha256::digest(username.as_bytes())); + let password_match = Sha256::digest(handler.password.expose().as_bytes()) + .ct_eq(&Sha256::digest(password.as_bytes())); + + if bool::from(username_match & password_match) { None } else { Some(unauthorized_response()) @@ -76,16 +90,11 @@ mod tests { use base64::engine::general_purpose::STANDARD; use fastly::http::{header, Method}; - use crate::test_support::tests::crate_test_settings_str; - - fn settings_with_handlers() -> Settings { - let config = crate_test_settings_str(); - Settings::from_toml(&config).expect("should parse settings with handlers") - } + use crate::test_support::tests::create_test_settings; #[test] fn no_challenge_for_non_protected_path() { - let settings = settings_with_handlers(); + let settings = create_test_settings(); let req = Request::new(Method::GET, "https://example.com/open"); assert!(enforce_basic_auth(&settings, &req).is_none()); @@ -93,7 +102,7 @@ mod tests { #[test] fn challenge_when_missing_credentials() { - let settings = settings_with_handlers(); + let settings = create_test_settings(); let req = Request::new(Method::GET, "https://example.com/secure"); let response = enforce_basic_auth(&settings, &req).expect("should challenge"); @@ -106,7 +115,7 @@ mod tests { #[test] fn allow_when_credentials_match() { - let settings = settings_with_handlers(); + let settings = create_test_settings(); let mut req = Request::new(Method::GET, "https://example.com/secure/data"); let token = STANDARD.encode("user:pass"); req.set_header(header::AUTHORIZATION, format!("Basic {token}")); @@ -115,19 +124,20 @@ mod tests { } #[test] - fn challenge_when_credentials_mismatch() { - let settings = settings_with_handlers(); + fn challenge_when_both_credentials_wrong() { + let settings = create_test_settings(); let mut req = Request::new(Method::GET, "https://example.com/secure/data"); - let token = STANDARD.encode("user:wrong"); + let token = STANDARD.encode("wrong:wrong"); req.set_header(header::AUTHORIZATION, format!("Basic {token}")); - let response = enforce_basic_auth(&settings, &req).expect("should challenge"); + let response = enforce_basic_auth(&settings, &req) + .expect("should challenge when both username and password are wrong"); assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); } #[test] fn challenge_when_scheme_is_not_basic() { - let settings = settings_with_handlers(); + let settings = create_test_settings(); let mut req = Request::new(Method::GET, "https://example.com/secure"); req.set_header(header::AUTHORIZATION, "Bearer token"); @@ -137,7 +147,7 @@ mod tests { #[test] fn allow_admin_path_with_valid_credentials() { - let settings = settings_with_handlers(); + let settings = create_test_settings(); let mut req = Request::new(Method::POST, "https://example.com/admin/keys/rotate"); let token = STANDARD.encode("admin:admin-pass"); req.set_header(header::AUTHORIZATION, format!("Basic {token}")); @@ -150,7 +160,7 @@ mod tests { #[test] fn challenge_admin_path_with_wrong_credentials() { - let settings = settings_with_handlers(); + let settings = create_test_settings(); let mut req = Request::new(Method::POST, "https://example.com/admin/keys/rotate"); let token = STANDARD.encode("admin:wrong"); req.set_header(header::AUTHORIZATION, format!("Basic {token}")); @@ -162,11 +172,35 @@ mod tests { #[test] fn challenge_admin_path_with_missing_credentials() { - let settings = settings_with_handlers(); + let settings = create_test_settings(); let req = Request::new(Method::POST, "https://example.com/admin/keys/rotate"); let response = enforce_basic_auth(&settings, &req) .expect("should challenge admin path with missing credentials"); assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); } + + #[test] + fn challenge_when_username_wrong_password_correct() { + let settings = create_test_settings(); + let mut req = Request::new(Method::GET, "https://example.com/secure/data"); + let token = STANDARD.encode("wrong:pass"); + req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + + let response = enforce_basic_auth(&settings, &req) + .expect("should challenge when only username is wrong"); + assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn challenge_when_username_correct_password_wrong() { + let settings = create_test_settings(); + let mut req = Request::new(Method::GET, "https://example.com/secure/data"); + let token = STANDARD.encode("user:wrong"); + req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + + let response = enforce_basic_auth(&settings, &req) + .expect("should challenge when only password is wrong"); + assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + } }