Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6177f5e
Use constant-time comparison for token and credential verification
prk-Jr Mar 16, 2026
0d5376e
Address PR review feedback on constant-time comparison hardening
prk-Jr Mar 17, 2026
2523a92
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 17, 2026
ff4b49d
Merge branch 'main' into hardening/constant-time-comparisons
aram356 Mar 18, 2026
15c346e
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 19, 2026
ae85c9d
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 20, 2026
7f1cba5
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 21, 2026
2a4d19c
Resolve pr review findings
prk-Jr Mar 21, 2026
7bcd6ba
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 21, 2026
98d9991
Merge main into hardening/constant-time-comparisons
prk-Jr Mar 23, 2026
53846f8
Rename Unauthorized error variant to Forbidden to match 403 status code
prk-Jr Mar 25, 2026
a86b78a
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 25, 2026
364de4f
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 26, 2026
ebc803b
Restrict ct_str_eq to pub(crate) and convert doctest to unit test
prk-Jr Mar 30, 2026
5bd27f3
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 30, 2026
98a9151
fix lint format issue
prk-Jr Mar 30, 2026
07382b6
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Mar 31, 2026
6967a55
Merge branch 'main' into hardening/constant-time-comparisons
prk-Jr Apr 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 38 additions & 27 deletions crates/trusted-server-core/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,23 @@ use crate::settings::Settings;

const BASIC_AUTH_REALM: &str = r#"Basic realm="Trusted Server""#;

/// Enforces Basic-auth for incoming requests.
/// Enforces HTTP Basic authentication for configured handler paths.
///
/// Authentication is required when a configured handler's `path` regex matches
/// the request path. Paths not covered by any handler pass through without
/// authentication.
///
/// Admin endpoints are protected by requiring a handler at build time — see
/// [`Settings::from_toml_and_env`].
/// Admin endpoints are protected by requiring a handler at build time; see
/// [`Settings::from_toml_and_env`]. Credential checks use constant-time
/// comparison for both username and password, and evaluate both regardless of
/// individual match results to avoid timing oracles.
///
/// # Returns
///
/// * `Some(Response)` — a `401 Unauthorized` response that should be sent back
/// to the client (credentials missing or incorrect).
/// * `None` — the request is allowed to proceed.
#[must_use]
pub fn enforce_basic_auth(settings: &Settings, req: &Request) -> Option<Response> {
let path = req.get_path();

Expand All @@ -47,6 +50,7 @@ pub fn enforce_basic_auth(settings: &Settings, req: &Request) -> Option<Response
if bool::from(username_match & password_match) {
None
} else {
log::warn!("Basic auth failed for path: {}", req.get_path());
Some(unauthorized_response())
}
}
Expand Down Expand Up @@ -135,6 +139,37 @@ mod tests {
assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED);
}

#[test]
fn challenge_when_username_wrong_password_correct() {
// Validates that both fields are always evaluated — no short-circuit username oracle.
let settings = create_test_settings();
let mut req = Request::new(Method::GET, "https://example.com/secure/data");
let token = STANDARD.encode("wrong-user:pass");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req).expect("should challenge");
assert_eq!(
response.get_status(),
StatusCode::UNAUTHORIZED,
"should reject wrong username even with correct password"
);
}

#[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-pass");
req.set_header(header::AUTHORIZATION, format!("Basic {token}"));

let response = enforce_basic_auth(&settings, &req).expect("should challenge");
assert_eq!(
response.get_status(),
StatusCode::UNAUTHORIZED,
"should reject correct username with wrong password"
);
}

#[test]
fn challenge_when_scheme_is_not_basic() {
let settings = create_test_settings();
Expand Down Expand Up @@ -179,28 +214,4 @@ mod tests {
.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);
}
}
5 changes: 5 additions & 0 deletions crates/trusted-server-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ pub enum TrustedServerError {
#[display("Proxy error: {message}")]
Proxy { message: String },

/// Authorization failure — request understood but not permitted.
Comment thread
prk-Jr marked this conversation as resolved.
Outdated
#[display("Unauthorized: {message}")]
Unauthorized { message: String },

/// Settings parsing or validation failed.
#[display("Settings error: {message}")]
Settings { message: String },
Expand Down Expand Up @@ -106,6 +110,7 @@ impl IntoHttpResponse for TrustedServerError {
Self::Prebid { .. } => StatusCode::BAD_GATEWAY,
Self::Integration { .. } => StatusCode::BAD_GATEWAY,
Self::Proxy { .. } => StatusCode::BAD_GATEWAY,
Self::Unauthorized { .. } => StatusCode::FORBIDDEN,
Self::SyntheticId { .. } => StatusCode::INTERNAL_SERVER_ERROR,
Self::Template { .. } => StatusCode::INTERNAL_SERVER_ERROR,
}
Expand Down
55 changes: 54 additions & 1 deletion crates/trusted-server-core/src/http_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use chacha20poly1305::{aead::Aead, aead::KeyInit, XChaCha20Poly1305, XNonce};
use fastly::http::{header, StatusCode};
use fastly::{Request, Response};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq as _;

use crate::constants::INTERNAL_HEADERS;
use crate::settings::Settings;
Expand Down Expand Up @@ -318,10 +319,35 @@ pub fn sign_clear_url(settings: &Settings, clear_url: &str) -> String {
URL_SAFE_NO_PAD.encode(digest)
}
Comment thread
prk-Jr marked this conversation as resolved.

/// Constant-time string comparison.
///
/// The explicit length check documents the invariant that both values have known,
/// non-secret lengths. Both checks always run — the short-circuit `&&` is safe
/// here because token lengths are public information, not secrets.
///
/// # Examples
///
/// ```
/// use trusted_server_core::http_util::ct_str_eq;
///
/// assert!(ct_str_eq("hello", "hello"));
/// assert!(!ct_str_eq("hello", "world"));
/// assert!(!ct_str_eq("hello", "hell"));
/// ```
#[must_use]
pub fn ct_str_eq(a: &str, b: &str) -> bool {
Comment thread
prk-Jr marked this conversation as resolved.
Outdated
a.len() == b.len() && bool::from(a.as_bytes().ct_eq(b.as_bytes()))
Comment thread
prk-Jr marked this conversation as resolved.
}

/// Verify a `tstoken` for the given clear-text URL.
///
/// Uses constant-time comparison to prevent timing side-channel attacks.
/// Length is not secret (always 43 bytes for base64url-encoded SHA-256),
/// but we check explicitly to document the invariant.
#[must_use]
Comment thread
prk-Jr marked this conversation as resolved.
pub fn verify_clear_url_signature(settings: &Settings, clear_url: &str, token: &str) -> bool {
sign_clear_url(settings, clear_url) == token
let expected = sign_clear_url(settings, clear_url);
ct_str_eq(&expected, token)
}

/// Compute tstoken for the new proxy scheme: SHA-256 of the encrypted full URL (including query).
Expand Down Expand Up @@ -388,6 +414,33 @@ mod tests {
));
}

