use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use p256::ecdsa::signature::Verifier;
use p256::ecdsa::{Signature, VerifyingKey};
use thiserror::Error;
pub const SIGV4A_ALGORITHM: &str = "AWS4-ECDSA-P256-SHA256";
pub const REGION_SET_HEADER: &str = "x-amz-region-set";
#[derive(Debug, Error)]
pub enum SigV4aError {
#[error("malformed ECDSA-P256 signature: {0}")]
BadSignature(String),
#[error("ECDSA-P256 signature verification failed")]
VerificationFailed,
#[error("region '{requested}' not in signed region-set '{set}'")]
RegionMismatch { requested: String, set: String },
#[error("invalid P-256 public key in '{path}': {reason}")]
BadPublicKey { path: String, reason: String },
#[error("credential store I/O for '{path}': {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("missing x-amz-date header (required for SigV4a)")]
MissingXAmzDate,
#[error("x-amz-date format must be YYYYMMDDTHHMMSSZ")]
InvalidDateFormat,
#[error("request time too skewed: {drift_secs}s drift, tolerance {tolerance_secs}s")]
RequestTimeTooSkewed {
drift_secs: i64,
tolerance_secs: i64,
},
#[error("x-amz-date date does not match credential scope date")]
DateScopeMismatch,
#[error("x-amz-date must be in SignedHeaders list")]
XAmzDateNotSigned,
#[error("credential scope must end with /aws4_request")]
InvalidTerminator,
#[error("credential scope service must be 's3', got {got:?}")]
WrongService { got: String },
#[error("credential scope must have 4 components separated by '/'")]
InvalidCredentialScope,
#[error(
"duplicate signed header '{header}' (HTTP/1.1 forbids duplicates of host / x-amz-date)"
)]
DuplicateSignedHeader { header: String },
}
pub struct CanonicalRequest<'a> {
bytes: &'a [u8],
}
impl<'a> CanonicalRequest<'a> {
#[must_use]
pub fn new(bytes: &'a [u8]) -> Self {
Self { bytes }
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
self.bytes
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SigV4aAuth {
pub access_key_id: String,
pub date: String,
pub service: String,
pub terminator: String,
pub credential_scope: Vec<String>,
pub signed_headers: Vec<String>,
pub signature_der: Vec<u8>,
}
pub fn parse_authorization_header(header: &str) -> Result<SigV4aAuth, SigV4aError> {
let rest = header
.trim()
.strip_prefix(SIGV4A_ALGORITHM)
.ok_or_else(|| SigV4aError::BadSignature("not a SigV4a Authorization header".into()))?;
let rest = rest.trim_start();
let mut credential: Option<&str> = None;
let mut signed_headers: Option<&str> = None;
let mut signature: Option<&str> = None;
for part in rest.split(',') {
let part = part.trim();
if let Some(v) = part.strip_prefix("Credential=") {
credential = Some(v);
} else if let Some(v) = part.strip_prefix("SignedHeaders=") {
signed_headers = Some(v);
} else if let Some(v) = part.strip_prefix("Signature=") {
signature = Some(v);
}
}
let cred =
credential.ok_or_else(|| SigV4aError::BadSignature("missing Credential= field".into()))?;
let scope_parts: Vec<&str> = cred.split('/').collect();
if scope_parts.len() != 4 {
return Err(SigV4aError::InvalidCredentialScope);
}
let access_key_id = scope_parts[0].to_owned();
let date = scope_parts[1].to_owned();
let service = scope_parts[2].to_owned();
let terminator = scope_parts[3].to_owned();
if access_key_id.is_empty() {
return Err(SigV4aError::InvalidCredentialScope);
}
if date.len() != 8 || !date.chars().all(|c| c.is_ascii_digit()) {
return Err(SigV4aError::InvalidDateFormat);
}
if service != "s3" {
return Err(SigV4aError::WrongService { got: service });
}
if terminator != "aws4_request" {
return Err(SigV4aError::InvalidTerminator);
}
let credential_scope: Vec<String> = scope_parts[1..].iter().map(|s| (*s).to_owned()).collect();
let signed_headers_raw = signed_headers
.ok_or_else(|| SigV4aError::BadSignature("missing SignedHeaders= field".into()))?;
let signed_headers: Vec<String> = signed_headers_raw
.split(';')
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect();
if signed_headers.is_empty() {
return Err(SigV4aError::BadSignature(
"empty SignedHeaders= list".into(),
));
}
let signature_hex =
signature.ok_or_else(|| SigV4aError::BadSignature("missing Signature= field".into()))?;
let signature_der = decode_hex(signature_hex)
.ok_or_else(|| SigV4aError::BadSignature("non-hex Signature= value".into()))?;
Ok(SigV4aAuth {
access_key_id,
date,
service,
terminator,
credential_scope,
signed_headers,
signature_der,
})
}
pub fn detect<B>(req: &http::Request<B>) -> bool {
let h = req.headers();
if let Some(auth) = h
.get(http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
&& auth.trim_start().starts_with(SIGV4A_ALGORITHM)
{
return true;
}
h.contains_key(REGION_SET_HEADER)
}
pub fn verify(
request_bytes: &CanonicalRequest<'_>,
signature: &[u8],
pubkey: &VerifyingKey,
region_set: &str,
requested_region: &str,
) -> Result<(), SigV4aError> {
if !region_set_contains(region_set, requested_region) {
return Err(SigV4aError::RegionMismatch {
requested: requested_region.to_owned(),
set: region_set.to_owned(),
});
}
let sig =
Signature::from_der(signature).map_err(|e| SigV4aError::BadSignature(e.to_string()))?;
pubkey
.verify(request_bytes.as_bytes(), &sig)
.map_err(|_| SigV4aError::VerificationFailed)
}
#[allow(clippy::too_many_arguments)]
pub fn verify_request(
parsed: &SigV4aAuth,
headers: &HashMap<String, String>,
canonical_request_bytes: &[u8],
pubkey: &VerifyingKey,
region_set: &str,
requested_region: &str,
now: chrono::DateTime<chrono::Utc>,
skew_tolerance: chrono::Duration,
) -> Result<(), SigV4aError> {
let x_amz_date = lookup_header_ci(headers, "x-amz-date").ok_or(SigV4aError::MissingXAmzDate)?;
if x_amz_date.len() != 16 || !x_amz_date.ends_with('Z') {
return Err(SigV4aError::InvalidDateFormat);
}
let request_time = chrono::NaiveDateTime::parse_from_str(x_amz_date, "%Y%m%dT%H%M%SZ")
.map_err(|_| SigV4aError::InvalidDateFormat)?
.and_utc();
let drift = (now - request_time).abs();
if drift > skew_tolerance {
return Err(SigV4aError::RequestTimeTooSkewed {
drift_secs: drift.num_seconds(),
tolerance_secs: skew_tolerance.num_seconds(),
});
}
if x_amz_date[..8] != parsed.date {
return Err(SigV4aError::DateScopeMismatch);
}
if !parsed
.signed_headers
.iter()
.any(|h| h.eq_ignore_ascii_case("x-amz-date"))
{
return Err(SigV4aError::XAmzDateNotSigned);
}
verify(
&CanonicalRequest::new(canonical_request_bytes),
&parsed.signature_der,
pubkey,
region_set,
requested_region,
)
}
fn lookup_header_ci<'a>(headers: &'a HashMap<String, String>, name: &str) -> Option<&'a String> {
let needle = name.to_ascii_lowercase();
if let Some(v) = headers.get(&needle) {
return Some(v);
}
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v)
}
#[must_use]
pub fn region_set_contains(region_set: &str, region: &str) -> bool {
if region.is_empty() {
return false;
}
region_set
.split(',')
.map(str::trim)
.any(|item| item == "*" || item.eq_ignore_ascii_case(region))
}
#[derive(Debug, Default, Clone)]
pub struct SigV4aCredentialStore {
keys: Arc<HashMap<String, VerifyingKey>>,
}
pub type SharedSigV4aCredentialStore = Arc<SigV4aCredentialStore>;
impl SigV4aCredentialStore {
#[must_use]
pub fn from_map(map: HashMap<String, VerifyingKey>) -> Self {
Self {
keys: Arc::new(map),
}
}
#[must_use]
pub fn get(&self, access_key_id: &str) -> Option<&VerifyingKey> {
self.keys.get(access_key_id)
}
#[must_use]
pub fn len(&self) -> usize {
self.keys.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
pub fn load_dir(dir: impl AsRef<Path>) -> Result<Self, SigV4aError> {
let dir = dir.as_ref();
let read = fs::read_dir(dir).map_err(|source| SigV4aError::Io {
path: dir.display().to_string(),
source,
})?;
let mut keys: HashMap<String, VerifyingKey> = HashMap::new();
for entry in read {
let entry = entry.map_err(|source| SigV4aError::Io {
path: dir.display().to_string(),
source,
})?;
let path: PathBuf = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("pem") {
continue;
}
let access_key_id = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) if !s.is_empty() => s.to_owned(),
_ => continue,
};
let pem = fs::read_to_string(&path).map_err(|source| SigV4aError::Io {
path: path.display().to_string(),
source,
})?;
let key =
parse_p256_public_key_pem(&pem).map_err(|reason| SigV4aError::BadPublicKey {
path: path.display().to_string(),
reason,
})?;
keys.insert(access_key_id, key);
}
Ok(Self {
keys: Arc::new(keys),
})
}
}
fn parse_p256_public_key_pem(pem: &str) -> Result<VerifyingKey, String> {
use p256::pkcs8::DecodePublicKey;
VerifyingKey::from_public_key_pem(pem.trim()).map_err(|e| e.to_string())
}
fn decode_hex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(s.len() / 2);
let mut i = 0;
while i < bytes.len() {
let hi = nibble(bytes[i])?;
let lo = nibble(bytes[i + 1])?;
out.push((hi << 4) | lo);
i += 2;
}
Some(out)
}
fn nibble(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use p256::ecdsa::SigningKey;
use p256::ecdsa::signature::Signer;
use rand::rngs::OsRng;
fn lower_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
#[test]
fn parse_authorization_header_aws_sample() {
let sig_hex = "30440220".to_owned() + &"ab".repeat(32) + &"cd".repeat(34);
let sig_hex = &sig_hex[..sig_hex.len() & !1];
let header = format!(
"AWS4-ECDSA-P256-SHA256 \
Credential=AKIAEXAMPLEKEYID/20260513/s3/aws4_request, \
SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-region-set, \
Signature={sig_hex}"
);
let parsed = parse_authorization_header(&header).expect("parses");
assert_eq!(parsed.access_key_id, "AKIAEXAMPLEKEYID");
assert_eq!(
parsed.credential_scope,
vec!["20260513", "s3", "aws4_request"],
);
assert_eq!(
parsed.signed_headers,
vec![
"host",
"x-amz-content-sha256",
"x-amz-date",
"x-amz-region-set"
],
);
assert_eq!(parsed.signature_der, decode_hex(sig_hex).unwrap());
}
#[test]
fn parse_authorization_header_rejects_sigv4_hmac() {
let header = "AWS4-HMAC-SHA256 \
Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
SignedHeaders=host, \
Signature=deadbeef";
let err = parse_authorization_header(header).expect_err("not SigV4a");
assert!(matches!(err, SigV4aError::BadSignature(_)));
}
#[test]
fn parse_authorization_header_rejects_missing_fields() {
let header = "AWS4-ECDSA-P256-SHA256 Credential=AKIA/20260513/s3/aws4_request, \
SignedHeaders=host";
let err = parse_authorization_header(header).expect_err("missing Signature=");
assert!(matches!(err, SigV4aError::BadSignature(_)));
}
#[test]
fn detect_picks_up_sigv4a_authorization_header() {
let req = http::Request::builder()
.method("GET")
.uri("/bucket/key")
.header(
"authorization",
"AWS4-ECDSA-P256-SHA256 Credential=A/20260513/s3/aws4_request, \
SignedHeaders=host, Signature=ab",
)
.body(())
.unwrap();
assert!(detect(&req));
}
#[test]
fn detect_picks_up_region_set_header() {
let req = http::Request::builder()
.method("GET")
.uri("/bucket/key")
.header("authorization", "AWS4-HMAC-SHA256 ...")
.header(REGION_SET_HEADER, "us-east-1,us-west-2")
.body(())
.unwrap();
assert!(detect(&req));
}
#[test]
fn detect_ignores_plain_sigv4() {
let req = http::Request::builder()
.method("GET")
.uri("/bucket/key")
.header(
"authorization",
"AWS4-HMAC-SHA256 Credential=A/20260513/us-east-1/s3/aws4_request, \
SignedHeaders=host, Signature=ab",
)
.body(())
.unwrap();
assert!(!detect(&req));
}
#[test]
fn region_set_membership() {
assert!(region_set_contains("us-east-1,us-west-2", "us-east-1"));
assert!(region_set_contains("us-east-1,us-west-2", "us-west-2"));
assert!(region_set_contains("*", "ap-northeast-1"));
assert!(region_set_contains("us-east-1, us-west-2", "us-west-2"));
assert!(region_set_contains("us-east-1", "US-EAST-1"));
}
#[test]
fn region_set_non_member_rejected() {
assert!(!region_set_contains("us-east-1,us-west-2", "eu-west-1"));
assert!(!region_set_contains("", "us-east-1"));
assert!(!region_set_contains("us-east-1", ""));
}
#[test]
fn ecdsa_p256_sign_then_verify_ok() {
let signing_key = SigningKey::random(&mut OsRng);
let verifying_key = VerifyingKey::from(&signing_key);
let canonical = b"AWS4-ECDSA-P256-SHA256\n20260513T120000Z\n\
20260513/s3/aws4_request\n\
deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let sig: Signature = signing_key.sign(canonical);
let der = sig.to_der().as_bytes().to_vec();
verify(
&CanonicalRequest::new(canonical),
&der,
&verifying_key,
"us-east-1,us-west-2",
"us-east-1",
)
.expect("happy-path verify must succeed");
}
#[test]
fn ecdsa_p256_verify_wildcard_region() {
let signing_key = SigningKey::random(&mut OsRng);
let verifying_key = VerifyingKey::from(&signing_key);
let canonical = b"canonical-request-bytes";
let sig: Signature = signing_key.sign(canonical);
let der = sig.to_der().as_bytes().to_vec();
verify(
&CanonicalRequest::new(canonical),
&der,
&verifying_key,
"*",
"ap-northeast-1",
)
.expect("wildcard region should match anything");
}
#[test]
fn ecdsa_p256_verify_region_mismatch() {
let signing_key = SigningKey::random(&mut OsRng);
let verifying_key = VerifyingKey::from(&signing_key);
let canonical = b"canonical";
let sig: Signature = signing_key.sign(canonical);
let der = sig.to_der().as_bytes().to_vec();
let err = verify(
&CanonicalRequest::new(canonical),
&der,
&verifying_key,
"us-east-1,us-west-2",
"eu-west-1",
)
.expect_err("region mismatch must reject");
assert!(matches!(err, SigV4aError::RegionMismatch { .. }));
}
#[test]
fn ecdsa_p256_verify_tamper_one_byte_fails() {
let signing_key = SigningKey::random(&mut OsRng);
let verifying_key = VerifyingKey::from(&signing_key);
let canonical = b"canonical-request-bytes-original";
let sig: Signature = signing_key.sign(canonical);
let der = sig.to_der().as_bytes().to_vec();
let mut tampered = canonical.to_vec();
tampered[0] ^= 0x01;
let err = verify(
&CanonicalRequest::new(&tampered),
&der,
&verifying_key,
"*",
"us-east-1",
)
.expect_err("tampered payload must not verify");
assert!(matches!(err, SigV4aError::VerificationFailed));
}
#[test]
fn ecdsa_p256_verify_malformed_signature() {
let signing_key = SigningKey::random(&mut OsRng);
let verifying_key = VerifyingKey::from(&signing_key);
let err = verify(
&CanonicalRequest::new(b"x"),
b"\x00\x01not-a-der-sig",
&verifying_key,
"*",
"us-east-1",
)
.expect_err("malformed signature must not verify");
assert!(matches!(err, SigV4aError::BadSignature(_)));
}
#[test]
fn hex_decode_rejects_invalid() {
assert_eq!(decode_hex("00ff"), Some(vec![0x00, 0xff]));
assert_eq!(decode_hex("ABcd"), Some(vec![0xab, 0xcd]));
assert!(decode_hex("0").is_none()); assert!(decode_hex("zz").is_none()); }
#[test]
fn credential_store_from_map_lookup() {
let signing = SigningKey::random(&mut OsRng);
let verifying = VerifyingKey::from(&signing);
let mut m = HashMap::new();
m.insert("AKIATEST".to_owned(), verifying);
let store = SigV4aCredentialStore::from_map(m);
assert_eq!(store.len(), 1);
assert!(!store.is_empty());
assert!(store.get("AKIATEST").is_some());
assert!(store.get("UNKNOWN").is_none());
}
#[test]
fn credential_store_load_dir_pem() {
use p256::pkcs8::EncodePublicKey;
use std::io::Write;
let dir = tempfile::tempdir().expect("tmp");
for id in ["AKIA1", "AKIA2"] {
let signing = SigningKey::random(&mut OsRng);
let verifying = VerifyingKey::from(&signing);
let pem = verifying
.to_public_key_pem(p256::pkcs8::LineEnding::LF)
.unwrap();
let mut f = std::fs::File::create(dir.path().join(format!("{id}.pem"))).unwrap();
f.write_all(pem.as_bytes()).unwrap();
}
std::fs::write(dir.path().join("ignored.txt"), b"ignored").unwrap();
let store = SigV4aCredentialStore::load_dir(dir.path()).expect("load");
assert_eq!(store.len(), 2);
assert!(store.get("AKIA1").is_some());
assert!(store.get("AKIA2").is_some());
}
#[test]
fn credential_store_load_dir_rejects_bad_pem() {
let dir = tempfile::tempdir().expect("tmp");
std::fs::write(dir.path().join("AKIABAD.pem"), b"not a pem").unwrap();
let err = SigV4aCredentialStore::load_dir(dir.path()).expect_err("bad pem");
assert!(matches!(err, SigV4aError::BadPublicKey { .. }));
}
#[test]
fn parse_then_verify_round_trip() {
let signing = SigningKey::random(&mut OsRng);
let verifying = VerifyingKey::from(&signing);
let canonical = b"GET\n/bucket/key\n\nhost:s3.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD";
let sig: Signature = signing.sign(canonical);
let sig_hex = lower_hex(sig.to_der().as_bytes());
let header = format!(
"AWS4-ECDSA-P256-SHA256 \
Credential=AKIARTRIP/20260513/s3/aws4_request, \
SignedHeaders=host, \
Signature={sig_hex}"
);
let parsed = parse_authorization_header(&header).expect("parse");
assert_eq!(parsed.access_key_id, "AKIARTRIP");
verify(
&CanonicalRequest::new(canonical),
&parsed.signature_der,
&verifying,
"*",
"us-east-1",
)
.expect("round-trip verify");
}
fn build_signed_request(
x_amz_date: &str,
scope_date: &str,
) -> (SigV4aAuth, HashMap<String, String>, Vec<u8>, VerifyingKey) {
let signing = SigningKey::random(&mut OsRng);
let verifying = VerifyingKey::from(&signing);
let canonical = b"GET\n/bucket/key\n\nhost:s3.example.com\nx-amz-date:placeholder\n\nhost;x-amz-date\nUNSIGNED-PAYLOAD".to_vec();
let sig: Signature = signing.sign(&canonical);
let sig_hex = lower_hex(sig.to_der().as_bytes());
let header = format!(
"AWS4-ECDSA-P256-SHA256 \
Credential=AKIATEST/{scope_date}/s3/aws4_request, \
SignedHeaders=host;x-amz-date, \
Signature={sig_hex}"
);
let parsed = parse_authorization_header(&header).expect("parse");
let mut headers = HashMap::new();
headers.insert("host".to_string(), "s3.example.com".to_string());
headers.insert("x-amz-date".to_string(), x_amz_date.to_string());
(parsed, headers, canonical, verifying)
}
#[test]
fn sigv4a_rejects_missing_x_amz_date() {
let (parsed, mut headers, canonical, vk) =
build_signed_request("20260514T120000Z", "20260514");
headers.remove("x-amz-date");
let now = chrono::DateTime::parse_from_rfc3339("2026-05-14T12:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let err = verify_request(
&parsed,
&headers,
&canonical,
&vk,
"*",
"us-east-1",
now,
chrono::Duration::seconds(900),
)
.expect_err("missing x-amz-date must reject");
assert!(matches!(err, SigV4aError::MissingXAmzDate));
}
#[test]
fn sigv4a_rejects_skew_beyond_15min_past() {
let (parsed, headers, canonical, vk) = build_signed_request("20260514T120000Z", "20260514");
let now = chrono::DateTime::parse_from_rfc3339("2026-05-14T12:16:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let err = verify_request(
&parsed,
&headers,
&canonical,
&vk,
"*",
"us-east-1",
now,
chrono::Duration::seconds(900),
)
.expect_err("16min past drift must reject");
match err {
SigV4aError::RequestTimeTooSkewed {
drift_secs,
tolerance_secs,
} => {
assert_eq!(drift_secs, 960);
assert_eq!(tolerance_secs, 900);
}
other => panic!("expected RequestTimeTooSkewed, got {other:?}"),
}
}
#[test]
fn sigv4a_rejects_skew_beyond_15min_future() {
let (parsed, headers, canonical, vk) = build_signed_request("20260514T121600Z", "20260514");
let now = chrono::DateTime::parse_from_rfc3339("2026-05-14T12:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let err = verify_request(
&parsed,
&headers,
&canonical,
&vk,
"*",
"us-east-1",
now,
chrono::Duration::seconds(900),
)
.expect_err("16min future drift must reject");
assert!(matches!(err, SigV4aError::RequestTimeTooSkewed { .. }));
}
#[test]
fn sigv4a_rejects_malformed_credential_scope() {
let header = "AWS4-ECDSA-P256-SHA256 \
Credential=AKIA/20260514/s3, \
SignedHeaders=host, \
Signature=ab";
let err = parse_authorization_header(header).expect_err("3 components must reject");
assert!(matches!(err, SigV4aError::InvalidCredentialScope));
let header = "AWS4-ECDSA-P256-SHA256 \
Credential=AKIA/20260514/us-east-1/s3/aws4_request, \
SignedHeaders=host, \
Signature=ab";
let err = parse_authorization_header(header).expect_err("5 components must reject");
assert!(matches!(err, SigV4aError::InvalidCredentialScope));
}
#[test]
fn sigv4a_rejects_wrong_service() {
let header = "AWS4-ECDSA-P256-SHA256 \
Credential=AKIA/20260514/ec2/aws4_request, \
SignedHeaders=host, \
Signature=ab";
let err = parse_authorization_header(header).expect_err("ec2 scope must reject");
match err {
SigV4aError::WrongService { got } => assert_eq!(got, "ec2"),
other => panic!("expected WrongService, got {other:?}"),
}
}
#[test]
fn sigv4a_accepts_within_skew_window() {
let (parsed, headers, canonical, vk) = build_signed_request("20260514T120000Z", "20260514");
let now = chrono::DateTime::parse_from_rfc3339("2026-05-14T12:14:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
verify_request(
&parsed,
&headers,
&canonical,
&vk,
"*",
"us-east-1",
now,
chrono::Duration::seconds(900),
)
.expect("14min drift within window must verify");
}
#[test]
fn sigv4a_rejects_invalid_terminator() {
let header = "AWS4-ECDSA-P256-SHA256 \
Credential=AKIA/20260514/s3/AWS4_REQUEST, \
SignedHeaders=host, \
Signature=ab";
let err = parse_authorization_header(header).expect_err("uppercase terminator must reject");
assert!(matches!(err, SigV4aError::InvalidTerminator));
}
#[test]
fn sigv4a_rejects_x_amz_date_not_in_signed_headers() {
let signing = SigningKey::random(&mut OsRng);
let verifying = VerifyingKey::from(&signing);
let canonical = b"x".to_vec();
let sig: Signature = signing.sign(&canonical);
let sig_hex = lower_hex(sig.to_der().as_bytes());
let header = format!(
"AWS4-ECDSA-P256-SHA256 \
Credential=AKIA/20260514/s3/aws4_request, \
SignedHeaders=host, \
Signature={sig_hex}"
);
let parsed = parse_authorization_header(&header).expect("parse");
let mut headers = HashMap::new();
headers.insert("x-amz-date".to_string(), "20260514T120000Z".to_string());
let now = chrono::DateTime::parse_from_rfc3339("2026-05-14T12:00:30Z")
.unwrap()
.with_timezone(&chrono::Utc);
let err = verify_request(
&parsed,
&headers,
&canonical,
&verifying,
"*",
"us-east-1",
now,
chrono::Duration::seconds(900),
)
.expect_err("x-amz-date not in SignedHeaders must reject");
assert!(matches!(err, SigV4aError::XAmzDateNotSigned));
}
#[test]
fn sigv4a_rejects_date_scope_mismatch() {
let (parsed, headers, canonical, vk) = build_signed_request("20260514T120000Z", "20260101");
let now = chrono::DateTime::parse_from_rfc3339("2026-05-14T12:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let err = verify_request(
&parsed,
&headers,
&canonical,
&vk,
"*",
"us-east-1",
now,
chrono::Duration::days(365),
)
.expect_err("scope date mismatch must reject");
assert!(matches!(err, SigV4aError::DateScopeMismatch));
}
}