From cd0ad414b41b764c4f2c255402ac152fca9d5664 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:53:02 +0000 Subject: [PATCH 1/3] test(auth): move the scope and check order tests to scope_tests.rs crates/auth/src/tests.rs held 613 lines. Lines 293 to 613 move to a new file, crates/auth/src/scope_tests.rs, which lib.rs declares beside mod tests. They hold nine tests of the request timestamp and credential scope checks and of the order of the checks. The helpers that only these tests use move with them: provider_without_credentials, make_date_stale, SCOPE_DATE_MESSAGE, yesterday, signed_with_scope and refusal. The helpers that both files use stay in tests.rs and become pub(super): MockCredentialStore and its fields, make_headers_with_auth, SECRET, signed_headers and provider. rustfmt wraps the signature of signed_headers, which is now longer than 100 columns. No test body, assertion or name changes. The moved lines are byte-identical to lines 293 to 613 of the old tests.rs. Before and after the move, cargo test for extenddb-auth lists and passes the same 515 unit tests, nine of them now under scope_tests. tests.rs now has 296 lines and scope_tests.rs has 331. --- crates/auth/src/lib.rs | 2 + crates/auth/src/scope_tests.rs | 331 ++++++++++++++++++++++++++++++++ crates/auth/src/tests.rs | 341 ++------------------------------- 3 files changed, 345 insertions(+), 329 deletions(-) create mode 100644 crates/auth/src/scope_tests.rs diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs index db09024fc..00c836a17 100644 --- a/crates/auth/src/lib.rs +++ b/crates/auth/src/lib.rs @@ -249,5 +249,7 @@ impl AuthProvider for BuiltinAuthProvider { } } +#[cfg(test)] +mod scope_tests; #[cfg(test)] mod tests; diff --git a/crates/auth/src/scope_tests.rs b/crates/auth/src/scope_tests.rs new file mode 100644 index 000000000..18bf0abbf --- /dev/null +++ b/crates/auth/src/scope_tests.rs @@ -0,0 +1,331 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Unit tests for the request timestamp and credential scope checks of +//! `BuiltinAuthProvider::authenticate`, and for the order of its checks. + +use super::tests::{MockCredentialStore, SECRET, make_headers_with_auth, provider, signed_headers}; +use super::*; +use axum::http::{HeaderMap, HeaderValue}; + +/// Measured 2026-09-16: a scope wrong in both region and service reports +/// both sentences in one message, region first. +#[tokio::test] +async fn scope_wrong_in_region_and_service_reports_both() { + let mut headers = make_headers_with_auth("AKIAEXTENDDB00000000", None); + headers.remove("authorization"); + let date_str = headers["x-amz-date"].to_str().unwrap().to_owned(); + let date_short = date_str[..8].to_owned(); + let signed = "content-type;host;x-amz-date;x-amz-target"; + let creq = sigv4::canonical::canonical_request("POST", "/", "", &headers, signed, b"{}"); + let scope = format!("{date_short}/us-west-2/s3/aws4_request"); + let sts = sigv4::canonical::string_to_sign(&date_str, &scope, &creq); + let key = sigv4::signing_key::derive_signing_key(SECRET, &date_short, "us-west-2", "s3"); + let sig = sigv4::signing_key::compute_signature(&key, &sts); + let auth = format!( + "AWS4-HMAC-SHA256 Credential=AKIAEXTENDDB00000000/{scope}, SignedHeaders={signed}, Signature={sig}" + ); + headers.insert("authorization", HeaderValue::from_str(&auth).unwrap()); + match provider(false).authenticate(&headers, b"{}").await { + Err(DynamoDbError::InvalidSignatureException(msg)) => { + assert_eq!( + msg, + "Credential should be scoped to a valid region. Credential should be scoped to correct service: 'dynamodb'. " + ); + } + other => panic!("Expected InvalidSignatureException, got: {other:?}"), + } +} + +/// A provider whose store holds no credential at all. +fn provider_without_credentials() -> BuiltinAuthProvider { + BuiltinAuthProvider::new( + MockCredentialStore { + credential: None, + error: None, + }, + "us-east-1", + ) +} + +/// Move the request date one hour into the past, re-signing the scope +/// date in the `Authorization` header to match. +fn make_date_stale(headers: &mut HeaderMap) { + let fresh = headers["x-amz-date"].to_str().unwrap().to_owned(); + let stale = time::OffsetDateTime::now_utc() - time::Duration::hours(1); + let stale_str = format!( + "{:04}{:02}{:02}T{:02}{:02}{:02}Z", + stale.year(), + stale.month() as u8, + stale.day(), + stale.hour(), + stale.minute(), + stale.second(), + ); + let auth = headers["authorization"] + .to_str() + .unwrap() + .replacen(&fresh[..8], &stale_str[..8], 1); + headers.insert("authorization", HeaderValue::from_str(&auth).unwrap()); + headers.insert("x-amz-date", HeaderValue::from_str(&stale_str).unwrap()); +} + +/// Measured 2026-10-23 (R2-1 case F): an unknown access key scoped to +/// another region answers the scope message, so the scope check runs +/// before the key lookup. +#[tokio::test] +async fn unknown_key_with_a_wrong_region_reports_the_scope() { + let headers = signed_headers("AKIAEXAMPLEEXAMPLE01", "us-west-2", None, b"{}"); + match provider_without_credentials() + .authenticate(&headers, b"{}") + .await + { + Err(DynamoDbError::InvalidSignatureException(msg)) => { + assert_eq!(msg, "Credential should be scoped to a valid region. "); + } + other => panic!("Expected InvalidSignatureException, got: {other:?}"), + } +} + +/// Measured 2026-10-23 (R2-2 case V): an unknown access key with a date +/// one hour old answers "Signature expired", so the timestamp check runs +/// before the key lookup. The service's message continues with the dates +/// and its type is InvalidSignatureException; the server's timestamp +/// error shape predates this change and is not altered here. +#[tokio::test] +async fn unknown_key_with_a_stale_date_reports_the_timestamp() { + let mut headers = make_headers_with_auth("AKIAEXAMPLEEXAMPLE01", None); + make_date_stale(&mut headers); + match provider_without_credentials() + .authenticate(&headers, b"{}") + .await + { + Err(DynamoDbError::UnrecognizedClientException(msg)) => { + assert!(msg.starts_with("Signature expired: "), "{msg}"); + } + other => panic!("Expected the timestamp error, got: {other:?}"), + } +} + +/// Measured 2026-10-23 (R2-2 case D): a stale date scoped to another +/// region answers "Signature expired", so the timestamp check runs +/// before the scope check. +#[tokio::test] +async fn stale_date_with_a_wrong_region_reports_the_timestamp() { + let mut headers = signed_headers("AKIAEXTENDDB00000000", "us-west-2", None, b"{}"); + make_date_stale(&mut headers); + match provider(false).authenticate(&headers, b"{}").await { + Err(DynamoDbError::UnrecognizedClientException(msg)) => { + assert!(msg.starts_with("Signature expired: "), "{msg}"); + } + other => panic!("Expected the timestamp error, got: {other:?}"), + } +} + +/// The scope date the service sends back for a mismatch, with the trailing +/// period and space (measured 2026-10-23, M4-R7b-1 cases 1e and 2e). +const SCOPE_DATE_MESSAGE: &str = + "Date in Credential scope does not match YYYYMMDD from ISO-8601 version of date from HTTP. "; + +/// The previous day as `YYYYMMDD`, for a scope date that does not match a +/// fresh `X-Amz-Date`. +fn yesterday() -> String { + let d = time::OffsetDateTime::now_utc() - time::Duration::days(1); + format!("{:04}{:02}{:02}", d.year(), d.month() as u8, d.day()) +} + +/// Headers for a request whose credential scope names `region`, `service` +/// and `scope_date`, with `X-Amz-Date` at the current time. `scope_date` +/// of `None` uses the date of `X-Amz-Date`. The signature is computed over +/// the scope as stated, so the only fault is the one the caller set. +fn signed_with_scope( + access_key: &str, + region: &str, + service: &str, + scope_date: Option<&str>, +) -> HeaderMap { + let mut headers = make_headers_with_auth(access_key, None); + headers.remove("authorization"); + let date_str = headers["x-amz-date"].to_str().unwrap().to_owned(); + let scope_date = scope_date.unwrap_or(&date_str[..8]).to_owned(); + let signed = "content-type;host;x-amz-date;x-amz-target"; + let creq = sigv4::canonical::canonical_request("POST", "/", "", &headers, signed, b"{}"); + let scope = format!("{scope_date}/{region}/{service}/aws4_request"); + let sts = sigv4::canonical::string_to_sign(&date_str, &scope, &creq); + let key = sigv4::signing_key::derive_signing_key(SECRET, &scope_date, region, service); + let sig = sigv4::signing_key::compute_signature(&key, &sts); + let auth = format!( + "AWS4-HMAC-SHA256 Credential={access_key}/{scope}, SignedHeaders={signed}, Signature={sig}" + ); + headers.insert("authorization", HeaderValue::from_str(&auth).unwrap()); + headers +} + +/// The status, error type and message a client sees for a refusal: the +/// three parts of the response body and status line that a caller can read +/// without a credential. +fn refusal(result: Result) -> (u16, String, String) { + match result { + Err(e) => (e.status_code(), e.full_error_type(), e.message().to_owned()), + Ok(_) => panic!("expected a refusal, the request authenticated"), + } +} + +/// Measured 2026-10-23 (M4-R7b-1 case 1e): an unknown access key whose +/// credential scope carries the previous day's date answers the scope date +/// message, so the scope date is compared before the key lookup. +#[tokio::test] +async fn unknown_key_with_a_stale_scope_date_reports_the_scope_date() { + let headers = signed_with_scope( + "AKIAEXAMPLEEXAMPLE01", + "us-east-1", + "dynamodb", + Some(&yesterday()), + ); + match provider_without_credentials() + .authenticate(&headers, b"{}") + .await + { + Err(DynamoDbError::InvalidSignatureException(msg)) => { + assert_eq!(msg, SCOPE_DATE_MESSAGE); + } + other => panic!("Expected InvalidSignatureException, got: {other:?}"), + } +} + +/// Every fault the service answers before it looks the access key up gives +/// the same status, error type and message for a key that exists and one +/// that does not (measured 2026-10-23, M4-R7b-1: 1b against 2b for the +/// timestamp, 1c against 2c for the region, 1d against 2d for the service, +/// 1e against 2e for the scope date). The timestamp error's own type and +/// message differ from the service's and are not changed here. +#[tokio::test] +async fn pre_lookup_faults_answer_alike_for_a_known_and_an_unknown_key() { + let stale = yesterday(); + let mut cases: Vec<(&str, HeaderMap)> = vec![ + ( + "scope region us-west-2", + signed_with_scope("AKIAEXTENDDB00000000", "us-west-2", "dynamodb", None), + ), + ( + "scope service s3", + signed_with_scope("AKIAEXTENDDB00000000", "us-east-1", "s3", None), + ), + ( + "scope date the previous day", + signed_with_scope( + "AKIAEXTENDDB00000000", + "us-east-1", + "dynamodb", + Some(&stale), + ), + ), + ( + "scope date eight letters", + signed_with_scope( + "AKIAEXTENDDB00000000", + "us-east-1", + "dynamodb", + Some("ABCDEFGH"), + ), + ), + ( + "scope date empty", + signed_with_scope("AKIAEXTENDDB00000000", "us-east-1", "dynamodb", Some("")), + ), + ]; + let mut timestamp = make_headers_with_auth("AKIAEXTENDDB00000000", None); + make_date_stale(&mut timestamp); + cases.push(("X-Amz-Date one hour old", timestamp)); + + for (name, headers) in cases { + let known = refusal(provider(false).authenticate(&headers, b"{}").await); + let unknown = refusal( + provider_without_credentials() + .authenticate(&headers, b"{}") + .await, + ); + assert_eq!( + known, unknown, + "{name}: a key that exists and one that does not answered differently" + ); + } +} + +/// The scope date is compared as bytes against the `YYYYMMDD` prefix of +/// `X-Amz-Date`, so a date of another length, a date carrying letters and a +/// date naming a day that does not exist are each a mismatch rather than a +/// parse error or an accepted value. +#[tokio::test] +async fn scope_date_comparison_is_exact() { + let stale = yesterday(); + let today = time::OffsetDateTime::now_utc(); + let full = format!( + "{:04}{:02}{:02}", + today.year(), + today.month() as u8, + today.day() + ); + let seven = &full[..7]; + let nine = format!("{full}0"); + for date in [ + stale.as_str(), + seven, + &nine, + "ABCDEFGH", + "20261332", + "", + "2026102X", + ] { + let headers = + signed_with_scope("AKIAEXTENDDB00000000", "us-east-1", "dynamodb", Some(date)); + match provider(false).authenticate(&headers, b"{}").await { + Err(DynamoDbError::InvalidSignatureException(msg)) => { + assert_eq!(msg, SCOPE_DATE_MESSAGE, "scope date {date:?}"); + } + other => panic!("scope date {date:?}: expected the date message, got {other:?}"), + } + } +} + +/// A scope whose date matches is accepted, so the check does not refuse a +/// correctly scoped request. +#[tokio::test] +async fn matching_scope_date_authenticates() { + let headers = signed_with_scope("AKIAEXTENDDB00000000", "us-east-1", "dynamodb", None); + let id = provider(false).authenticate(&headers, b"{}").await.unwrap(); + assert!(matches!(id, AuthIdentity::User { .. })); +} + +/// Measured 2026-10-24 (B18-1 cases 1c, 1d, 1e, 2e, 2f): the date complaint +/// joins the region and service complaints in one message and comes last. +#[tokio::test] +async fn scope_wrong_in_more_than_one_part_reports_each_complaint() { + let stale = yesterday(); + let region_then_date = format!( + "Credential should be scoped to a valid region. {}", + SCOPE_DATE_MESSAGE + ); + let service_then_date = format!( + "Credential should be scoped to correct service: 'dynamodb'. {}", + SCOPE_DATE_MESSAGE + ); + let all_three = format!( + "Credential should be scoped to a valid region. Credential should be scoped to correct service: 'dynamodb'. {}", + SCOPE_DATE_MESSAGE + ); + let cases = [ + ("us-west-2", "dynamodb", region_then_date), + ("us-east-1", "s3", service_then_date), + ("us-west-2", "s3", all_three), + ]; + for (region, service, expected) in cases { + let headers = signed_with_scope("AKIAEXTENDDB00000000", region, service, Some(&stale)); + match provider(false).authenticate(&headers, b"{}").await { + Err(DynamoDbError::InvalidSignatureException(msg)) => { + assert_eq!(msg, expected, "scope {region}/{service} with a stale date"); + } + other => panic!("scope {region}/{service}: expected the scope message, got {other:?}"), + } + } +} diff --git a/crates/auth/src/tests.rs b/crates/auth/src/tests.rs index bec44b6f3..16775fd1d 100644 --- a/crates/auth/src/tests.rs +++ b/crates/auth/src/tests.rs @@ -7,11 +7,11 @@ use super::*; use axum::http::{HeaderMap, HeaderValue}; /// In-memory credential store for unit tests. -struct MockCredentialStore { +pub(super) struct MockCredentialStore { /// `Some(credential)` for found credentials, `None` for not found. - credential: Option, + pub(super) credential: Option, /// If set, `lookup_credential` returns this error instead. - error: Option<&'static str>, + pub(super) error: Option<&'static str>, } #[async_trait::async_trait] @@ -27,7 +27,7 @@ impl CredentialStore for MockCredentialStore { } } -fn make_headers_with_auth(access_key: &str, token: Option<&str>) -> HeaderMap { +pub(super) fn make_headers_with_auth(access_key: &str, token: Option<&str>) -> HeaderMap { let mut headers = HeaderMap::new(); // Use current time for the date to pass timestamp validation. let now = time::OffsetDateTime::now_utc(); @@ -111,7 +111,7 @@ async fn nonexistent_key_returns_unrecognized_client() { } } -const SECRET: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; +pub(super) const SECRET: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; fn credential(is_session: bool) -> StoredCredential { StoredCredential { @@ -128,7 +128,12 @@ fn credential(is_session: bool) -> StoredCredential { /// Headers for a request correctly signed by a client whose credential /// scope names `region`. `token`, when present, is sent and signed. -fn signed_headers(access_key: &str, region: &str, token: Option<&str>, body: &[u8]) -> HeaderMap { +pub(super) fn signed_headers( + access_key: &str, + region: &str, + token: Option<&str>, + body: &[u8], +) -> HeaderMap { let mut headers = make_headers_with_auth(access_key, token); headers.remove("authorization"); let date_str = headers["x-amz-date"].to_str().unwrap().to_owned(); @@ -150,7 +155,7 @@ fn signed_headers(access_key: &str, region: &str, token: Option<&str>, body: &[u headers } -fn provider(is_session: bool) -> BuiltinAuthProvider { +pub(super) fn provider(is_session: bool) -> BuiltinAuthProvider { BuiltinAuthProvider::new( MockCredentialStore { credential: Some(credential(is_session)), @@ -289,325 +294,3 @@ async fn non_ascii_session_token_header_with_a_long_term_key_is_rejected() { other => panic!("Expected UnrecognizedClientException, got: {other:?}"), } } - -/// Measured 2026-09-16: a scope wrong in both region and service reports -/// both sentences in one message, region first. -#[tokio::test] -async fn scope_wrong_in_region_and_service_reports_both() { - let mut headers = make_headers_with_auth("AKIAEXTENDDB00000000", None); - headers.remove("authorization"); - let date_str = headers["x-amz-date"].to_str().unwrap().to_owned(); - let date_short = date_str[..8].to_owned(); - let signed = "content-type;host;x-amz-date;x-amz-target"; - let creq = sigv4::canonical::canonical_request("POST", "/", "", &headers, signed, b"{}"); - let scope = format!("{date_short}/us-west-2/s3/aws4_request"); - let sts = sigv4::canonical::string_to_sign(&date_str, &scope, &creq); - let key = sigv4::signing_key::derive_signing_key(SECRET, &date_short, "us-west-2", "s3"); - let sig = sigv4::signing_key::compute_signature(&key, &sts); - let auth = format!( - "AWS4-HMAC-SHA256 Credential=AKIAEXTENDDB00000000/{scope}, SignedHeaders={signed}, Signature={sig}" - ); - headers.insert("authorization", HeaderValue::from_str(&auth).unwrap()); - match provider(false).authenticate(&headers, b"{}").await { - Err(DynamoDbError::InvalidSignatureException(msg)) => { - assert_eq!( - msg, - "Credential should be scoped to a valid region. Credential should be scoped to correct service: 'dynamodb'. " - ); - } - other => panic!("Expected InvalidSignatureException, got: {other:?}"), - } -} - -/// A provider whose store holds no credential at all. -fn provider_without_credentials() -> BuiltinAuthProvider { - BuiltinAuthProvider::new( - MockCredentialStore { - credential: None, - error: None, - }, - "us-east-1", - ) -} - -/// Move the request date one hour into the past, re-signing the scope -/// date in the `Authorization` header to match. -fn make_date_stale(headers: &mut HeaderMap) { - let fresh = headers["x-amz-date"].to_str().unwrap().to_owned(); - let stale = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - let stale_str = format!( - "{:04}{:02}{:02}T{:02}{:02}{:02}Z", - stale.year(), - stale.month() as u8, - stale.day(), - stale.hour(), - stale.minute(), - stale.second(), - ); - let auth = headers["authorization"] - .to_str() - .unwrap() - .replacen(&fresh[..8], &stale_str[..8], 1); - headers.insert("authorization", HeaderValue::from_str(&auth).unwrap()); - headers.insert("x-amz-date", HeaderValue::from_str(&stale_str).unwrap()); -} - -/// Measured 2026-10-23 (R2-1 case F): an unknown access key scoped to -/// another region answers the scope message, so the scope check runs -/// before the key lookup. -#[tokio::test] -async fn unknown_key_with_a_wrong_region_reports_the_scope() { - let headers = signed_headers("AKIAEXAMPLEEXAMPLE01", "us-west-2", None, b"{}"); - match provider_without_credentials() - .authenticate(&headers, b"{}") - .await - { - Err(DynamoDbError::InvalidSignatureException(msg)) => { - assert_eq!(msg, "Credential should be scoped to a valid region. "); - } - other => panic!("Expected InvalidSignatureException, got: {other:?}"), - } -} - -/// Measured 2026-10-23 (R2-2 case V): an unknown access key with a date -/// one hour old answers "Signature expired", so the timestamp check runs -/// before the key lookup. The service's message continues with the dates -/// and its type is InvalidSignatureException; the server's timestamp -/// error shape predates this change and is not altered here. -#[tokio::test] -async fn unknown_key_with_a_stale_date_reports_the_timestamp() { - let mut headers = make_headers_with_auth("AKIAEXAMPLEEXAMPLE01", None); - make_date_stale(&mut headers); - match provider_without_credentials() - .authenticate(&headers, b"{}") - .await - { - Err(DynamoDbError::UnrecognizedClientException(msg)) => { - assert!(msg.starts_with("Signature expired: "), "{msg}"); - } - other => panic!("Expected the timestamp error, got: {other:?}"), - } -} - -/// Measured 2026-10-23 (R2-2 case D): a stale date scoped to another -/// region answers "Signature expired", so the timestamp check runs -/// before the scope check. -#[tokio::test] -async fn stale_date_with_a_wrong_region_reports_the_timestamp() { - let mut headers = signed_headers("AKIAEXTENDDB00000000", "us-west-2", None, b"{}"); - make_date_stale(&mut headers); - match provider(false).authenticate(&headers, b"{}").await { - Err(DynamoDbError::UnrecognizedClientException(msg)) => { - assert!(msg.starts_with("Signature expired: "), "{msg}"); - } - other => panic!("Expected the timestamp error, got: {other:?}"), - } -} - -/// The scope date the service sends back for a mismatch, with the trailing -/// period and space (measured 2026-10-23, M4-R7b-1 cases 1e and 2e). -const SCOPE_DATE_MESSAGE: &str = - "Date in Credential scope does not match YYYYMMDD from ISO-8601 version of date from HTTP. "; - -/// The previous day as `YYYYMMDD`, for a scope date that does not match a -/// fresh `X-Amz-Date`. -fn yesterday() -> String { - let d = time::OffsetDateTime::now_utc() - time::Duration::days(1); - format!("{:04}{:02}{:02}", d.year(), d.month() as u8, d.day()) -} - -/// Headers for a request whose credential scope names `region`, `service` -/// and `scope_date`, with `X-Amz-Date` at the current time. `scope_date` -/// of `None` uses the date of `X-Amz-Date`. The signature is computed over -/// the scope as stated, so the only fault is the one the caller set. -fn signed_with_scope( - access_key: &str, - region: &str, - service: &str, - scope_date: Option<&str>, -) -> HeaderMap { - let mut headers = make_headers_with_auth(access_key, None); - headers.remove("authorization"); - let date_str = headers["x-amz-date"].to_str().unwrap().to_owned(); - let scope_date = scope_date.unwrap_or(&date_str[..8]).to_owned(); - let signed = "content-type;host;x-amz-date;x-amz-target"; - let creq = sigv4::canonical::canonical_request("POST", "/", "", &headers, signed, b"{}"); - let scope = format!("{scope_date}/{region}/{service}/aws4_request"); - let sts = sigv4::canonical::string_to_sign(&date_str, &scope, &creq); - let key = sigv4::signing_key::derive_signing_key(SECRET, &scope_date, region, service); - let sig = sigv4::signing_key::compute_signature(&key, &sts); - let auth = format!( - "AWS4-HMAC-SHA256 Credential={access_key}/{scope}, SignedHeaders={signed}, Signature={sig}" - ); - headers.insert("authorization", HeaderValue::from_str(&auth).unwrap()); - headers -} - -/// The status, error type and message a client sees for a refusal: the -/// three parts of the response body and status line that a caller can read -/// without a credential. -fn refusal(result: Result) -> (u16, String, String) { - match result { - Err(e) => (e.status_code(), e.full_error_type(), e.message().to_owned()), - Ok(_) => panic!("expected a refusal, the request authenticated"), - } -} - -/// Measured 2026-10-23 (M4-R7b-1 case 1e): an unknown access key whose -/// credential scope carries the previous day's date answers the scope date -/// message, so the scope date is compared before the key lookup. -#[tokio::test] -async fn unknown_key_with_a_stale_scope_date_reports_the_scope_date() { - let headers = signed_with_scope( - "AKIAEXAMPLEEXAMPLE01", - "us-east-1", - "dynamodb", - Some(&yesterday()), - ); - match provider_without_credentials() - .authenticate(&headers, b"{}") - .await - { - Err(DynamoDbError::InvalidSignatureException(msg)) => { - assert_eq!(msg, SCOPE_DATE_MESSAGE); - } - other => panic!("Expected InvalidSignatureException, got: {other:?}"), - } -} - -/// Every fault the service answers before it looks the access key up gives -/// the same status, error type and message for a key that exists and one -/// that does not (measured 2026-10-23, M4-R7b-1: 1b against 2b for the -/// timestamp, 1c against 2c for the region, 1d against 2d for the service, -/// 1e against 2e for the scope date). The timestamp error's own type and -/// message differ from the service's and are not changed here. -#[tokio::test] -async fn pre_lookup_faults_answer_alike_for_a_known_and_an_unknown_key() { - let stale = yesterday(); - let mut cases: Vec<(&str, HeaderMap)> = vec![ - ( - "scope region us-west-2", - signed_with_scope("AKIAEXTENDDB00000000", "us-west-2", "dynamodb", None), - ), - ( - "scope service s3", - signed_with_scope("AKIAEXTENDDB00000000", "us-east-1", "s3", None), - ), - ( - "scope date the previous day", - signed_with_scope( - "AKIAEXTENDDB00000000", - "us-east-1", - "dynamodb", - Some(&stale), - ), - ), - ( - "scope date eight letters", - signed_with_scope( - "AKIAEXTENDDB00000000", - "us-east-1", - "dynamodb", - Some("ABCDEFGH"), - ), - ), - ( - "scope date empty", - signed_with_scope("AKIAEXTENDDB00000000", "us-east-1", "dynamodb", Some("")), - ), - ]; - let mut timestamp = make_headers_with_auth("AKIAEXTENDDB00000000", None); - make_date_stale(&mut timestamp); - cases.push(("X-Amz-Date one hour old", timestamp)); - - for (name, headers) in cases { - let known = refusal(provider(false).authenticate(&headers, b"{}").await); - let unknown = refusal( - provider_without_credentials() - .authenticate(&headers, b"{}") - .await, - ); - assert_eq!( - known, unknown, - "{name}: a key that exists and one that does not answered differently" - ); - } -} - -/// The scope date is compared as bytes against the `YYYYMMDD` prefix of -/// `X-Amz-Date`, so a date of another length, a date carrying letters and a -/// date naming a day that does not exist are each a mismatch rather than a -/// parse error or an accepted value. -#[tokio::test] -async fn scope_date_comparison_is_exact() { - let stale = yesterday(); - let today = time::OffsetDateTime::now_utc(); - let full = format!( - "{:04}{:02}{:02}", - today.year(), - today.month() as u8, - today.day() - ); - let seven = &full[..7]; - let nine = format!("{full}0"); - for date in [ - stale.as_str(), - seven, - &nine, - "ABCDEFGH", - "20261332", - "", - "2026102X", - ] { - let headers = - signed_with_scope("AKIAEXTENDDB00000000", "us-east-1", "dynamodb", Some(date)); - match provider(false).authenticate(&headers, b"{}").await { - Err(DynamoDbError::InvalidSignatureException(msg)) => { - assert_eq!(msg, SCOPE_DATE_MESSAGE, "scope date {date:?}"); - } - other => panic!("scope date {date:?}: expected the date message, got {other:?}"), - } - } -} - -/// A scope whose date matches is accepted, so the check does not refuse a -/// correctly scoped request. -#[tokio::test] -async fn matching_scope_date_authenticates() { - let headers = signed_with_scope("AKIAEXTENDDB00000000", "us-east-1", "dynamodb", None); - let id = provider(false).authenticate(&headers, b"{}").await.unwrap(); - assert!(matches!(id, AuthIdentity::User { .. })); -} - -/// Measured 2026-10-24 (B18-1 cases 1c, 1d, 1e, 2e, 2f): the date complaint -/// joins the region and service complaints in one message and comes last. -#[tokio::test] -async fn scope_wrong_in_more_than_one_part_reports_each_complaint() { - let stale = yesterday(); - let region_then_date = format!( - "Credential should be scoped to a valid region. {}", - SCOPE_DATE_MESSAGE - ); - let service_then_date = format!( - "Credential should be scoped to correct service: 'dynamodb'. {}", - SCOPE_DATE_MESSAGE - ); - let all_three = format!( - "Credential should be scoped to a valid region. Credential should be scoped to correct service: 'dynamodb'. {}", - SCOPE_DATE_MESSAGE - ); - let cases = [ - ("us-west-2", "dynamodb", region_then_date), - ("us-east-1", "s3", service_then_date), - ("us-west-2", "s3", all_three), - ]; - for (region, service, expected) in cases { - let headers = signed_with_scope("AKIAEXTENDDB00000000", region, service, Some(&stale)); - match provider(false).authenticate(&headers, b"{}").await { - Err(DynamoDbError::InvalidSignatureException(msg)) => { - assert_eq!(msg, expected, "scope {region}/{service} with a stale date"); - } - other => panic!("scope {region}/{service}: expected the scope message, got {other:?}"), - } - } -} From ea5a90bcce7d607a65e9f168e6f3c1ee7fd13b0a Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:55:37 +0000 Subject: [PATCH 2/3] test(auth): the scope date is compared before the token expiry DynamoDB compares the credential scope date before it checks whether a session token has expired. Measured on 2026-11-10 in us-east-1 with signed ListTables requests and temporary credentials, two sends per case and the same answer each time: - expired token, scope date of the X-Amz-Date day: 400 ExpiredTokenException, "The security token included in the request is expired" - expired token, scope date one day earlier: 400 InvalidSignatureException, "Date in Credential scope does not match YYYYMMDD from ISO-8601 version of date from HTTP. " (the message ends with a space) ExtendDB answers both cases the same way. authenticate() runs check_scope before lookup_credential, and the lookup checks the session expiry. The new test in scope_tests.rs checks that order for both cases. It reads the session through CachedCredentialStore, so the ExpiredTokenException type and message come from the expiry check in CachedCredentialStore::lookup_credential. The same request with a matching scope date authenticates through a CachedCredentialStore built the same way when the session ends an hour from now. The expiry is the only fault in the matching case. The measurement recorded the error type after the "#" only, so the test does not compare the prefix. signed_with_scope_and_token is signed_with_scope with an optional session token, sent and signed. It sorts the signed header list, so x-amz-security-token comes before x-amz-target, as in the measured requests. signed_with_scope calls it with no token, so its existing callers sign the same requests as before. credential() in tests.rs becomes pub(super) for the new test. --- crates/auth/src/scope_tests.rs | 106 +++++++++++++++++++++++++++++++-- crates/auth/src/tests.rs | 2 +- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/crates/auth/src/scope_tests.rs b/crates/auth/src/scope_tests.rs index 18bf0abbf..a8fd27993 100644 --- a/crates/auth/src/scope_tests.rs +++ b/crates/auth/src/scope_tests.rs @@ -4,7 +4,9 @@ //! Unit tests for the request timestamp and credential scope checks of //! `BuiltinAuthProvider::authenticate`, and for the order of its checks. -use super::tests::{MockCredentialStore, SECRET, make_headers_with_auth, provider, signed_headers}; +use super::tests::{ + MockCredentialStore, SECRET, credential, make_headers_with_auth, provider, signed_headers, +}; use super::*; use axum::http::{HeaderMap, HeaderValue}; @@ -144,12 +146,30 @@ fn signed_with_scope( service: &str, scope_date: Option<&str>, ) -> HeaderMap { - let mut headers = make_headers_with_auth(access_key, None); + signed_with_scope_and_token(access_key, region, service, scope_date, None) +} + +/// `signed_with_scope` with a session token: `token`, when present, is sent +/// and signed, as a client with temporary credentials sends it. +fn signed_with_scope_and_token( + access_key: &str, + region: &str, + service: &str, + scope_date: Option<&str>, + token: Option<&str>, +) -> HeaderMap { + let mut headers = make_headers_with_auth(access_key, token); headers.remove("authorization"); let date_str = headers["x-amz-date"].to_str().unwrap().to_owned(); let scope_date = scope_date.unwrap_or(&date_str[..8]).to_owned(); - let signed = "content-type;host;x-amz-date;x-amz-target"; - let creq = sigv4::canonical::canonical_request("POST", "/", "", &headers, signed, b"{}"); + let mut names = vec!["content-type", "host", "x-amz-date", "x-amz-target"]; + if token.is_some() { + names.push("x-amz-security-token"); + } + // SigV4 lists the signed headers sorted by character code. + names.sort_unstable(); + let signed = names.join(";"); + let creq = sigv4::canonical::canonical_request("POST", "/", "", &headers, &signed, b"{}"); let scope = format!("{scope_date}/{region}/{service}/aws4_request"); let sts = sigv4::canonical::string_to_sign(&date_str, &scope, &creq); let key = sigv4::signing_key::derive_signing_key(SECRET, &scope_date, region, service); @@ -329,3 +349,81 @@ async fn scope_wrong_in_more_than_one_part_reports_each_complaint() { } } } + +/// A provider whose store holds a temporary key whose session ends at +/// `expires_at`, read through the credential cache. The cache refuses an +/// expired session with the error the storage layer returns. +fn provider_with_a_session_expiring_at( + expires_at: time::OffsetDateTime, +) -> BuiltinAuthProvider { + let mut session = credential(true); + session.expires_at = Some(expires_at); + BuiltinAuthProvider::new( + CachedCredentialStore::new( + MockCredentialStore { + credential: Some(session), + error: None, + }, + extenddb_cache::SwrCacheConfig::default(), + ), + "us-east-1", + ) +} + +/// Measured 2026-11-10 on signed ListTables requests with temporary +/// credentials, after the session token expired. A credential scope dated +/// the previous day answered the scope date refusal, and a scope dated the +/// day of `X-Amz-Date` answered the expired token refusal. The scope date is +/// compared before the token's expiry is checked. The measurement recorded +/// the error type after the `#` only, so the prefix is not compared. +#[tokio::test] +async fn scope_date_is_compared_before_the_session_token_expiry() { + let now = time::OffsetDateTime::now_utc(); + let expired = provider_with_a_session_expiring_at(now - time::Duration::minutes(1)); + let stale = yesterday(); + let cases = [ + ( + Some(stale.as_str()), + (400, "InvalidSignatureException", SCOPE_DATE_MESSAGE), + ), + ( + None, + ( + 400, + "ExpiredTokenException", + "The security token included in the request is expired", + ), + ), + ]; + for (scope_date, expected) in cases { + let headers = signed_with_scope_and_token( + "ASIAEXTENDDB00000000", + "us-east-1", + "dynamodb", + scope_date, + Some("stored-token"), + ); + match expired.authenticate(&headers, b"{}").await { + Err(e) => assert_eq!( + (e.status_code(), e.error_type(), e.message()), + expected, + "scope date {scope_date:?}" + ), + Ok(_) => panic!("scope date {scope_date:?}: the expired session authenticated"), + } + } + + // A provider built the same way, whose session ends an hour from now, + // accepts the same request with a matching scope date. This shows that + // the expiry is the only fault in the matching case. + let current = provider_with_a_session_expiring_at(now + time::Duration::hours(1)); + let headers = signed_with_scope_and_token( + "ASIAEXTENDDB00000000", + "us-east-1", + "dynamodb", + None, + Some("stored-token"), + ); + let id = current.authenticate(&headers, b"{}").await.unwrap(); + assert!(matches!(id, AuthIdentity::RoleSession { .. })); +} diff --git a/crates/auth/src/tests.rs b/crates/auth/src/tests.rs index 16775fd1d..3374d1455 100644 --- a/crates/auth/src/tests.rs +++ b/crates/auth/src/tests.rs @@ -113,7 +113,7 @@ async fn nonexistent_key_returns_unrecognized_client() { pub(super) const SECRET: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; -fn credential(is_session: bool) -> StoredCredential { +pub(super) fn credential(is_session: bool) -> StoredCredential { StoredCredential { secret_key: SECRET.to_owned(), account_id: "123456789012".to_owned(), From 101857a9bda2926746993e587573e8764132597d Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:57:11 +0000 Subject: [PATCH 3/3] docs(security): list the SigV4 checks in the order the code runs them The SigV4 section of the security model manual says its steps run in the order listed. It listed the access key lookup before the X-Amz-Date check and the credential scope check. BuiltinAuthProvider runs validate_timestamp, then check_scope, then lookup_credential, and its doc comment gives that as the service's order. The list now follows the code. The X-Amz-Date check is step 3, the scope check is step 4 and the key lookup is step 5. The scope step names the date comparison that check_scope makes. The lookup step says that the lookup checks the session expiry and refuses an expired session at once with ExpiredTokenException, before the signature check. The storage layer checks the expiry on a cache miss, and CachedCredentialStore checks it on a cache hit. The lookup step also says that the lookup reads whether the key is active, and that step 9 applies that decision. Step 9 now refers to steps 5 and 6 for the deferred decisions. DynamoDB checks the scope date before the session expiry (measured on 2026-11-10 in us-east-1). With an expired session token, a scope date one day earlier answers InvalidSignatureException with the scope date message. The same token with a matching scope date answers ExpiredTokenException. The order of the X-Amz-Date, scope and key lookup checks was measured on 2026-10-23, as the comment above validate_timestamp records. --- docs/manuals/10-security-model.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md index a6ded4cd4..59cba923a 100755 --- a/docs/manuals/10-security-model.md +++ b/docs/manuals/10-security-model.md @@ -38,13 +38,13 @@ The steps run in this order; every rejection is HTTP 400. 1. Extract the `Authorization` header (AWS4-HMAC-SHA256 scheme); absent: `MissingAuthenticationToken` 2. Parse credential scope, signed headers, and signature; malformed: `IncompleteSignatureException`. A request with more than one `host` or `authorization` header is refused before this point with a plain 400, as the service's front end does -3. Look up the access key in the credential store (through the credential cache, see below); unknown: `UnrecognizedClientException` -4. Check `X-Amz-Date` against the server clock: more than 15 minutes of skew is `UnrecognizedClientException` (signature expired) -5. Check the credential scope: the region must be this server's `[server] region` and the service must be `dynamodb`; otherwise `InvalidSignatureException` carrying one sentence per failing part, region first, as the service reports it +3. Check `X-Amz-Date` against the server clock: more than 15 minutes of skew is `UnrecognizedClientException` (signature expired) +4. Check the credential scope: the region must be this server's `[server] region` and the service must be `dynamodb`; otherwise `InvalidSignatureException` carrying one sentence per failing part, region first, as the service reports it. The scope date must also be the `YYYYMMDD` prefix of `X-Amz-Date`, and its sentence comes after the region and service sentences +5. Look up the access key in the credential store (through the credential cache, see below); unknown: `UnrecognizedClientException`. The lookup also checks the session expiry of a temporary key, and refuses an expired session at once with `ExpiredTokenException`, before the signature check. It also reads whether the key is active, and step 9 applies that decision 6. Compare the session token: a temporary key must present its stored token, and a long-term key must present none; the decision is applied at step 9 7. Reconstruct the canonical request and the string-to-sign. `host` must be among the signed headers. Repeated headers contribute their values comma-joined. The hashed-payload line is the SHA-256 of the body the server received; a client-supplied `x-amz-content-sha256` header is not used for it, so a request whose body was altered after signing, or signed with a literal such as `UNSIGNED-PAYLOAD`, fails at step 8 8. Derive the signing key (HMAC-SHA256 chain over date, region, service, "aws4_request") and compare the computed signature with the provided one in constant time; mismatch: `InvalidSignatureException` -9. Apply the deferred decisions from steps 3 and 6, after the signature check so that failure paths take the same time: an inactive key or a wrong or missing token is `UnrecognizedClientException` +9. Apply the deferred decisions from steps 5 and 6, after the signature check so that failure paths take the same time: an inactive key or a wrong or missing token is `UnrecognizedClientException` Request bodies are parsed before authentication (the service does the same), so a malformed body returns `SerializationException` whatever the signature. Headers not named in `SignedHeaders` do not take part in verification; the service behaves the same way, and clients sign every `x-amz-*` header they send.