#[test]
fn verify_clear_url_rejects_tampered_token() {
let settings = crate::test_support::tests::create_test_settings();
let url = "https://cdn.example/a.png?x=1";
let valid_token = sign_clear_url(&settings, url);

// Flip one bit in the first byte — same URL, same length, wrong bytes
let mut tampered = valid_token.into_bytes();
tampered[0] ^= 0x01;
let tampered =
String::from_utf8(tampered).expect("should be valid utf8 after single-bit flip");

assert!(
!verify_clear_url_signature(&settings, url, &tampered),
"should reject token with tampered bytes"
);
}

#[test]
fn verify_clear_url_rejects_empty_token() {
let settings = crate::test_support::tests::create_test_settings();
assert!(
!verify_clear_url_signature(&settings, "https://cdn.example/a.png", ""),
"should reject empty token"
);
}

// RequestInfo tests

#[test]
Expand Down
32 changes: 29 additions & 3 deletions crates/trusted-server-core/src/proxy.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::http_util::compute_encrypted_sha256_token;
use crate::http_util::{compute_encrypted_sha256_token, ct_str_eq};
use error_stack::{Report, ResultExt};
use fastly::http::{header, HeaderValue, Method, StatusCode};
use fastly::{Request, Response};
Expand Down Expand Up @@ -1074,8 +1074,11 @@ fn reconstruct_and_validate_signed_target(
};

let expected = compute_encrypted_sha256_token(settings, &full_for_token);
if expected != sig {
return Err(Report::new(TrustedServerError::Proxy {
// Constant-time comparison to prevent timing side-channel attacks on the token.
// Length is not secret (always 43 bytes for base64url-encoded SHA-256),
// but we check explicitly to document the invariant.
if !ct_str_eq(&expected, &sig) {
Comment thread
prk-Jr marked this conversation as resolved.
Comment thread
prk-Jr marked this conversation as resolved.
return Err(Report::new(TrustedServerError::Unauthorized {
message: "invalid tstoken".to_string(),
}));
}
Expand Down Expand Up @@ -1275,6 +1278,29 @@ mod tests {
assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY);
}

#[tokio::test]
async fn reconstruct_rejects_tampered_tstoken() {
let settings = create_test_settings();
let tsurl = "https://cdn.example/asset.js";
let tsurl_encoded =
url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::<String>();
// Syntactically valid base64url token of the right length, but not the correct signature
let bad_token = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let url = format!(
"https://edge.example/first-party/proxy?tsurl={}&tstoken={}",
tsurl_encoded, bad_token
);

let err: Report<TrustedServerError> =
reconstruct_and_validate_signed_target(&settings, &url)
.expect_err("should reject tampered token");
assert_eq!(
err.current_context().status_code(),
StatusCode::FORBIDDEN,
"should return 403 for invalid tstoken"
);
}

#[tokio::test]
async fn click_missing_params_returns_400() {
let settings = create_test_settings();
Expand Down
34 changes: 0 additions & 34 deletions crates/trusted-server-core/src/redacted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,6 @@ impl<T> fmt::Display for Redacted<T> {
}
}

impl<T: PartialEq> PartialEq for Redacted<T> {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}

impl<T: PartialEq> PartialEq<T> for Redacted<T> {
fn eq(&self, other: &T) -> bool {
self.0 == *other
}
}

impl From<String> for Redacted<String> {
fn from(value: String) -> Self {
Self(value)
Expand Down Expand Up @@ -152,28 +140,6 @@ mod tests {
);
}

#[test]
fn partial_eq_with_inner_type() {
let secret = Redacted::new("my-secret".to_string());
assert!(
secret == "my-secret".to_string(),
"should equal the inner value"
);
assert!(
secret != "other".to_string(),
"should not equal a different value"
);
}

#[test]
fn partial_eq_between_redacted() {
let a = Redacted::new("same".to_string());
let b = Redacted::new("same".to_string());
let c = Redacted::new("different".to_string());
assert!(a == b, "should equal when inner values match");
assert!(a != c, "should not equal when inner values differ");
}

#[test]
fn struct_with_redacted_field_debug() {
#[derive(Debug)]
Expand Down
Loading