From 0cc8775117256414a44035bd03e103dc2546e114 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:19:17 +0000 Subject: [PATCH 1/2] test(auth): check SigV4 decisions against the AWS SDK signer Sign requests with aws-sigv4, the SigV4 signer in the AWS SDK for Rust, and pass them to BuiltinAuthProvider::authenticate. A request as signed must verify, and the server's canonical request must equal the signer's. A change to any part the signature covers must be refused with the message of the check that covers it. The fixed corpus covers the header sets DynamoDB clients sign and send, 39 region codes and three local endpoint names, a long-term key and a session key. 256 more requests come from proptest with a fixed seed. aws-sigv4, aws-credential-types and proptest are already in Cargo.lock and are dev-dependencies only. --- Cargo.lock | 3 + Cargo.toml | 13 + crates/auth/Cargo.toml | 5 + .../auth/tests/sigv4_differential/accept.rs | 176 ++++++ .../auth/tests/sigv4_differential/capture.rs | 77 +++ .../auth/tests/sigv4_differential/corpus.rs | 288 +++++++++ .../tests/sigv4_differential/generated.rs | 173 +++++ .../auth/tests/sigv4_differential/harness.rs | 598 ++++++++++++++++++ crates/auth/tests/sigv4_differential/main.rs | 27 + .../auth/tests/sigv4_differential/mutate.rs | 416 ++++++++++++ .../auth/tests/sigv4_differential/reject.rs | 154 +++++ 11 files changed, 1930 insertions(+) create mode 100644 crates/auth/tests/sigv4_differential/accept.rs create mode 100644 crates/auth/tests/sigv4_differential/capture.rs create mode 100644 crates/auth/tests/sigv4_differential/corpus.rs create mode 100644 crates/auth/tests/sigv4_differential/generated.rs create mode 100644 crates/auth/tests/sigv4_differential/harness.rs create mode 100644 crates/auth/tests/sigv4_differential/main.rs create mode 100644 crates/auth/tests/sigv4_differential/mutate.rs create mode 100644 crates/auth/tests/sigv4_differential/reject.rs diff --git a/Cargo.lock b/Cargo.lock index 72184565b..ada606ba8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1728,12 +1728,15 @@ name = "extenddb-auth" version = "0.1.14" dependencies = [ "async-trait", + "aws-credential-types", + "aws-sigv4", "axum", "extenddb-cache", "extenddb-core", "futures", "hex", "hmac 0.12.1", + "proptest", "serde_json", "sha2 0.10.9", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 8ba393e8e..529e719c0 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,6 +120,19 @@ aws-smithy-http-client = { version = "=1.1.9", default-features = false, feature url = { version = "=2.5.8" } bytes = "1" +# Auth tests +# Already in the graph through aws-config; named directly so the auth crate's +# tests can sign requests with the SDK's SigV4 signer and check the server's +# verifier against it. +aws-sigv4 = { version = "=1.3.8", default-features = false } +# Already in the graph through aws-config; named directly because the +# aws-sigv4 signer takes this crate's credentials type and does not re-export +# it. +aws-credential-types = { version = "=1.2.11", default-features = false } +# Already in the graph through the MongoDB backend's tests; named directly so +# the auth tests can generate requests from a fixed seed. +proptest = { version = "1.11", default-features = false } + # Crypto & checksums crc32fast = "1" # Pinned to the versions already resolved in Cargo.lock. diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml index f878ebaad..79781fa0a 100755 --- a/crates/auth/Cargo.toml +++ b/crates/auth/Cargo.toml @@ -25,3 +25,8 @@ zeroize = { workspace = true } [dev-dependencies] extenddb-cache = { workspace = true, features = ["test-util"] } tokio = { workspace = true, features = ["test-util", "macros", "rt-multi-thread"] } +# The SigV4 differential tests (tests/sigv4_differential) sign requests with +# the SDK's signer and generate them from a fixed seed. +aws-credential-types = { workspace = true } +aws-sigv4 = { workspace = true, features = ["sign-http", "http1"] } +proptest = { workspace = true, features = ["std"] } diff --git a/crates/auth/tests/sigv4_differential/accept.rs b/crates/auth/tests/sigv4_differential/accept.rs new file mode 100644 index 000000000..de237a867 --- /dev/null +++ b/crates/auth/tests/sigv4_differential/accept.rs @@ -0,0 +1,176 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Requests `aws-sigv4` signed the way DynamoDB clients send them verify, and +//! the server's canonical request for each is the one `aws-sigv4` signed. + +use crate::corpus::{self, ENDPOINTS, REGIONS, SDK_HEADERS}; +use crate::harness::{Key, Shape, Tally, check_accepted, sign}; +use crate::mutate::{MARGIN, SKEW_WINDOW}; + +fn accept(shape: &Shape, what: &str, tally: &mut Tally, category: &'static str) { + if let Err(err) = check_accepted(&sign(shape)) { + panic!("{what}: {err}"); + } + tally.add(category); +} + +/// Each client shape, with and without `x-amz-content-sha256`, for both keys +/// and every small body. +#[test] +fn client_shapes_verify() { + let mut tally = Tally::default(); + for key in Key::ALL { + for (body_name, body) in corpus::small_bodies() { + for (shape_name, shape) in corpus::client_shapes(key, &body) { + for content_sha256 in [false, true] { + let mut shape = shape.clone(); + shape.content_sha256 = content_sha256; + let what = + format!("{key:?}, {shape_name}, {body_name}, sha256 {content_sha256}"); + accept(&shape, &what, &mut tally, "accepted: client shape"); + } + } + } + } + tally.report("client_shapes_verify"); +} + +/// A request signed for each region verifies. +#[test] +fn every_region_verifies() { + let mut tally = Tally::default(); + for key in Key::ALL { + for region in REGIONS { + let mut shape = corpus::base(key, corpus::PUT_ITEM.to_vec()); + (*region).clone_into(&mut shape.region); + accept( + &shape, + &format!("{key:?} in {region}"), + &mut tally, + "accepted: region", + ); + } + } + tally.report("every_region_verifies"); +} + +/// Each endpoint, with Host taken from the URI and with Host set by the +/// client. +#[test] +fn every_endpoint_verifies() { + let mut tally = Tally::default(); + for key in Key::ALL { + for endpoint in ENDPOINTS { + for explicit_host in [false, true] { + let mut shape = corpus::base(key, corpus::PUT_ITEM.to_vec()); + shape.endpoint = *endpoint; + if explicit_host { + shape = shape.with_signed("host", endpoint.host); + } + let what = format!("{key:?} to {}, host set {explicit_host}", endpoint.uri); + accept(&shape, &what, &mut tally, "accepted: endpoint"); + } + } + } + tally.report("every_endpoint_verifies"); +} + +/// Rewrites a header value's spacing. +type Respace = fn(&str) -> String; + +/// Signed values with leading, trailing and repeated inner spaces. +#[test] +fn header_values_with_spaces_verify() { + let spacings: [(&str, Respace); 4] = [ + ("leading spaces", |value| format!(" {value}")), + ("trailing spaces", |value| format!("{value} ")), + ("repeated inner spaces", |value| value.replace(' ', " ")), + ("all three", |value| { + format!(" {} ", value.replace(' ', " ")) + }), + ]; + let mut tally = Tally::default(); + for key in Key::ALL { + for (spacing, respace) in spacings { + let mut shape = corpus::base(key, corpus::PUT_ITEM.to_vec()); + for (name, value) in SDK_HEADERS { + shape = shape.with_signed(name, value); + } + for (name, value) in &mut shape.signed { + if name != "content-length" { + *value = respace(value); + } + } + accept( + &shape, + &format!("{key:?}, {spacing}"), + &mut tally, + "accepted: spaced header values", + ); + } + } + tally.report("header_values_with_spaces_verify"); +} + +/// Header names in Title-Case, upper case and alternating case, signed and +/// unsigned. +#[test] +fn mixed_case_header_names_verify() { + let mut tally = Tally::default(); + for key in Key::ALL { + for style in 1..4 { + for (shape_name, shape) in corpus::client_shapes(key, corpus::PUT_ITEM) { + let host = shape.endpoint.host; + let mut shape = shape.with_signed("host", host); + for (name, _) in shape.signed.iter_mut().chain(shape.unsigned.iter_mut()) { + *name = corpus::spell(name, style); + } + accept( + &shape, + &format!("{key:?}, {shape_name}, style {style}"), + &mut tally, + "accepted: mixed-case names", + ); + } + } + } + tally.report("mixed_case_header_names_verify"); +} + +/// Large bodies, with and without `x-amz-content-sha256`. +#[test] +fn large_bodies_verify() { + let mut tally = Tally::default(); + for key in Key::ALL { + for (body_name, body) in corpus::large_bodies() { + for content_sha256 in [false, true] { + let mut shape = corpus::base(key, body.clone()); + shape.content_sha256 = content_sha256; + let what = format!("{key:?}, {body_name}, sha256 {content_sha256}"); + accept(&shape, &what, &mut tally, "accepted: large body"); + } + } + } + tally.report("large_bodies_verify"); +} + +/// A client clock ahead of or behind the server's, inside the skew window. +#[test] +fn clock_offsets_inside_the_window_verify() { + let edge = SKEW_WINDOW - MARGIN; + let mut tally = Tally::default(); + for key in Key::ALL { + for offset in [-edge, -60, 0, 60, edge] { + let mut shape = corpus::base(key, corpus::PUT_ITEM.to_vec()); + shape.clock_offset = offset; + accept( + &shape, + &format!("{key:?}, clock {offset} s"), + &mut tally, + "accepted: clock offset inside the window", + ); + } + } + tally.report("clock_offsets_inside_the_window_verify"); +} diff --git a/crates/auth/tests/sigv4_differential/capture.rs b/crates/auth/tests/sigv4_differential/capture.rs new file mode 100644 index 000000000..d4ceb033f --- /dev/null +++ b/crates/auth/tests/sigv4_differential/capture.rs @@ -0,0 +1,77 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! The canonical request `aws-sigv4` builds. +//! +//! `aws-sigv4` keeps its canonical request private and records it in a +//! trace-level event, in the field `canonical_request`. The subscriber here +//! keeps that field's text while a closure runs on the current thread. + +use std::fmt; +use std::sync::{Arc, Mutex, PoisonError}; + +use tracing::field::{Field, Visit}; +use tracing::span::{Attributes, Id, Record}; +use tracing::{Event, Metadata, Subscriber}; + +/// Run `sign` and return its result with the canonical request `aws-sigv4` +/// recorded while it ran, if it recorded one. +pub fn canonical_request(sign: impl FnOnce() -> T) -> (T, Option) { + let recorded = Arc::new(Mutex::new(None)); + let recorder = Recorder { + canonical_request: Arc::clone(&recorded), + }; + let output = tracing::subscriber::with_default(recorder, sign); + let text = recorded + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(); + (output, text) +} + +/// Keeps the last `canonical_request` field an `aws-sigv4` event carries. +struct Recorder { + canonical_request: Arc>>, +} + +impl Subscriber for Recorder { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.target().starts_with("aws_sigv4") + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, event: &Event<'_>) { + let mut field = CanonicalRequestField(None); + event.record(&mut field); + if let Some(text) = field.0 { + *self + .canonical_request + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(text); + } + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} +} + +/// The text of an event's `canonical_request` field. +struct CanonicalRequestField(Option); + +impl Visit for CanonicalRequestField { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "canonical_request" { + // The field is recorded with `%`, so its Debug output is the + // Display text of the canonical request. + self.0 = Some(format!("{value:?}")); + } + } +} diff --git a/crates/auth/tests/sigv4_differential/corpus.rs b/crates/auth/tests/sigv4_differential/corpus.rs new file mode 100644 index 000000000..82ef4ed22 --- /dev/null +++ b/crates/auth/tests/sigv4_differential/corpus.rs @@ -0,0 +1,288 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Fixed request parts: endpoints, regions, operations, the headers DynamoDB +//! clients send, and bodies. + +use crate::harness::{Endpoint, Key, Shape}; + +/// Endpoints a client is configured with. None names the scheme's default +/// port, so the Host value the client's HTTP stack sends is the authority, +/// which is also what the signer signs. +pub const ENDPOINTS: &[Endpoint] = &[ + Endpoint { + uri: "https://localhost:18443/", + host: "localhost:18443", + }, + Endpoint { + uri: "https://127.0.0.1:18443/", + host: "127.0.0.1:18443", + }, + Endpoint { + uri: "https://[::1]:18443/", + host: "[::1]:18443", + }, + Endpoint { + uri: "http://extenddb.internal:8000/", + host: "extenddb.internal:8000", + }, + Endpoint { + uri: "https://extenddb.example.com/", + host: "extenddb.example.com", + }, +]; + +/// Regions a client signs for. `verify_signature` checks the credential +/// scope's service and date but not its region, so a request signed for any +/// region verifies. The list holds the AWS commercial, GovCloud, China and +/// European Sovereign Cloud region codes, and names a local deployment uses. +pub const REGIONS: &[&str] = &[ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "af-south-1", + "ap-east-1", + "ap-east-2", + "ap-south-1", + "ap-south-2", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ca-central-1", + "ca-west-1", + "eu-central-1", + "eu-central-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-south-1", + "eu-south-2", + "eu-north-1", + "il-central-1", + "me-south-1", + "me-central-1", + "mx-central-1", + "sa-east-1", + "us-gov-east-1", + "us-gov-west-1", + "cn-north-1", + "cn-northwest-1", + "eusc-de-east-1", + "local", + "localhost", + "LOCAL", +]; + +/// `X-Amz-Target` values of operations the server serves. +pub const TARGETS: &[&str] = &[ + "DynamoDB_20120810.ListTables", + "DynamoDB_20120810.PutItem", + "DynamoDB_20120810.GetItem", + "DynamoDB_20120810.Query", + "DynamoDB_20120810.BatchWriteItem", + "DynamoDB_20120810.TransactWriteItems", + "DynamoDBStreams_20120810.GetRecords", +]; + +/// Headers SDKs send besides the ones DynamoDB needs, with values of the +/// form the AWS SDK for Rust sends. +pub const SDK_HEADERS: &[(&str, &str)] = &[ + ( + "user-agent", + "aws-sdk-rust/1.3.8 ua/2.1 api/dynamodb/1.95.0 os/linux lang/rust/1.88.0", + ), + ( + "x-amz-user-agent", + "aws-sdk-rust/1.3.8 ua/2.1 api/dynamodb/1.95.0 os/linux lang/rust/1.88.0 m/E,b,D", + ), + ( + "amz-sdk-invocation-id", + "3f9d1c7a-58e2-4b7e-9c61-0a2b4d6e8f10", + ), + ("amz-sdk-request", "attempt=1; max=3"), +]; + +/// Headers clients and proxies send without signing them. +pub const UNSIGNED_HEADERS: &[(&str, &str)] = &[ + ("accept-encoding", "gzip, deflate"), + ( + "x-amzn-trace-id", + "Root=1-6727a6f0-1a2b3c4d5e6f708192a3b4c5;Sampled=1", + ), + ("accept", "*/*"), + ("x-forwarded-for", "203.0.113.7"), +]; + +/// A PutItem body as SDKs write it. +pub const PUT_ITEM: &[u8] = br#"{"TableName":"Music","Item":{"Artist":{"S":"No One You Know"},"SongTitle":{"S":"Call Me Today"}}}"#; + +/// Text with characters of two, three and four bytes in UTF-8. +pub const MULTIBYTE: &[&str] = &[ + "Bjørk", + "日本語のテキスト", + "Ünïcödé ✓", + "🦀🦀🦀", + "\u{10FFFF}", + "€ £ ¥", + "混合 mixed テキスト", +]; + +/// The number of JSON tokens `spaced_item` writes. +pub const ITEM_TOKENS: usize = 17; + +/// A PutItem object with one string attribute holding `value`, with +/// `gaps[i]` written before token `i` and `gaps[ITEM_TOKENS]` after the last. +pub fn spaced_item(gaps: &[&str], table: &str, value: &str) -> Vec { + let table = serde_json::to_string(table).expect("a string encodes"); + let value = serde_json::to_string(value).expect("a string encodes"); + let tokens: [&str; ITEM_TOKENS] = [ + "{", + "\"TableName\"", + ":", + &table, + ",", + "\"Item\"", + ":", + "{", + "\"pk\"", + ":", + "{", + "\"S\"", + ":", + &value, + "}", + "}", + "}", + ]; + let mut body = String::new(); + for (gap, token) in gaps.iter().zip(tokens) { + body.push_str(gap); + body.push_str(token); + } + body.push_str(gaps.get(ITEM_TOKENS).copied().unwrap_or("")); + body.into_bytes() +} + +/// A PutItem object of exactly `size` bytes whose string value repeats +/// `fill`, topped up with ASCII. +pub fn sized_item(size: usize, fill: &str) -> Vec { + let head = br#"{"TableName":"Music","Item":{"pk":{"S":""#; + let tail = br#""}}}"#; + let room = size - head.len() - tail.len(); + let mut value = fill.repeat(room / fill.len()); + while value.len() < room { + value.push('a'); + } + let mut body = head.to_vec(); + body.extend_from_slice(value.as_bytes()); + body.extend_from_slice(tail); + assert_eq!(body.len(), size); + body +} + +/// The bodies every client shape is checked with: empty, a PutItem as SDKs +/// write it, JSON with odd spacing, and multi-byte UTF-8. +pub fn small_bodies() -> Vec<(&'static str, Vec)> { + let gaps = ["\n", " ", "\t", " ", "\r\n", " ", "\t\t", "", " \n "]; + let odd: Vec<&str> = gaps.iter().copied().cycle().take(ITEM_TOKENS + 1).collect(); + vec![ + ("empty body", Vec::new()), + ("PutItem body", PUT_ITEM.to_vec()), + ("JSON with odd spacing", spaced_item(&odd, "Music", "x")), + ( + "multi-byte UTF-8", + spaced_item(&[""; ITEM_TOKENS + 1], "Music", &MULTIBYTE.join(" ")), + ), + ] +} + +/// Large bodies: 64 KiB, the 400 KiB item limit, and 1 MiB of multi-byte +/// text. +pub fn large_bodies() -> Vec<(&'static str, Vec)> { + vec![ + ("64 KiB body", sized_item(64 * 1024, "a")), + ("400 KiB body", sized_item(400 * 1024, "item ")), + ("1 MiB multi-byte body", sized_item(1024 * 1024, "日本語🦀")), + ] +} + +/// The server's request body limit, 16 MiB. +pub const BODY_LIMIT: usize = 16 * 1024 * 1024; + +/// A DynamoDB PutItem to the server's default endpoint, signed for us-east-1. +pub fn base(key: Key, body: Vec) -> Shape { + Shape::new( + key, + ENDPOINTS[0], + "us-east-1", + "DynamoDB_20120810.PutItem", + body, + ) +} + +/// The shapes DynamoDB clients send, for `key` with `body`. +pub fn client_shapes(key: Key, body: &[u8]) -> Vec<(&'static str, Shape)> { + let mut sdk = base(key, body.to_vec()); + for (name, value) in SDK_HEADERS { + sdk = sdk.with_signed(name, value); + } + sdk.sdk_exclusions = true; + + let mut every_header_signed = sdk.clone(); + every_header_signed.sdk_exclusions = false; + + let mut unsigned = base(key, body.to_vec()); + for (name, value) in SDK_HEADERS.iter().chain(UNSIGNED_HEADERS) { + unsigned = unsigned.with_unsigned(name, value); + } + let unsigned = unsigned.with_unsigned("accept-encoding", "identity"); + + vec![ + // Every header goes to the signer, which leaves user-agent out of the + // signature by default. + ("AWS SDK for Rust headers", sdk), + // host, x-amz-date, x-amz-target, content-type and content-length. + ("DynamoDB headers only", base(key, body.to_vec())), + ("every SDK header signed", every_header_signed), + // accept-encoding is sent twice. + ("SDK and proxy headers unsigned", unsigned), + ] +} + +/// `name` spelled in one of four styles: as given, Title-Case, upper case, +/// and alternating case. +pub fn spell(name: &str, style: u8) -> String { + match style % 4 { + 0 => name.to_owned(), + 1 => name + .split('-') + .map(|part| { + let mut chars = part.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_ascii_uppercase().to_string() + chars.as_str() + }) + }) + .collect::>() + .join("-"), + 2 => name.to_ascii_uppercase(), + _ => name + .chars() + .enumerate() + .map(|(index, c)| { + if index % 2 == 0 { + c.to_ascii_lowercase() + } else { + c.to_ascii_uppercase() + } + }) + .collect(), + } +} diff --git a/crates/auth/tests/sigv4_differential/generated.rs b/crates/auth/tests/sigv4_differential/generated.rs new file mode 100644 index 000000000..308521518 --- /dev/null +++ b/crates/auth/tests/sigv4_differential/generated.rs @@ -0,0 +1,173 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Generated requests from a fixed seed: each verifies, changes to its +//! unsigned headers still verify, and each change `mutate` makes to it is +//! refused. +//! +//! A generated request is a POST to `/` with no query string. Its header +//! values are visible ASCII words joined by runs of spaces, never empty, and +//! each header is sent once, except that the first unsigned header may be +//! sent twice. + +use std::cell::RefCell; + +use proptest::collection::vec; +use proptest::prelude::*; +use proptest::sample::select; +use proptest::test_runner::{Config, RngAlgorithm, RngSeed, TestCaseError, TestRunner}; + +use crate::corpus::{ + self, ENDPOINTS, ITEM_TOKENS, REGIONS, SDK_HEADERS, TARGETS, UNSIGNED_HEADERS, +}; +use crate::harness::{Key, Shape, Tally}; +use crate::mutate::{self, MARGIN, SKEW_WINDOW}; + +/// Requests generated per run. +const CASES: u32 = 256; +/// The seed every run starts from. +const SEED: u64 = 0x5167_7634_2026_1103; +/// The widest clock offset a generated client signs with, in seconds. +const MAX_OFFSET: i64 = SKEW_WINDOW - MARGIN; +/// Name spellings drawn per request, one per header line. +const SPELLINGS: usize = 12; + +/// Whitespace written between JSON tokens. +const GAPS: &[&str] = &["", " ", " ", "\t", "\n", "\r\n", " \t\n "]; +const TABLES: &[&str] = &["Music", "t", "orders-2026", "A.b_c-9"]; + +fn word() -> impl Strategy { + vec(0x21_u8..=0x7e, 1..10).prop_map(|bytes| String::from_utf8(bytes).expect("visible ASCII")) +} + +/// Visible ASCII words joined by one to three spaces, with up to two spaces +/// before and after. +fn header_value() -> impl Strategy { + (0_usize..3, vec((1_usize..4, word()), 1..4), 0_usize..3).prop_map(|(lead, words, trail)| { + let mut value = " ".repeat(lead); + for (index, (gap, word)) in words.iter().enumerate() { + if index > 0 { + value.push_str(&" ".repeat(*gap)); + } + value.push_str(word); + } + value.push_str(&" ".repeat(trail)); + value + }) +} + +/// A JSON string value: any characters, or one of the multi-byte samples. +fn text() -> impl Strategy { + prop_oneof![ + vec(any::(), 0..16).prop_map(|chars| chars.into_iter().collect()), + select(corpus::MULTIBYTE).prop_map(str::to_owned), + ] +} + +/// An empty body, or a PutItem object with generated whitespace between its +/// tokens. +fn body() -> impl Strategy> { + prop_oneof![ + 1 => Just(Vec::new()), + 6 => (vec(select(GAPS), ITEM_TOKENS + 1), select(TABLES), text()) + .prop_map(|(gaps, table, value)| corpus::spaced_item(&gaps, table, &value)), + ] +} + +fn shape() -> impl Strategy { + let request = ( + prop_oneof![Just(Key::LongTerm), Just(Key::Session)], + select(ENDPOINTS), + any::(), + select(REGIONS), + select(TARGETS), + ); + let signing = (any::(), any::(), -MAX_OFFSET..=MAX_OFFSET); + let signed = ( + vec(any::(), SDK_HEADERS.len()), + vec(header_value(), SDK_HEADERS.len()), + ); + let unsigned = ( + vec(any::(), UNSIGNED_HEADERS.len()), + vec(header_value(), UNSIGNED_HEADERS.len()), + any::(), + ); + let spelling = (vec(0_u8..4, SPELLINGS), 0_usize..3, 0_usize..3); + (request, signing, signed, unsigned, spelling, body()).prop_map( + |( + (key, endpoint, explicit_host, region, target), + (content_sha256, sdk_exclusions, clock_offset), + (sign_mask, sign_values), + (send_mask, send_values, repeat), + (styles, lead, trail), + body, + )| { + let mut shape = Shape::new(key, endpoint, region, target, body); + shape.content_sha256 = content_sha256; + shape.sdk_exclusions = sdk_exclusions; + shape.clock_offset = clock_offset; + for (name, value) in &mut shape.signed { + if name != "content-length" { + *value = format!("{}{value}{}", " ".repeat(lead), " ".repeat(trail)); + } + } + if explicit_host { + shape + .signed + .insert(0, ("host".to_owned(), endpoint.host.to_owned())); + } + for ((&(name, _), on), value) in SDK_HEADERS.iter().zip(sign_mask).zip(sign_values) { + if on { + shape.signed.push((name.to_owned(), value)); + } + } + for ((&(name, _), on), value) in UNSIGNED_HEADERS.iter().zip(send_mask).zip(send_values) + { + if on { + shape.unsigned.push((name.to_owned(), value)); + } + } + if repeat && let Some((name, value)) = shape.unsigned.first().cloned() { + shape + .unsigned + .push((name, format!("{} again", value.trim()))); + } + for ((name, _), style) in shape + .signed + .iter_mut() + .chain(shape.unsigned.iter_mut()) + .zip(styles.iter().cycle()) + { + *name = corpus::spell(name, *style); + } + shape + }, + ) +} + +#[test] +fn generated_requests_verify_and_every_change_is_refused() { + let config = Config { + cases: CASES, + failure_persistence: None, + rng_algorithm: RngAlgorithm::ChaCha, + rng_seed: RngSeed::Fixed(SEED), + ..Config::default() + }; + let tally = RefCell::new(Tally::default()); + let mut runner = TestRunner::new(config); + let result = runner.run(&shape(), |shape| { + mutate::check_shape( + &shape, + &mut tally.borrow_mut(), + "accepted: generated request", + ) + .map_err(TestCaseError::fail) + }); + tally + .borrow() + .report("generated_requests_verify_and_every_change_is_refused"); + if let Err(failure) = result { + panic!("{failure}"); + } +} diff --git a/crates/auth/tests/sigv4_differential/harness.rs b/crates/auth/tests/sigv4_differential/harness.rs new file mode 100644 index 000000000..c09c1667e --- /dev/null +++ b/crates/auth/tests/sigv4_differential/harness.rs @@ -0,0 +1,598 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Signing with `aws-sigv4`, the header map and body `authenticate` receives, +//! and the authentication call the HTTP layer makes. + +use std::collections::BTreeMap; +use std::time::{Duration, SystemTime}; + +use aws_credential_types::Credentials; +use aws_sigv4::http_request::{ + PayloadChecksumKind, SignableBody, SignableRequest, SigningSettings, sign as sdk_sign, +}; +use aws_sigv4::sign::v4; +use axum::http::{HeaderMap, HeaderName, HeaderValue}; +use extenddb_auth::sigv4::{canonical, parse}; +use extenddb_auth::{ + AuthIdentity, AuthProvider, BuiltinAuthProvider, CredentialStore, StoredCredential, +}; +use extenddb_core::error::DynamoDbError; + +use crate::capture; + +/// The account both keys belong to. +const ACCOUNT_ID: &str = "123456789012"; +const USER_NAME: &str = "differential-user"; +const ROLE_NAME: &str = "differential-role"; +const SESSION_NAME: &str = "differential-session"; + +/// The session key's token: base64 text with `/`, `+` and `=`, as STS issues. +pub const SESSION_TOKEN: &str = "IQoJb3JpZ2luX2VjEXAMPLE//////////wEaCXVzLWVhc3QtMSJHMEUCIQD\ + EXAMPLEtoken+AiB/EXAMPLEsession8Qkfq4hYtHFNfc0EXAMPLEnZsnS+OTdtI2tcNmJbGXgoQTBYWHMmHUvjHU\ + S2EXAMPLE0hn5WGuR83kN3EXAMPLEvL0zHKAI1aWIGGHMhCKEXAMPLE+tokenEXAMPLE/value=="; +/// A token the store does not hold. +pub const OTHER_TOKEN: &str = "IQoJb3JpZ2luX2VjEXAMPLEotherTOKEN+value/EXAMPLE=="; + +/// The two keys the store holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Key { + /// A long-term `AKIA` key of an IAM user. + LongTerm, + /// A temporary `ASIA` key of a role session, used with its token. + Session, +} + +impl Key { + pub const ALL: [Self; 2] = [Self::LongTerm, Self::Session]; + + pub fn id(self) -> &'static str { + match self { + Self::LongTerm => "AKIAIOSFODNN7EXAMPLE", + Self::Session => "ASIAIOSFODNN7EXAMPLE", + } + } + + pub fn secret(self) -> &'static str { + match self { + Self::LongTerm => "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + Self::Session => "je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY", + } + } + + pub fn token(self) -> Option<&'static str> { + match self { + Self::LongTerm => None, + Self::Session => Some(SESSION_TOKEN), + } + } + + /// The other key's secret, which the store does not hold for this key. + pub fn other_secret(self) -> &'static str { + match self { + Self::LongTerm => Self::Session.secret(), + Self::Session => Self::LongTerm.secret(), + } + } +} + +/// An in-memory credential store holding the two keys. +pub struct MemoryStore; + +#[async_trait::async_trait] +impl CredentialStore for MemoryStore { + async fn lookup_credential( + &self, + access_key_id: &str, + ) -> Result, DynamoDbError> { + let Some(key) = Key::ALL.into_iter().find(|key| key.id() == access_key_id) else { + return Ok(None); + }; + let session = key == Key::Session; + Ok(Some(StoredCredential { + secret_key: key.secret().to_owned(), + account_id: ACCOUNT_ID.to_owned(), + principal_name: if session { ROLE_NAME } else { USER_NAME }.to_owned(), + session_name: session.then(|| SESSION_NAME.to_owned()), + is_session: session, + session_token: key.token().map(str::to_owned), + is_active: true, + expires_at: session.then(|| time::OffsetDateTime::now_utc() + time::Duration::hours(1)), + })) + } +} + +/// Where a client sends: the URI it signs and the Host value its HTTP stack +/// sends for that URI. +#[derive(Clone, Copy, Debug)] +pub struct Endpoint { + pub uri: &'static str, + pub host: &'static str, +} + +/// A request as a client builds it before signing. +#[derive(Clone, Debug)] +pub struct Shape { + pub key: Key, + pub endpoint: Endpoint, + pub region: String, + /// The service the client signs for. + pub service: &'static str, + /// Seconds from the real clock to the time the client signs with. + pub clock_offset: i64, + /// Headers the client passes to the signer, names as the client writes + /// them. The signer adds `host` when the list has none, `x-amz-date`, and + /// the session token for a session key. + pub signed: Vec<(String, String)>, + /// Headers the client adds after signing. + pub unsigned: Vec<(String, String)>, + pub body: Vec, + /// Whether the signer adds a signed `x-amz-content-sha256` header. + pub content_sha256: bool, + /// Whether the signer leaves out the headers it skips by default + /// (`user-agent` and `x-amzn-trace-id` among them). Otherwise it signs + /// every header in `signed`. + pub sdk_exclusions: bool, + /// The secret the client signs with. + pub signing_secret: String, + /// The session token the client signs with and sends. + pub signing_token: Option, +} + +impl Shape { + /// A DynamoDB request: `x-amz-target`, the JSON 1.0 content type and the + /// content length go to the signer. + pub fn new(key: Key, endpoint: Endpoint, region: &str, target: &str, body: Vec) -> Self { + let mut shape = Self { + key, + endpoint, + region: region.to_owned(), + service: "dynamodb", + clock_offset: 0, + signed: vec![ + ("x-amz-target".to_owned(), target.to_owned()), + ( + "content-type".to_owned(), + "application/x-amz-json-1.0".to_owned(), + ), + ], + unsigned: Vec::new(), + body: Vec::new(), + content_sha256: false, + sdk_exclusions: false, + signing_secret: key.secret().to_owned(), + signing_token: key.token().map(str::to_owned), + }; + shape.set_body(body); + shape + } + + /// Replace the body and the `content-length` value that goes with it. + pub fn set_body(&mut self, body: Vec) { + let length = body.len().to_string(); + match self + .signed + .iter_mut() + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + { + Some((_, value)) => *value = length, + None => self.signed.push(("content-length".to_owned(), length)), + } + self.body = body; + } + + pub fn with_signed(mut self, name: &str, value: &str) -> Self { + self.signed.push((name.to_owned(), value.to_owned())); + self + } + + pub fn with_unsigned(mut self, name: &str, value: &str) -> Self { + self.unsigned.push((name.to_owned(), value.to_owned())); + self + } +} + +/// A request `aws-sigv4` signed, as `authenticate` receives it. +#[derive(Clone, Debug)] +pub struct Signed { + pub key: Key, + pub wire: Wire, + /// The time the client signed with. + pub time: SystemTime, + /// The canonical request `aws-sigv4` signed. + pub canonical_request: String, +} + +/// Sign `shape` with `aws-sigv4` and build the header lines and body +/// `authenticate` receives: the Host line the client's HTTP stack adds when +/// the client set none, the client's headers, the headers the signer +/// returned, then the headers added after signing. +pub fn sign(shape: &Shape) -> Signed { + let time = shift(SystemTime::now(), shape.clock_offset); + let identity = Credentials::new( + shape.key.id(), + shape.signing_secret.clone(), + shape.signing_token.clone(), + None, + "sigv4-differential", + ) + .into(); + let mut settings = SigningSettings::default(); + if shape.content_sha256 { + settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256; + } + if !shape.sdk_exclusions { + settings.excluded_headers = None; + } + let params = v4::SigningParams::builder() + .identity(&identity) + .region(&shape.region) + .name(shape.service) + .time(time) + .settings(settings) + .build() + .expect("the signing parameters are complete") + .into(); + let request = SignableRequest::new( + "POST", + shape.endpoint.uri, + shape + .signed + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())), + SignableBody::Bytes(&shape.body), + ) + .expect("aws-sigv4 takes the request"); + let (output, canonical_request) = capture::canonical_request(|| sdk_sign(request, ¶ms)); + let output = output.expect("aws-sigv4 signs the request"); + + let mut headers = Vec::new(); + if !shape + .signed + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("host")) + { + headers.push(("host".to_owned(), shape.endpoint.host.to_owned())); + } + headers.extend(shape.signed.iter().cloned()); + headers.extend( + output + .output() + .headers() + .map(|(name, value)| (name.to_owned(), value.to_owned())), + ); + headers.extend(shape.unsigned.iter().cloned()); + Signed { + key: shape.key, + wire: Wire { + headers, + body: shape.body.clone(), + }, + time, + canonical_request: canonical_request.expect("aws-sigv4 records its canonical request"), + } +} + +/// `time` moved by `seconds`, either way. +pub fn shift(time: SystemTime, seconds: i64) -> SystemTime { + let delta = Duration::from_secs(seconds.unsigned_abs()); + if seconds < 0 { + time - delta + } else { + time + delta + } +} + +/// `time` as `X-Amz-Date` carries it: `YYYYMMDDTHHMMSSZ` in UTC. +pub fn amz_date(time: SystemTime) -> String { + let time = time::OffsetDateTime::from(time); + format!( + "{:04}{:02}{:02}T{:02}{:02}{:02}Z", + time.year(), + u8::from(time.month()), + time.day(), + time.hour(), + time.minute(), + time.second(), + ) +} + +/// The header map and body `authenticate` receives. The headers are kept as +/// lines, and `header_map` builds the map from them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Wire { + /// Header lines in the order sent, names as the client wrote them. + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +impl Wire { + /// The header map `authenticate` receives: names lowercased, spaces and + /// tabs trimmed from both ends of each value as httparse trims them, and + /// a repeated name kept as several values in the order sent. + pub fn header_map(&self) -> HeaderMap { + let mut map = HeaderMap::new(); + for (name, value) in &self.headers { + map.append( + HeaderName::from_bytes(name.as_bytes()).expect("a valid header name"), + HeaderValue::from_str(value.trim_matches([' ', '\t'])) + .expect("a valid header value"), + ); + } + map + } + + /// The first value of `name`. + pub fn value(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(line, _)| line.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } + + /// Replace the first value of `name`. + pub fn replace(&mut self, name: &str, value: String) { + let line = self + .headers + .iter_mut() + .find(|(line, _)| line.eq_ignore_ascii_case(name)) + .unwrap_or_else(|| panic!("{name} is sent")); + line.1 = value; + } + + /// Remove every line of `name`. + pub fn remove(&mut self, name: &str) { + self.headers + .retain(|(line, _)| !line.eq_ignore_ascii_case(name)); + } + + pub fn authorization(&self) -> Authorization { + Authorization::parse( + self.value("authorization") + .expect("an authorization header"), + ) + } + + pub fn set_authorization(&mut self, authorization: &Authorization) { + self.replace("authorization", authorization.render()); + } + + /// Lowercased names of the header lines the signature does not cover, + /// `authorization` aside. + pub fn unsigned_names(&self) -> Vec { + let signed = self.authorization().signed_headers; + let mut names: Vec = self + .headers + .iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .filter(|name| name != "authorization" && !signed.contains(name)) + .collect(); + names.sort(); + names.dedup(); + names + } +} + +/// The parts of an `Authorization` value as `aws-sigv4` writes it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Authorization { + pub access_key_id: String, + pub date: String, + pub region: String, + pub service: String, + pub signed_headers: Vec, + pub signature: String, +} + +impl Authorization { + pub fn parse(value: &str) -> Self { + let rest = value + .strip_prefix("AWS4-HMAC-SHA256 Credential=") + .unwrap_or_else(|| panic!("a SigV4 authorization value: {value}")); + let (credential, rest) = rest + .split_once(", SignedHeaders=") + .unwrap_or_else(|| panic!("SignedHeaders in {value}")); + let (signed_headers, signature) = rest + .split_once(", Signature=") + .unwrap_or_else(|| panic!("Signature in {value}")); + let scope: Vec<&str> = credential.split('/').collect(); + assert!( + scope.len() == 5 && scope[4] == "aws4_request", + "a credential scope: {credential}" + ); + let parts = Self { + access_key_id: scope[0].to_owned(), + date: scope[1].to_owned(), + region: scope[2].to_owned(), + service: scope[3].to_owned(), + signed_headers: signed_headers.split(';').map(str::to_owned).collect(), + signature: signature.to_owned(), + }; + assert_eq!(parts.render(), value, "the parts render the value back"); + parts + } + + pub fn render(&self) -> String { + format!( + "AWS4-HMAC-SHA256 Credential={}/{}/{}/{}/aws4_request, SignedHeaders={}, Signature={}", + self.access_key_id, + self.date, + self.region, + self.service, + self.signed_headers.join(";"), + self.signature, + ) + } +} + +/// The authentication call the HTTP layer makes for a DynamoDB request. +pub fn authenticate(wire: &Wire) -> Result { + let provider = BuiltinAuthProvider::new(MemoryStore); + futures::executor::block_on(provider.authenticate(&wire.header_map(), &wire.body)) +} + +/// The canonical request the server builds for `wire`. +pub fn server_canonical_request(wire: &Wire) -> String { + let value = wire + .value("authorization") + .expect("an authorization header"); + let parsed = parse::parse_authorization(value).expect("the server parses the header"); + canonical::canonical_request( + "POST", + "/", + "", + &wire.header_map(), + &parsed.signed_headers, + &wire.body, + ) +} + +/// How the server refuses a request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + /// `InvalidSignatureException`: the signature, the credential scope or a + /// signed part does not match. + Signature, + /// `UnrecognizedClientException`: the clock check or the session token. + Client, + /// `IncompleteSignature`: a part the check needs is missing. + Incomplete, +} + +impl Refusal { + fn of(err: &DynamoDbError) -> Option { + match err { + DynamoDbError::InvalidSignatureException(_) => Some(Self::Signature), + DynamoDbError::UnrecognizedClientException(_) => Some(Self::Client), + DynamoDbError::IncompleteSignature(_) => Some(Self::Incomplete), + _ => None, + } + } +} + +// The start of the message each check refuses with. Checks that return the +// same error are told apart by their messages. + +/// The signature differs from the one the server computes. +pub const SIGNATURE_MISMATCH: &str = + "The request signature we calculated does not match the signature you provided."; +/// The credential scope names a service other than `dynamodb`. +pub const WRONG_SERVICE: &str = "Credential should be scoped to correct service: 'dynamodb'."; +/// The credential scope's date differs from the date in `X-Amz-Date`. +pub const SCOPE_DATE_MISMATCH: &str = + "Date in Credential scope does not match YYYYMMDD from ISO-8601 version of date from HTTP"; +/// `X-Amz-Date` is outside the skew window. +pub const EXPIRED: &str = "Signature expired:"; +/// The session token is missing or is not the key's. +pub const INVALID_TOKEN: &str = "The security token included in the request is invalid."; +/// `X-Amz-Date` is missing. +pub const MISSING_DATE: &str = "Authorization header requires existence of 'X-Amz-Date' header"; + +fn is_principal_of(identity: &AuthIdentity, key: Key) -> bool { + match (identity, key) { + ( + AuthIdentity::User { + account_id, + user_name, + }, + Key::LongTerm, + ) => account_id == ACCOUNT_ID && user_name == USER_NAME, + ( + AuthIdentity::RoleSession { + account_id, + role_name, + session_name, + }, + Key::Session, + ) => account_id == ACCOUNT_ID && role_name == ROLE_NAME && session_name == SESSION_NAME, + _ => false, + } +} + +/// Check that `signed` verifies as its key's principal and that the server's +/// canonical request for it is the one `aws-sigv4` signed. +pub fn check_accepted(signed: &Signed) -> Result<(), String> { + check_accepted_as(signed, &signed.wire) +} + +/// Check that `wire`, sent in place of `signed`'s request, verifies as its +/// key's principal and canonicalizes to the request `aws-sigv4` signed. +pub fn check_accepted_as(signed: &Signed, wire: &Wire) -> Result<(), String> { + match authenticate(wire) { + Ok(identity) if is_principal_of(&identity, signed.key) => {} + Ok(identity) => { + return Err(format!( + "verified as {identity:?}, not as the {:?} key", + signed.key + )); + } + Err(err) => { + return Err(format!( + "refused with {err:?}\n{}", + differences(&signed.canonical_request, wire) + )); + } + } + let server = server_canonical_request(wire); + if server == signed.canonical_request { + Ok(()) + } else { + Err(format!( + "canonical requests differ\n{}", + differences(&signed.canonical_request, wire) + )) + } +} + +/// Check that the server refuses `wire` with the error `expected` names and a +/// message starting with `message`, which names the check that refused it. +pub fn check_refused(wire: &Wire, expected: Refusal, message: &str) -> Result<(), String> { + match authenticate(wire) { + Ok(identity) => Err(format!( + "verified as {identity:?}, expected {expected:?} starting {message:?}\nheaders: {:?}", + wire.headers + )), + Err(err) if Refusal::of(&err) == Some(expected) && err.message().starts_with(message) => { + Ok(()) + } + Err(err) => Err(format!( + "refused with {err:?}, expected {expected:?} starting {message:?}" + )), + } +} + +/// The lines where the canonical request `aws-sigv4` signed and the one the +/// server builds for `wire` differ. +pub fn differences(sdk: &str, wire: &Wire) -> String { + let server = server_canonical_request(wire); + let sdk_lines: Vec<&str> = sdk.split('\n').collect(); + let server_lines: Vec<&str> = server.split('\n').collect(); + let mut out = String::new(); + for index in 0..sdk_lines.len().max(server_lines.len()) { + let left = sdk_lines.get(index).copied(); + let right = server_lines.get(index).copied(); + if left != right { + out.push_str(&format!( + "line {index}: aws-sigv4 {left:?}, server {right:?}\n" + )); + } + } + out +} + +/// Requests checked per category. Each test prints its counts when it ends; +/// `--nocapture` shows them. +#[derive(Debug, Default)] +pub struct Tally(BTreeMap<&'static str, usize>); + +impl Tally { + pub fn add(&mut self, category: &'static str) { + *self.0.entry(category).or_default() += 1; + } + + pub fn report(&self, test: &str) { + for (category, count) in &self.0 { + eprintln!("{test}: {category}: {count}"); + } + } +} diff --git a/crates/auth/tests/sigv4_differential/main.rs b/crates/auth/tests/sigv4_differential/main.rs new file mode 100644 index 000000000..aca5ba084 --- /dev/null +++ b/crates/auth/tests/sigv4_differential/main.rs @@ -0,0 +1,27 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! SigV4 decisions of the server's authentication path, checked against the +//! AWS SDK for Rust signer, `aws-sigv4`. +//! +//! Each request is signed by `aws-sigv4` and passed to +//! `BuiltinAuthProvider::authenticate`, the call the HTTP layer makes for a +//! DynamoDB request, over an in-memory `CredentialStore` that holds one +//! long-term key and one session key with its token. A request as the signer +//! produced it must verify, and the canonical request the server builds for it +//! must equal the one `aws-sigv4` signed. A request changed after signing in a +//! part the signature covers must be refused. +//! +//! Requests are signed with the current time, so the timestamp check runs +//! against the real clock with a margin of a minute on each side. Generated +//! requests come from a fixed seed, so every run checks the same shapes. Each +//! test prints how many requests it checked per category; `--nocapture` +//! shows the counts. + +mod accept; +mod capture; +mod corpus; +mod generated; +mod harness; +mod mutate; +mod reject; diff --git a/crates/auth/tests/sigv4_differential/mutate.rs b/crates/auth/tests/sigv4_differential/mutate.rs new file mode 100644 index 000000000..5617bc640 --- /dev/null +++ b/crates/auth/tests/sigv4_differential/mutate.rs @@ -0,0 +1,416 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Changes made to a request after `aws-sigv4` signed it, each with the +//! refusal it must get, and changes the signature does not cover. + +use std::time::SystemTime; + +use crate::harness::{ + Authorization, EXPIRED, INVALID_TOKEN, Key, MISSING_DATE, OTHER_TOKEN, Refusal, + SCOPE_DATE_MISMATCH, SIGNATURE_MISMATCH, Shape, Signed, Tally, WRONG_SERVICE, Wire, amz_date, + check_accepted, check_accepted_as, check_refused, shift, sign, +}; + +/// Seconds the server allows between `X-Amz-Date` and its clock. +pub const SKEW_WINDOW: i64 = 15 * 60; +/// Seconds kept between a case and the window's edge, so the time a slow run +/// spends between signing and checking cannot move a case across it. +pub const MARGIN: i64 = 60; + +const DAY: i64 = 24 * 60 * 60; + +/// A request changed after signing and the refusal it must get. +pub struct Mutant { + pub category: &'static str, + pub label: String, + pub wire: Wire, + pub expected: Refusal, + /// The start of the refusal message, which names the check that must + /// refuse the request. + pub message: &'static str, +} + +fn changed(wire: &Wire, change: impl FnOnce(&mut Wire)) -> Wire { + let mut wire = wire.clone(); + change(&mut wire); + wire +} + +/// `value` with its first character other than a space replaced. +fn first_character_replaced(value: &str) -> String { + let (index, current) = value + .char_indices() + .find(|(_, c)| *c != ' ') + .expect("a signed value has a character other than a space"); + let replacement = if current == 'a' { 'b' } else { 'a' }; + format!( + "{}{replacement}{}", + &value[..index], + &value[index + current.len_utf8()..] + ) +} + +/// Changes to `signed` after signing: each signed header's value changed and +/// each signed header dropped, one more name in `SignedHeaders`, one body +/// byte changed, `X-Amz-Date` moved, the credential scope's date, region and +/// service changed, and the signature truncated, extended, upper-cased or +/// with one digit changed. +pub fn mutants(signed: &Signed) -> Vec { + let mut out = Vec::new(); + header_mutants(signed, &mut out); + signed_headers_mutants(signed, &mut out); + out.extend(body_mutants(signed)); + date_mutants(signed, &mut out); + scope_mutants(signed, &mut out); + signature_mutants(signed, &mut out); + out +} + +fn header_mutants(signed: &Signed, out: &mut Vec) { + let wire = &signed.wire; + for name in wire.authorization().signed_headers { + let value = wire + .value(&name) + .unwrap_or_else(|| panic!("the signed header {name} is sent")) + .to_owned(); + let token = name == "x-amz-security-token"; + // X-Amz-Date is moved by whole seconds in `date_mutants` instead. + if name != "x-amz-date" { + let category = if token { + "session token changed after signing" + } else { + "signed header value changed" + }; + out.push(Mutant { + category, + label: format!("{name}: a character appended"), + wire: changed(wire, |wire| wire.replace(&name, format!("{value}x"))), + expected: Refusal::Signature, + message: SIGNATURE_MISMATCH, + }); + out.push(Mutant { + category, + label: format!("{name}: the first character replaced"), + wire: changed(wire, |wire| { + wire.replace(&name, first_character_replaced(&value)); + }), + expected: Refusal::Signature, + message: SIGNATURE_MISMATCH, + }); + } + let (category, expected, message) = match name.as_str() { + "x-amz-date" => ("signed header dropped", Refusal::Incomplete, MISSING_DATE), + "x-amz-security-token" => ( + "session token removed after signing", + Refusal::Signature, + SIGNATURE_MISMATCH, + ), + _ => ( + "signed header dropped", + Refusal::Signature, + SIGNATURE_MISMATCH, + ), + }; + out.push(Mutant { + category, + label: format!("{name}: dropped"), + wire: changed(wire, |wire| wire.remove(&name)), + expected, + message, + }); + } +} + +/// `SignedHeaders` names one more header: one the request does not carry, and +/// each one the request carries unsigned. +fn signed_headers_mutants(signed: &Signed, out: &mut Vec) { + let wire = &signed.wire; + let authorization = wire.authorization(); + let mut extras = vec!["x-amz-not-sent".to_owned()]; + extras.extend(wire.unsigned_names()); + for extra in extras { + let mut listed = authorization.clone(); + listed.signed_headers.push(extra.clone()); + listed.signed_headers.sort(); + out.push(Mutant { + category: "extra name in SignedHeaders", + label: format!("{extra} listed in SignedHeaders"), + wire: changed(wire, |wire| wire.set_authorization(&listed)), + expected: Refusal::Signature, + message: SIGNATURE_MISMATCH, + }); + } +} + +/// One body byte changed (the first, the middle and the last), or one byte +/// added to an empty body. When the request carries `x-amz-content-sha256` +/// it still names the hash of the body that was signed. +pub fn body_mutants(signed: &Signed) -> Vec { + let wire = &signed.wire; + let category = if wire.value("x-amz-content-sha256").is_some() { + "body byte changed, x-amz-content-sha256 kept" + } else { + "body byte changed" + }; + let length = wire.body.len(); + if length == 0 { + return vec![Mutant { + category, + label: "a byte added to the empty body".to_owned(), + wire: changed(wire, |wire| wire.body.push(b'{')), + expected: Refusal::Signature, + message: SIGNATURE_MISMATCH, + }]; + } + let mut positions = vec![0, length / 2, length - 1]; + positions.dedup(); + positions + .into_iter() + .map(|position| Mutant { + category, + label: format!("body byte {position} of {length} changed"), + wire: changed(wire, |wire| wire.body[position] ^= 0x01), + expected: Refusal::Signature, + message: SIGNATURE_MISMATCH, + }) + .collect() +} + +/// `X-Amz-Date` moved one second either way, and moved past the skew window +/// either way from the server's clock. +fn date_mutants(signed: &Signed, out: &mut Vec) { + let scope_date = signed.wire.authorization().date; + for seconds in [-1, 1] { + let date = amz_date(shift(signed.time, seconds)); + // Moved across midnight UTC, X-Amz-Date is on a different day from + // the credential scope, and the scope date check refuses the request + // before the signature is compared. + let message = if date[..8] == scope_date { + SIGNATURE_MISMATCH + } else { + SCOPE_DATE_MISMATCH + }; + out.push(Mutant { + category: "x-amz-date moved one second", + label: format!("x-amz-date moved {seconds} s to {date}"), + wire: changed(&signed.wire, |wire| { + wire.replace("x-amz-date", date.clone()) + }), + expected: Refusal::Signature, + message, + }); + } + let now = SystemTime::now(); + for seconds in [-(SKEW_WINDOW + MARGIN), SKEW_WINDOW + MARGIN] { + let date = amz_date(shift(now, seconds)); + out.push(Mutant { + category: "x-amz-date moved past the skew window", + label: format!("x-amz-date set {seconds} s from now, to {date}"), + wire: changed(&signed.wire, |wire| { + wire.replace("x-amz-date", date.clone()) + }), + expected: Refusal::Client, + message: EXPIRED, + }); + } +} + +/// `signed` with its `Authorization` value changed by `change`, refused with +/// `InvalidSignatureException` and a message starting with `message`. +fn authorization_mutant( + signed: &Signed, + category: &'static str, + label: String, + message: &'static str, + change: impl FnOnce(&mut Authorization), +) -> Mutant { + let mut parts = signed.wire.authorization(); + change(&mut parts); + Mutant { + category, + label, + wire: changed(&signed.wire, |wire| wire.set_authorization(&parts)), + expected: Refusal::Signature, + message, + } +} + +/// The credential scope's date, region or service changed. +/// +/// The server checks the scope's service and date before it compares the +/// signature. It has no check of its own for the region, so a changed region +/// is refused by the signature. `aws-sigv4` takes the scope date and +/// `X-Amz-Date` from one time, so a changed scope date also breaks the +/// signature, and the scope date check refuses it first. +fn scope_mutants(signed: &Signed, out: &mut Vec) { + for days in [-1, 1] { + let date = amz_date(shift(signed.time, days * DAY))[..8].to_owned(); + out.push(authorization_mutant( + signed, + "credential scope date changed, refused by the scope date check", + format!("scope date {date}"), + SCOPE_DATE_MISMATCH, + |parts| parts.date = date, + )); + } + let region = if signed.wire.authorization().region == "us-west-2" { + "us-east-1" + } else { + "us-west-2" + }; + out.push(authorization_mutant( + signed, + "credential scope region changed, refused by the signature", + format!("scope region {region}"), + SIGNATURE_MISMATCH, + |parts| region.clone_into(&mut parts.region), + )); + for service in ["s3", "streams.dynamodb", "DynamoDB"] { + out.push(authorization_mutant( + signed, + "credential scope service changed", + format!("scope service {service}"), + WRONG_SERVICE, + |parts| service.clone_into(&mut parts.service), + )); + } +} + +/// The signature truncated, extended, upper-cased or with one digit changed. +fn signature_mutants(signed: &Signed, out: &mut Vec) { + let authorization = signed.wire.authorization(); + let signature = &authorization.signature; + let last = signature.chars().last().expect("a signature"); + let mut variants = vec![ + ( + "signature truncated", + signature[..signature.len() - 1].to_owned(), + ), + ("signature extended", format!("{signature}0")), + ( + "signature digit changed", + format!( + "{}{}", + &signature[..signature.len() - 1], + if last == '0' { '1' } else { '0' } + ), + ), + ]; + let upper = signature.to_ascii_uppercase(); + // A signature of digits alone reads the same upper-cased. + if upper != *signature { + variants.push(("signature upper-cased", upper)); + } + for (category, changed_signature) in variants { + out.push(authorization_mutant( + signed, + category, + format!("signature {changed_signature}"), + SIGNATURE_MISMATCH, + |parts| parts.signature = changed_signature, + )); + } +} + +/// `shape` signed with credentials the store does not hold for its key, or +/// at a time outside the skew window. +pub fn resigned(shape: &Shape) -> Vec { + let mut variants = Vec::new(); + let mut other_secret = shape.clone(); + other_secret.signing_secret = shape.key.other_secret().to_owned(); + variants.push(( + "wrong secret", + "signed with another secret", + other_secret, + Refusal::Signature, + SIGNATURE_MISMATCH, + )); + for offset in [-(SKEW_WINDOW + MARGIN), SKEW_WINDOW + MARGIN] { + let mut outside = shape.clone(); + outside.clock_offset = offset; + variants.push(( + "signed outside the skew window", + "signed with a clock outside the window", + outside, + Refusal::Client, + EXPIRED, + )); + } + if shape.key == Key::Session { + let mut other_token = shape.clone(); + other_token.signing_token = Some(OTHER_TOKEN.to_owned()); + variants.push(( + "session token changed before signing", + "signed and sent with another token", + other_token, + Refusal::Client, + INVALID_TOKEN, + )); + let mut no_token = shape.clone(); + no_token.signing_token = None; + variants.push(( + "session token removed before signing", + "signed and sent without the token", + no_token, + Refusal::Client, + INVALID_TOKEN, + )); + } + variants + .into_iter() + .map(|(category, label, shape, expected, message)| Mutant { + category, + label: label.to_owned(), + wire: sign(&shape).wire, + expected, + message, + }) + .collect() +} + +/// Changes to headers the signature does not cover: each unsigned header +/// changed and dropped, and one header added. +pub fn unsigned_changes(signed: &Signed) -> Vec<(&'static str, Wire)> { + let wire = &signed.wire; + let mut out = Vec::new(); + for name in wire.unsigned_names() { + out.push(( + "unsigned header changed after signing", + changed(wire, |wire| { + wire.replace(&name, "changed after signing".to_owned()); + }), + )); + out.push(( + "unsigned header dropped after signing", + changed(wire, |wire| wire.remove(&name)), + )); + } + out.push(( + "unsigned header added after signing", + changed(wire, |wire| { + wire.headers + .push(("x-added-after-signing".to_owned(), "1".to_owned())); + }), + )); + out +} + +/// Sign `shape` and check that it verifies, that changes to its unsigned +/// headers still verify, and that each request `mutants` and `resigned` make +/// from it is refused by the check its message names. +pub fn check_shape(shape: &Shape, tally: &mut Tally, category: &'static str) -> Result<(), String> { + let signed = sign(shape); + check_accepted(&signed)?; + tally.add(category); + for (category, wire) in unsigned_changes(&signed) { + check_accepted_as(&signed, &wire).map_err(|err| format!("{category}: {err}"))?; + tally.add(category); + } + for mutant in mutants(&signed).into_iter().chain(resigned(shape)) { + check_refused(&mutant.wire, mutant.expected, mutant.message) + .map_err(|err| format!("{}: {err}", mutant.label))?; + tally.add(mutant.category); + } + Ok(()) +} diff --git a/crates/auth/tests/sigv4_differential/reject.rs b/crates/auth/tests/sigv4_differential/reject.rs new file mode 100644 index 000000000..36f59bbb5 --- /dev/null +++ b/crates/auth/tests/sigv4_differential/reject.rs @@ -0,0 +1,154 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! A request changed after `aws-sigv4` signed it, in a part the signature +//! covers, or signed for another service, is refused. + +use extenddb_auth::sigv4::{parse, verify}; +use extenddb_core::error::DynamoDbError; + +use crate::corpus; +use crate::harness::{ + Key, Refusal, SIGNATURE_MISMATCH, Tally, WRONG_SERVICE, check_accepted, check_refused, + server_canonical_request, sign, +}; +use crate::mutate; + +/// Every change `mutate` makes, to each client shape, with and without +/// `x-amz-content-sha256`, for both keys. +#[test] +fn every_change_after_signing_is_refused() { + let mut tally = Tally::default(); + for key in Key::ALL { + for (shape_name, shape) in corpus::client_shapes(key, corpus::PUT_ITEM) { + for content_sha256 in [false, true] { + let mut shape = shape.clone(); + shape.content_sha256 = content_sha256; + if let Err(err) = mutate::check_shape(&shape, &mut tally, "accepted: client shape") + { + panic!("{key:?}, {shape_name}, sha256 {content_sha256}: {err}"); + } + } + } + } + tally.report("every_change_after_signing_is_refused"); +} + +/// One body byte changed in every fixed body, with and without +/// `x-amz-content-sha256` carrying the hash of the body that was signed. +#[test] +fn one_body_byte_changed_is_refused() { + let mut tally = Tally::default(); + for key in Key::ALL { + for (body_name, body) in corpus::small_bodies() + .into_iter() + .chain(corpus::large_bodies()) + { + for content_sha256 in [false, true] { + let mut shape = corpus::base(key, body.clone()); + shape.content_sha256 = content_sha256; + let signed = sign(&shape); + for mutant in mutate::body_mutants(&signed) { + if let Err(err) = check_refused(&mutant.wire, mutant.expected, mutant.message) { + panic!( + "{key:?}, {body_name}, sha256 {content_sha256}, {}: {err}", + mutant.label + ); + } + tally.add(mutant.category); + } + } + } + } + tally.report("one_body_byte_changed_is_refused"); +} + +/// `authenticate` hashes and verifies a 16 MiB body, and refuses it with one +/// byte changed. The HTTP layer enforces the server's body size limit before +/// `authenticate` runs, so this test does not check that limit. +#[test] +fn a_16_mib_body_is_hashed_and_verified_and_one_byte_changed_is_refused() { + let mut shape = corpus::base(Key::LongTerm, corpus::sized_item(corpus::BODY_LIMIT, "a")); + shape.content_sha256 = true; + let signed = sign(&shape); + check_accepted(&signed).unwrap(); + let mut changed = signed.wire.clone(); + let middle = changed.body.len() / 2; + changed.body[middle] ^= 0x01; + check_refused(&changed, Refusal::Signature, SIGNATURE_MISMATCH).unwrap(); + eprintln!( + "a_16_mib_body_is_hashed_and_verified_and_one_byte_changed_is_refused: \ + accepted: 16 MiB body: 1, refused: one byte changed: 1" + ); +} + +/// A request `aws-sigv4` signed for another service. Its signature matches +/// the credential scope it carries, so the service check is the only check +/// that refuses it. +#[test] +fn signed_for_another_service_is_refused_by_the_service_check() { + let mut tally = Tally::default(); + for key in Key::ALL { + let mut shape = corpus::base(key, corpus::PUT_ITEM.to_vec()); + shape.service = "s3"; + let signed = sign(&shape); + assert_eq!( + server_canonical_request(&signed.wire), + signed.canonical_request, + "{key:?}: the server builds the canonical request aws-sigv4 signed" + ); + if let Err(err) = check_refused(&signed.wire, Refusal::Signature, WRONG_SERVICE) { + panic!("{key:?}, signed for s3: {err}"); + } + tally.add("signed for another service"); + } + tally.report("signed_for_another_service_is_refused_by_the_service_check"); +} + +/// The HTTP layer routes only POST requests for `/` to `authenticate`, which +/// passes POST, `/` and an empty query string to `verify_signature`. These +/// calls pass other values to `verify_signature` to show the signature covers +/// the method, the path and the query string. +#[test] +fn method_path_and_query_string_are_covered_by_the_signature() { + let mut tally = Tally::default(); + for key in Key::ALL { + for (shape_name, shape) in corpus::client_shapes(key, corpus::PUT_ITEM) { + let signed = sign(&shape); + let header = signed.wire.value("authorization").unwrap(); + let parsed = parse::parse_authorization(header).unwrap(); + let headers = signed.wire.header_map(); + let verify = |method: &str, path: &str, query: &str| { + verify::verify_signature( + &parsed, + key.secret(), + method, + path, + query, + &headers, + &signed.wire.body, + ) + }; + verify("POST", "/", "").unwrap_or_else(|err| panic!("{shape_name}: {err:?}")); + let changes = [ + ("method changed", "GET", "/", ""), + ("method changed", "PUT", "/", ""), + ("path changed", "POST", "/x", ""), + ("path changed", "POST", "//", ""), + ("query string changed", "POST", "/", "a=b"), + ("query string changed", "POST", "/", "Action=ListTables"), + ]; + for (category, method, path, query) in changes { + match verify(method, path, query) { + Err(DynamoDbError::InvalidSignatureException(message)) + if message.starts_with(SIGNATURE_MISMATCH) => + { + tally.add(category); + } + other => panic!("{shape_name}, {method} {path}?{query}: {other:?}"), + } + } + } + } + tally.report("method_path_and_query_string_are_covered_by_the_signature"); +} From 062f43932256c9cd8406a2fdec4f1c9b76f42089 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:44:26 +0000 Subject: [PATCH 2/2] test(auth): require the time check for requests outside the skew window The skew window cases expected UnrecognizedClientException and the server's own message. DynamoDB answers InvalidSignatureException (#805). Its message starts "Signature expired:" for a past date and "Signature not yet current:" for a future one. Each of the four requests per shape must now be refused by the time check. Either error passes, with any message except those of the scope date check and the signature comparison. A request with X-Amz-Date moved after signing also breaks the signature, so it still checks that the time check runs first, as it does on DynamoDB. Its credential scope date moves with X-Amz-Date. The scope date check then passes when the move crosses midnight UTC. --- .../auth/tests/sigv4_differential/harness.rs | 32 +++++- .../auth/tests/sigv4_differential/mutate.rs | 100 +++++++++++------- 2 files changed, 92 insertions(+), 40 deletions(-) diff --git a/crates/auth/tests/sigv4_differential/harness.rs b/crates/auth/tests/sigv4_differential/harness.rs index c09c1667e..fb6ea1ddc 100644 --- a/crates/auth/tests/sigv4_differential/harness.rs +++ b/crates/auth/tests/sigv4_differential/harness.rs @@ -453,7 +453,8 @@ pub enum Refusal { /// `InvalidSignatureException`: the signature, the credential scope or a /// signed part does not match. Signature, - /// `UnrecognizedClientException`: the clock check or the session token. + /// `UnrecognizedClientException`: the session token is missing or is not + /// the key's, or `X-Amz-Date` is outside the skew window (#805). Client, /// `IncompleteSignature`: a part the check needs is missing. Incomplete, @@ -481,8 +482,6 @@ pub const WRONG_SERVICE: &str = "Credential should be scoped to correct service: /// The credential scope's date differs from the date in `X-Amz-Date`. pub const SCOPE_DATE_MISMATCH: &str = "Date in Credential scope does not match YYYYMMDD from ISO-8601 version of date from HTTP"; -/// `X-Amz-Date` is outside the skew window. -pub const EXPIRED: &str = "Signature expired:"; /// The session token is missing or is not the key's. pub const INVALID_TOKEN: &str = "The security token included in the request is invalid."; /// `X-Amz-Date` is missing. @@ -561,6 +560,33 @@ pub fn check_refused(wire: &Wire, expected: Refusal, message: &str) -> Result<() } } +/// Check that the server refuses `wire` with `InvalidSignatureException` or +/// `UnrecognizedClientException`, with any message but those of the scope +/// date check and the signature comparison, so neither of those checks +/// refused it. +pub fn check_refused_by_the_time_check(wire: &Wire) -> Result<(), String> { + let err = match authenticate(wire) { + Ok(identity) => { + return Err(format!( + "verified as {identity:?}, expected a refusal\nheaders: {:?}", + wire.headers + )); + } + Err(err) => err, + }; + let message = err.message(); + let later_check = + message.starts_with(SIGNATURE_MISMATCH) || message.starts_with(SCOPE_DATE_MISMATCH); + match Refusal::of(&err) { + Some(Refusal::Signature | Refusal::Client) if !later_check => Ok(()), + _ => Err(format!( + "refused with {err:?}, expected InvalidSignatureException or \ + UnrecognizedClientException from the time check, before the \ + scope date check and the signature comparison run" + )), + } +} + /// The lines where the canonical request `aws-sigv4` signed and the one the /// server builds for `wire` differ. pub fn differences(sdk: &str, wire: &Wire) -> String { diff --git a/crates/auth/tests/sigv4_differential/mutate.rs b/crates/auth/tests/sigv4_differential/mutate.rs index 5617bc640..5f1f21188 100644 --- a/crates/auth/tests/sigv4_differential/mutate.rs +++ b/crates/auth/tests/sigv4_differential/mutate.rs @@ -2,14 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 //! Changes made to a request after `aws-sigv4` signed it, each with the -//! refusal it must get, and changes the signature does not cover. +//! refusal it must get; requests signed with credentials the store does not +//! hold for the key; requests dated outside the skew window; and changes the +//! signature does not cover. use std::time::SystemTime; use crate::harness::{ - Authorization, EXPIRED, INVALID_TOKEN, Key, MISSING_DATE, OTHER_TOKEN, Refusal, - SCOPE_DATE_MISMATCH, SIGNATURE_MISMATCH, Shape, Signed, Tally, WRONG_SERVICE, Wire, amz_date, - check_accepted, check_accepted_as, check_refused, shift, sign, + Authorization, INVALID_TOKEN, Key, MISSING_DATE, OTHER_TOKEN, Refusal, SCOPE_DATE_MISMATCH, + SIGNATURE_MISMATCH, Shape, Signed, Tally, WRONG_SERVICE, Wire, amz_date, check_accepted, + check_accepted_as, check_refused, check_refused_by_the_time_check, shift, sign, }; /// Seconds the server allows between `X-Amz-Date` and its clock. @@ -53,9 +55,9 @@ fn first_character_replaced(value: &str) -> String { /// Changes to `signed` after signing: each signed header's value changed and /// each signed header dropped, one more name in `SignedHeaders`, one body -/// byte changed, `X-Amz-Date` moved, the credential scope's date, region and -/// service changed, and the signature truncated, extended, upper-cased or -/// with one digit changed. +/// byte changed, `X-Amz-Date` moved one second, the credential scope's date, +/// region and service changed, and the signature truncated, extended, +/// upper-cased or with one digit changed. pub fn mutants(signed: &Signed) -> Vec { let mut out = Vec::new(); header_mutants(signed, &mut out); @@ -177,8 +179,7 @@ pub fn body_mutants(signed: &Signed) -> Vec { .collect() } -/// `X-Amz-Date` moved one second either way, and moved past the skew window -/// either way from the server's clock. +/// `X-Amz-Date` moved one second either way. fn date_mutants(signed: &Signed, out: &mut Vec) { let scope_date = signed.wire.authorization().date; for seconds in [-1, 1] { @@ -201,19 +202,6 @@ fn date_mutants(signed: &Signed, out: &mut Vec) { message, }); } - let now = SystemTime::now(); - for seconds in [-(SKEW_WINDOW + MARGIN), SKEW_WINDOW + MARGIN] { - let date = amz_date(shift(now, seconds)); - out.push(Mutant { - category: "x-amz-date moved past the skew window", - label: format!("x-amz-date set {seconds} s from now, to {date}"), - wire: changed(&signed.wire, |wire| { - wire.replace("x-amz-date", date.clone()) - }), - expected: Refusal::Client, - message: EXPIRED, - }); - } } /// `signed` with its `Authorization` value changed by `change`, refused with @@ -313,8 +301,7 @@ fn signature_mutants(signed: &Signed, out: &mut Vec) { } } -/// `shape` signed with credentials the store does not hold for its key, or -/// at a time outside the skew window. +/// `shape` signed with credentials the store does not hold for its key. pub fn resigned(shape: &Shape) -> Vec { let mut variants = Vec::new(); let mut other_secret = shape.clone(); @@ -326,17 +313,6 @@ pub fn resigned(shape: &Shape) -> Vec { Refusal::Signature, SIGNATURE_MISMATCH, )); - for offset in [-(SKEW_WINDOW + MARGIN), SKEW_WINDOW + MARGIN] { - let mut outside = shape.clone(); - outside.clock_offset = offset; - variants.push(( - "signed outside the skew window", - "signed with a clock outside the window", - outside, - Refusal::Client, - EXPIRED, - )); - } if shape.key == Key::Session { let mut other_token = shape.clone(); other_token.signing_token = Some(OTHER_TOKEN.to_owned()); @@ -369,6 +345,51 @@ pub fn resigned(shape: &Shape) -> Vec { .collect() } +/// Requests dated `MARGIN` seconds outside the skew window, before and after +/// the server's clock: `signed` with `X-Amz-Date` moved there after signing, +/// and `shape` signed with a clock there. +/// +/// `DynamoDB` refuses all four with `InvalidSignatureException` and a message +/// that starts "Signature expired:" before the window or "Signature not yet +/// current:" after it. It checks the time before it compares the signature, +/// so a moved `X-Amz-Date`, which also breaks the signature, gets the same +/// answer. The server answers `UnrecognizedClientException` with its own +/// message (#805). Each request must be refused by the time check, with +/// either error and any message. +/// +/// The credential scope date moves with `X-Amz-Date`, so the scope date +/// check passes when the move crosses midnight UTC. The order of that check +/// and the time check was not measured on `DynamoDB`, and these requests do +/// not test it. +pub fn outside_the_skew_window( + signed: &Signed, + shape: &Shape, +) -> Vec<(&'static str, String, Wire)> { + let now = SystemTime::now(); + let mut out = Vec::new(); + for seconds in [-(SKEW_WINDOW + MARGIN), SKEW_WINDOW + MARGIN] { + let date = amz_date(shift(now, seconds)); + let mut scope = signed.wire.authorization(); + date[..8].clone_into(&mut scope.date); + out.push(( + "x-amz-date moved past the skew window", + format!("x-amz-date set {seconds} s from now, to {date}"), + changed(&signed.wire, |wire| { + wire.replace("x-amz-date", date.clone()); + wire.set_authorization(&scope); + }), + )); + let mut outside = shape.clone(); + outside.clock_offset = seconds; + out.push(( + "signed outside the skew window", + format!("signed with a clock {seconds} s from now"), + sign(&outside).wire, + )); + } + out +} + /// Changes to headers the signature does not cover: each unsigned header /// changed and dropped, and one header added. pub fn unsigned_changes(signed: &Signed) -> Vec<(&'static str, Wire)> { @@ -397,8 +418,9 @@ pub fn unsigned_changes(signed: &Signed) -> Vec<(&'static str, Wire)> { } /// Sign `shape` and check that it verifies, that changes to its unsigned -/// headers still verify, and that each request `mutants` and `resigned` make -/// from it is refused by the check its message names. +/// headers still verify, that each request `mutants` and `resigned` make from +/// it is refused by the check its message names, and that the time check +/// refuses each request `outside_the_skew_window` makes from it. pub fn check_shape(shape: &Shape, tally: &mut Tally, category: &'static str) -> Result<(), String> { let signed = sign(shape); check_accepted(&signed)?; @@ -412,5 +434,9 @@ pub fn check_shape(shape: &Shape, tally: &mut Tally, category: &'static str) -> .map_err(|err| format!("{}: {err}", mutant.label))?; tally.add(mutant.category); } + for (category, label, wire) in outside_the_skew_window(&signed, shape) { + check_refused_by_the_time_check(&wire).map_err(|err| format!("{label}: {err}"))?; + tally.add(category); + } Ok(()) }