diff --git a/Cargo.lock b/Cargo.lock index 6cb6f19e4..fa4371977 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1683,12 +1683,15 @@ name = "extenddb-auth" version = "0.1.14" dependencies = [ "async-trait", + "aws-credential-types", "aws-lc-rs", + "aws-sigv4", "axum", "extenddb-cache", "extenddb-core", "futures", "hex", + "proptest", "serde_json", "sha2 0.10.9", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 97e64e9e0..2ee723c20 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 d9a141235..a71126ef2 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..fb6ea1ddc --- /dev/null +++ b/crates/auth/tests/sigv4_differential/harness.rs @@ -0,0 +1,624 @@ +// 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 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, +} + +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"; +/// 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:?}" + )), + } +} + +/// 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 { + 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..5f1f21188 --- /dev/null +++ b/crates/auth/tests/sigv4_differential/mutate.rs @@ -0,0 +1,442 @@ +// 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; 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, 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. +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 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); + 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. +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, + }); + } +} + +/// `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. +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, + )); + 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() +} + +/// 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)> { + 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, 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)?; + 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); + } + 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(()) +} 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"); +}