use chrono::{DateTime, TimeZone, Utc};
use hmac::{Hmac, Mac};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone)]
pub struct SigV4Info {
pub access_key: String,
pub region: String,
pub service: String,
}
#[derive(Debug, Clone)]
pub struct ParsedSigV4 {
pub access_key: String,
pub date_stamp: String,
pub region: String,
pub service: String,
pub signed_headers: Vec<String>,
pub signature: String,
pub amz_date: String,
pub is_presigned: bool,
}
impl ParsedSigV4 {
pub fn as_info(&self) -> SigV4Info {
SigV4Info {
access_key: self.access_key.clone(),
region: self.region.clone(),
service: self.service.clone(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SigV4Error {
#[error("request time {signed} is outside the allowed 15-minute window from {server}")]
RequestTimeTooSkewed {
signed: DateTime<Utc>,
server: DateTime<Utc>,
},
#[error("malformed SigV4 signature: {0}")]
Malformed(&'static str),
#[error("signature does not match")]
SignatureMismatch,
#[error("invalid x-amz-date: {0}")]
InvalidDate(&'static str),
}
pub const CLOCK_SKEW_SECONDS: i64 = 15 * 60;
pub fn parse_sigv4(auth_header: &str) -> Option<SigV4Info> {
parse_header_credential(auth_header)
}
fn parse_header_credential(auth_header: &str) -> Option<SigV4Info> {
let auth = auth_header.strip_prefix("AWS4-HMAC-SHA256 ")?;
let credential_start = auth.find("Credential=")?;
let credential_value = &auth[credential_start + "Credential=".len()..];
let credential = match credential_value.find(',') {
Some(end) => &credential_value[..end],
None => credential_value,
};
parse_credential_scope(credential)
}
fn parse_credential_scope(credential: &str) -> Option<SigV4Info> {
let parts: Vec<&str> = credential.split('/').collect();
if parts.len() != 5 || parts[4] != "aws4_request" {
return None;
}
Some(SigV4Info {
access_key: parts[0].to_string(),
region: parts[2].to_string(),
service: parts[3].to_string(),
})
}
pub fn parse_sigv4_header(auth_header: &str, amz_date: Option<&str>) -> Option<ParsedSigV4> {
let auth = auth_header.strip_prefix("AWS4-HMAC-SHA256 ")?;
let mut credential: Option<&str> = None;
let mut signed_headers: Option<&str> = None;
let mut signature: Option<&str> = None;
for part in auth.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 credential = credential?;
let signed_headers = signed_headers?;
let signature = signature?;
let scope = parse_credential_scope(credential)?;
let date_stamp = credential.split('/').nth(1)?.to_string();
let signed_headers: Vec<String> = signed_headers
.split(';')
.map(|s| s.to_ascii_lowercase())
.collect();
if signed_headers.is_empty() {
return None;
}
Some(ParsedSigV4 {
access_key: scope.access_key,
date_stamp,
region: scope.region,
service: scope.service,
signed_headers,
signature: signature.to_string(),
amz_date: amz_date?.to_string(),
is_presigned: false,
})
}
pub fn parse_sigv4_presigned(
query: &std::collections::HashMap<String, String>,
) -> Option<ParsedSigV4> {
if query.get("X-Amz-Algorithm").map(|s| s.as_str()) != Some("AWS4-HMAC-SHA256") {
return None;
}
let credential = query.get("X-Amz-Credential")?;
let scope = parse_credential_scope(credential)?;
let date_stamp = credential.split('/').nth(1)?.to_string();
let signed_headers = query.get("X-Amz-SignedHeaders")?;
let signed_headers: Vec<String> = signed_headers
.split(';')
.map(|s| s.to_ascii_lowercase())
.collect();
let signature = query.get("X-Amz-Signature")?.clone();
let amz_date = query.get("X-Amz-Date")?.clone();
Some(ParsedSigV4 {
access_key: scope.access_key,
date_stamp,
region: scope.region,
service: scope.service,
signed_headers,
signature,
amz_date,
is_presigned: true,
})
}
#[derive(Debug, Clone)]
pub struct VerifyRequest<'a> {
pub method: &'a str,
pub path: &'a str,
pub query: &'a str,
pub headers: &'a [(String, String)],
pub body: &'a [u8],
}
pub fn headers_from_http(headers: &http::HeaderMap) -> Vec<(String, String)> {
let mut out: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for (name, value) in headers.iter() {
let key = name.as_str().to_ascii_lowercase();
if let Ok(v) = value.to_str() {
out.entry(key).or_default().push(v.to_string());
}
}
out.into_iter().map(|(k, vs)| (k, vs.join(", "))).collect()
}
pub fn verify(
parsed: &ParsedSigV4,
req: &VerifyRequest<'_>,
secret_access_key: &str,
now: DateTime<Utc>,
) -> Result<(), SigV4Error> {
let signed_at = parse_amz_date(&parsed.amz_date)?;
let drift = (now - signed_at).num_seconds().abs();
if drift > CLOCK_SKEW_SECONDS {
return Err(SigV4Error::RequestTimeTooSkewed {
signed: signed_at,
server: now,
});
}
let canonical_request = build_canonical_request(parsed, req)?;
let hashed_canonical = hex::encode(Sha256::digest(canonical_request.as_bytes()));
let credential_scope = format!(
"{}/{}/{}/aws4_request",
parsed.date_stamp, parsed.region, parsed.service
);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
parsed.amz_date, credential_scope, hashed_canonical
);
let signing_key = derive_signing_key(
secret_access_key,
&parsed.date_stamp,
&parsed.region,
&parsed.service,
);
let expected = hmac_sha256(&signing_key, string_to_sign.as_bytes());
let expected_hex = hex::encode(expected);
if constant_time_eq(expected_hex.as_bytes(), parsed.signature.as_bytes()) {
Ok(())
} else {
Err(SigV4Error::SignatureMismatch)
}
}
fn parse_amz_date(s: &str) -> Result<DateTime<Utc>, SigV4Error> {
let naive = chrono::NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%SZ")
.map_err(|_| SigV4Error::InvalidDate("expected YYYYMMDDTHHMMSSZ"))?;
Utc.from_local_datetime(&naive)
.single()
.ok_or(SigV4Error::InvalidDate("ambiguous datetime"))
}
const SIGV4_URI_ENCODE: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'!')
.add(b'"')
.add(b'#')
.add(b'$')
.add(b'%')
.add(b'&')
.add(b'\'')
.add(b'(')
.add(b')')
.add(b'*')
.add(b'+')
.add(b',')
.add(b'/')
.add(b':')
.add(b';')
.add(b'<')
.add(b'=')
.add(b'>')
.add(b'?')
.add(b'@')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
fn sigv4_encode(s: &str) -> String {
utf8_percent_encode(s, SIGV4_URI_ENCODE).to_string()
}
fn canonical_uri(path: &str, service: &str) -> String {
if path.is_empty() {
return "/".to_string();
}
let encoded: Vec<String> = path
.split('/')
.map(|seg| {
let once = sigv4_encode(seg);
if service == "s3" {
once
} else {
sigv4_encode(&once)
}
})
.collect();
encoded.join("/")
}
fn canonical_query(query: &str, is_presigned: bool) -> String {
if query.is_empty() {
return String::new();
}
let mut pairs: Vec<(String, String)> = query
.split('&')
.filter_map(|kv| {
if kv.is_empty() {
return None;
}
let (k, v) = match kv.split_once('=') {
Some((k, v)) => (k, v),
None => (kv, ""),
};
let k_dec = percent_decode(k);
let v_dec = percent_decode(v);
if is_presigned && k_dec == "X-Amz-Signature" {
return None;
}
Some((sigv4_encode(&k_dec), sigv4_encode(&v_dec)))
})
.collect();
pairs.sort();
pairs
.into_iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&")
}
fn percent_decode(s: &str) -> String {
percent_encoding::percent_decode_str(s)
.decode_utf8_lossy()
.into_owned()
}
fn build_canonical_request(
parsed: &ParsedSigV4,
req: &VerifyRequest<'_>,
) -> Result<String, SigV4Error> {
let method = req.method.to_ascii_uppercase();
let canonical_path = canonical_uri(req.path, &parsed.service);
let canonical_qs = canonical_query(req.query, parsed.is_presigned);
let header_map: BTreeMap<String, String> = req
.headers
.iter()
.map(|(k, v)| (k.to_ascii_lowercase(), collapse_ws(v)))
.collect();
let mut canonical_headers = String::new();
for name in &parsed.signed_headers {
let value = header_map.get(name).ok_or(SigV4Error::Malformed(
"signed header not present in request",
))?;
canonical_headers.push_str(name);
canonical_headers.push(':');
canonical_headers.push_str(value);
canonical_headers.push('\n');
}
let signed_headers_list = parsed.signed_headers.join(";");
let payload_hash = if parsed.is_presigned {
header_map
.get("x-amz-content-sha256")
.cloned()
.unwrap_or_else(|| "UNSIGNED-PAYLOAD".to_string())
} else if let Some(h) = header_map.get("x-amz-content-sha256") {
h.clone()
} else {
hex::encode(Sha256::digest(req.body))
};
Ok(format!(
"{}\n{}\n{}\n{}\n{}\n{}",
method, canonical_path, canonical_qs, canonical_headers, signed_headers_list, payload_hash
))
}
fn collapse_ws(v: &str) -> String {
let mut out = String::with_capacity(v.len());
let mut in_ws = false;
for ch in v.trim().chars() {
if ch == ' ' || ch == '\t' {
if !in_ws {
out.push(' ');
in_ws = true;
}
} else {
out.push(ch);
in_ws = false;
}
}
out
}
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
fn derive_signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
let k_secret = format!("AWS4{}", secret);
let k_date = hmac_sha256(k_secret.as_bytes(), date.as_bytes());
let k_region = hmac_sha256(&k_date, region.as_bytes());
let k_service = hmac_sha256(&k_region, service.as_bytes());
hmac_sha256(&k_service, b"aws4_request")
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
use subtle::ConstantTimeEq;
a.ct_eq(b).into()
}
#[cfg(test)]
mod tests {
use super::*;
const AWS_EXAMPLE_SECRET: &str = "testtesttesttesttesttesttesttesttesttest";
const AWS_EXAMPLE_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
#[test]
fn parse_valid_header() {
let header = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/sqs/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc123";
let info = parse_sigv4(header).unwrap();
assert_eq!(info.access_key, "AKIAIOSFODNN7EXAMPLE");
assert_eq!(info.region, "us-east-1");
assert_eq!(info.service, "sqs");
}
#[test]
fn parse_credential_only_no_trailing_parts() {
let header =
"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/rds/aws4_request";
let info = parse_sigv4(header).unwrap();
assert_eq!(info.service, "rds");
assert_eq!(info.region, "us-east-1");
}
#[test]
fn parse_full_header_extracts_all_fields() {
let header = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/sqs/aws4_request, SignedHeaders=host;x-amz-date, Signature=deadbeef";
let parsed = parse_sigv4_header(header, Some("20260101T000000Z")).unwrap();
assert_eq!(parsed.access_key, "AKIAIOSFODNN7EXAMPLE");
assert_eq!(parsed.date_stamp, "20260101");
assert_eq!(parsed.signed_headers, vec!["host", "x-amz-date"]);
assert_eq!(parsed.signature, "deadbeef");
assert!(!parsed.is_presigned);
}
#[test]
fn parse_presigned_query_extracts_all_fields() {
let mut q = std::collections::HashMap::new();
q.insert(
"X-Amz-Algorithm".to_string(),
"AWS4-HMAC-SHA256".to_string(),
);
q.insert(
"X-Amz-Credential".to_string(),
"AKIAIOSFODNN7EXAMPLE/20260101/us-east-1/s3/aws4_request".to_string(),
);
q.insert("X-Amz-Date".to_string(), "20260101T000000Z".to_string());
q.insert("X-Amz-SignedHeaders".to_string(), "host".to_string());
q.insert("X-Amz-Signature".to_string(), "cafe".to_string());
let parsed = parse_sigv4_presigned(&q).unwrap();
assert_eq!(parsed.service, "s3");
assert!(parsed.is_presigned);
assert_eq!(parsed.signature, "cafe");
}
#[test]
fn returns_none_for_invalid() {
assert!(parse_sigv4("Bearer token123").is_none());
assert!(parse_sigv4("").is_none());
}
#[test]
fn canonical_uri_non_s3_double_encodes() {
assert_eq!(canonical_uri("/foo bar", "iam"), "/foo%2520bar");
assert_eq!(canonical_uri("/a/b", "iam"), "/a/b");
}
#[test]
fn canonical_uri_s3_single_encodes() {
assert_eq!(canonical_uri("/foo bar", "s3"), "/foo%20bar");
}
#[test]
fn canonical_query_sorts_and_drops_presigned_signature() {
let q = "X-Amz-Signature=ignored&B=2&A=1";
assert_eq!(canonical_query(q, true), "A=1&B=2");
assert_eq!(canonical_query(q, false), "A=1&B=2&X-Amz-Signature=ignored");
}
#[test]
fn derive_signing_key_is_deterministic_and_stable() {
let key = derive_signing_key(AWS_EXAMPLE_SECRET, "20150830", "us-east-1", "iam");
assert_eq!(
hex::encode(&key),
"0d041c02f01817181204845091e3445c37d6f6b200833f52d34d682b2005918a"
);
let diff = derive_signing_key(AWS_EXAMPLE_SECRET, "20150831", "us-east-1", "iam");
assert_ne!(key, diff);
}
#[test]
fn verify_rejects_skewed_clock() {
let parsed = ParsedSigV4 {
access_key: "X".into(),
date_stamp: "20260101".into(),
region: "us-east-1".into(),
service: "iam".into(),
signed_headers: vec!["host".into()],
signature: "00".into(),
amz_date: "20260101T000000Z".into(),
is_presigned: false,
};
let req = VerifyRequest {
method: "GET",
path: "/",
query: "",
headers: &[("host".into(), "iam.amazonaws.com".into())],
body: b"",
};
let server_now = Utc.with_ymd_and_hms(2026, 1, 1, 1, 0, 0).unwrap();
let result = verify(&parsed, &req, AWS_EXAMPLE_SECRET, server_now);
assert!(matches!(
result,
Err(SigV4Error::RequestTimeTooSkewed { .. })
));
}
#[test]
fn verify_round_trip_matches_self_computed_signature() {
let secret = AWS_EXAMPLE_SECRET;
let date_stamp = "20260101";
let amz_date = "20260101T120000Z";
let region = "us-east-1";
let service = "iam";
let method = "GET";
let path = "/";
let query = "Action=GetUser&Version=2010-05-08";
let headers = vec![
("host".to_string(), "iam.amazonaws.com".to_string()),
("x-amz-date".to_string(), amz_date.to_string()),
];
let body: &[u8] = b"";
let canonical_request = {
let mut parsed = ParsedSigV4 {
access_key: AWS_EXAMPLE_ACCESS_KEY.into(),
date_stamp: date_stamp.into(),
region: region.into(),
service: service.into(),
signed_headers: vec!["host".into(), "x-amz-date".into()],
signature: String::new(),
amz_date: amz_date.into(),
is_presigned: false,
};
let req = VerifyRequest {
method,
path,
query,
headers: &headers,
body,
};
let cr = build_canonical_request(&parsed, &req).unwrap();
parsed.signature = {
let scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
let sts = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
amz_date,
scope,
hex::encode(Sha256::digest(cr.as_bytes()))
);
let sk = derive_signing_key(secret, date_stamp, region, service);
hex::encode(hmac_sha256(&sk, sts.as_bytes()))
};
parsed
};
let req = VerifyRequest {
method,
path,
query,
headers: &headers,
body,
};
let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 30).unwrap();
verify(&canonical_request, &req, secret, now).unwrap();
}
#[test]
fn verify_rejects_tampered_body() {
let secret = AWS_EXAMPLE_SECRET;
let date_stamp = "20260101";
let amz_date = "20260101T120000Z";
let region = "us-east-1";
let service = "iam";
let method = "POST";
let path = "/";
let query = "";
let headers = vec![
("host".to_string(), "iam.amazonaws.com".to_string()),
("x-amz-date".to_string(), amz_date.to_string()),
];
let original_body: &[u8] = b"Action=ListUsers&Version=2010-05-08";
let mut parsed = ParsedSigV4 {
access_key: AWS_EXAMPLE_ACCESS_KEY.into(),
date_stamp: date_stamp.into(),
region: region.into(),
service: service.into(),
signed_headers: vec!["host".into(), "x-amz-date".into()],
signature: String::new(),
amz_date: amz_date.into(),
is_presigned: false,
};
let signing_req = VerifyRequest {
method,
path,
query,
headers: &headers,
body: original_body,
};
let cr = build_canonical_request(&parsed, &signing_req).unwrap();
let scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
let sts = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
amz_date,
scope,
hex::encode(Sha256::digest(cr.as_bytes()))
);
let sk = derive_signing_key(secret, date_stamp, region, service);
parsed.signature = hex::encode(hmac_sha256(&sk, sts.as_bytes()));
let tampered = VerifyRequest {
method,
path,
query,
headers: &headers,
body: b"Action=DeleteUser&Version=2010-05-08",
};
let now = Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, 30).unwrap();
assert!(matches!(
verify(&parsed, &tampered, secret, now),
Err(SigV4Error::SignatureMismatch)
));
}
#[test]
fn collapse_ws_normalizes_runs() {
assert_eq!(collapse_ws(" foo bar "), "foo bar");
assert_eq!(collapse_ws("foo\tbar"), "foo bar");
}
}