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..a8fd27993 --- /dev/null +++ b/crates/auth/src/scope_tests.rs @@ -0,0 +1,429 @@ +// 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, credential, 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 { + 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 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); + 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:?}"), + } + } +} + +/// 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 bec44b6f3..3374d1455 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,9 +111,9 @@ 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 { +pub(super) fn credential(is_session: bool) -> StoredCredential { StoredCredential { secret_key: SECRET.to_owned(), account_id: "123456789012".to_owned(), @@ -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:?}"), - } - } -} 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.