use std::fmt;
use hmac::{Hmac, KeyInit, Mac};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use time::OffsetDateTime;
type HmacSha256 = Hmac<Sha256>;
const ALGORITHM: &str = "AWS4-HMAC-SHA256";
pub const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
#[derive(Clone)]
pub struct Credentials {
pub access_key_id: String,
pub secret_access_key: String,
}
impl Credentials {
#[must_use]
pub fn new(access_key_id: impl Into<String>, secret_access_key: impl Into<String>) -> Self {
Self {
access_key_id: access_key_id.into(),
secret_access_key: secret_access_key.into(),
}
}
}
impl fmt::Debug for Credentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Credentials")
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"[redacted]")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SigV4Error {
#[error("malformed presigned URL: {0}")]
Malformed(String),
#[error("presigned URL has expired")]
Expired,
#[error("presigned URL signature does not match")]
BadSignature,
}
#[derive(Debug, Clone)]
pub struct SignableRequest<'a> {
pub method: &'a str,
pub host: &'a str,
pub path: &'a str,
pub query: &'a [(String, String)],
pub headers: &'a [(String, String)],
}
#[must_use]
pub fn s3_key_path(key: &str) -> String {
format!("/{}", uri_encode(key, false))
}
#[must_use]
pub fn authorization_header(
credentials: &Credentials,
region: &str,
service: &str,
request: &SignableRequest<'_>,
payload_hash: &str,
now: OffsetDateTime,
) -> String {
let amz_date = amz_date(now);
let headers = signable_headers(request.host, request.headers, Some(&amz_date));
let (canonical, signed) = canonical_request(
request.method,
request.path,
request.query,
&headers,
payload_hash,
);
let signature = sign(credentials, &amz_date, region, service, &canonical);
let scope = credential_scope(&amz_date[..8], region, service);
format!(
"{ALGORITHM} Credential={}/{scope}, SignedHeaders={signed}, Signature={signature}",
credentials.access_key_id
)
}
#[must_use]
pub fn presign(
credentials: &Credentials,
region: &str,
service: &str,
request: &SignableRequest<'_>,
expires_secs: u64,
now: OffsetDateTime,
) -> String {
let amz_date = amz_date(now);
let headers = signable_headers(request.host, request.headers, None);
let scope = credential_scope(&amz_date[..8], region, service);
let mut query: Vec<(String, String)> = request.query.to_vec();
query.push(("X-Amz-Algorithm".to_owned(), ALGORITHM.to_owned()));
query.push(("X-Amz-Date".to_owned(), amz_date.clone()));
query.push(("X-Amz-Expires".to_owned(), expires_secs.to_string()));
query.push((
"X-Amz-SignedHeaders".to_owned(),
signed_header_names(&headers),
));
query.push((
"X-Amz-Credential".to_owned(),
format!("{}/{scope}", credentials.access_key_id),
));
let (canonical, _) = canonical_request(
request.method,
request.path,
&query,
&headers,
UNSIGNED_PAYLOAD,
);
let signature = sign(credentials, &amz_date, region, service, &canonical);
format!(
"https://{}{}?{}&X-Amz-Signature={signature}",
request.host,
request.path,
canonical_query_string(&query),
)
}
pub fn verify_presigned(
url: &str,
method: &str,
headers: &[(String, String)],
credentials: &Credentials,
region: &str,
service: &str,
now: OffsetDateTime,
) -> Result<(), SigV4Error> {
let (host, path, query) = split_url(url)?;
let param = |name: &str| {
query
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
};
if param("X-Amz-Algorithm") != Some(ALGORITHM) {
return Err(malformed("unexpected X-Amz-Algorithm"));
}
let amz_date = param("X-Amz-Date").ok_or_else(|| malformed("missing X-Amz-Date"))?;
let signed_headers =
param("X-Amz-SignedHeaders").ok_or_else(|| malformed("missing headers"))?;
let provided = param("X-Amz-Signature").ok_or_else(|| malformed("missing signature"))?;
let date = amz_date
.get(..8)
.ok_or_else(|| malformed("X-Amz-Date is too short"))?;
let credential = param("X-Amz-Credential").ok_or_else(|| malformed("missing credential"))?;
let scope = credential_scope(date, region, service);
if credential != format!("{}/{scope}", credentials.access_key_id) {
return Err(SigV4Error::BadSignature);
}
let signed_at =
parse_amz_date(amz_date).ok_or_else(|| malformed("X-Amz-Date is not a timestamp"))?;
let expires: u64 = param("X-Amz-Expires")
.and_then(|value| value.parse().ok())
.ok_or_else(|| malformed("bad X-Amz-Expires"))?;
let expires = i64::try_from(expires).map_err(|_| malformed("X-Amz-Expires is out of range"))?;
let deadline = signed_at
.checked_add(time::Duration::seconds(expires))
.ok_or_else(|| malformed("X-Amz-Expires is out of range"))?;
if deadline < now {
return Err(SigV4Error::Expired);
}
let mut canonical_headers = Vec::with_capacity(signed_headers.len());
for name in signed_headers.split(';') {
let value = if name == "host" {
host.clone()
} else {
header_value(headers, name)
.ok_or_else(|| malformed("a signed header the client did not send"))?
};
canonical_headers.push((name.to_owned(), value));
}
let query: Vec<(String, String)> = query
.iter()
.filter(|(key, _)| key != "X-Amz-Signature")
.cloned()
.collect();
let (canonical, _) =
canonical_request(method, &path, &query, &canonical_headers, UNSIGNED_PAYLOAD);
let expected = sign(credentials, amz_date, region, service, &canonical);
if bool::from(expected.as_bytes().ct_eq(provided.as_bytes())) {
Ok(())
} else {
Err(SigV4Error::BadSignature)
}
}
#[must_use]
pub fn sha256_hex(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
type UrlParts = (String, String, Vec<(String, String)>);
fn split_url(url: &str) -> Result<UrlParts, SigV4Error> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| malformed("not an absolute URL"))?;
let rest = rest.split('#').next().unwrap_or(rest);
let (host, tail) = rest.split_at(rest.find(['/', '?']).unwrap_or(rest.len()));
if scheme.is_empty() || host.is_empty() || host.contains('@') {
return Err(malformed("missing host"));
}
let (path, query) = tail.split_once('?').unwrap_or((tail, ""));
let query = query
.split('&')
.filter(|pair| !pair.is_empty())
.map(|pair| {
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
Ok((percent_decode(name)?, percent_decode(value)?))
})
.collect::<Result<_, SigV4Error>>()?;
Ok((
host.to_owned(),
if path.is_empty() { "/" } else { path }.to_owned(),
query,
))
}
fn percent_decode(value: &str) -> Result<String, SigV4Error> {
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'%' {
out.push(bytes[i]);
i += 1;
continue;
}
let digit = |at: usize| bytes.get(at).and_then(|byte| (*byte as char).to_digit(16));
let (Some(high), Some(low)) = (digit(i + 1), digit(i + 2)) else {
return Err(malformed("bad percent-encoding"));
};
out.push(u8::try_from(high << 4 | low).expect("two hex digits fit in a byte"));
i += 3;
}
String::from_utf8(out).map_err(|_| malformed("query is not UTF-8"))
}
fn malformed(why: &str) -> SigV4Error {
SigV4Error::Malformed(why.to_owned())
}
fn collapse(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
headers
.iter()
.find(|(header, _)| header.eq_ignore_ascii_case(name))
.map(|(_, value)| collapse(value))
}
fn signable_headers(
host: &str,
headers: &[(String, String)],
amz_date: Option<&str>,
) -> Vec<(String, String)> {
let mut all: Vec<(String, String)> = headers
.iter()
.filter(|(name, _)| {
!name.eq_ignore_ascii_case("host") && !name.eq_ignore_ascii_case("x-amz-date")
})
.cloned()
.collect();
all.push(("host".to_owned(), host.to_owned()));
all.extend(amz_date.map(|date| ("x-amz-date".to_owned(), date.to_owned())));
all
}
fn signed_header_names(headers: &[(String, String)]) -> String {
let mut names: Vec<String> = headers
.iter()
.map(|(name, _)| name.to_ascii_lowercase())
.collect();
names.sort();
names.dedup();
names.join(";")
}
fn canonical_request(
method: &str,
path: &str,
query: &[(String, String)],
headers: &[(String, String)],
payload_hash: &str,
) -> (String, String) {
use std::fmt::Write as _;
let mut sorted: Vec<(String, String)> = headers
.iter()
.map(|(name, value)| (name.to_ascii_lowercase(), collapse(value)))
.collect();
sorted.sort();
let mut block = String::new();
for (name, value) in &sorted {
let _ = writeln!(block, "{name}:{value}");
}
let signed = sorted
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>()
.join(";");
let canonical = format!(
"{method}\n{path}\n{}\n{block}\n{signed}\n{payload_hash}",
canonical_query_string(query),
);
(canonical, signed)
}
fn canonical_query_string(query: &[(String, String)]) -> String {
let mut pairs: Vec<(String, String)> = query
.iter()
.map(|(key, value)| (uri_encode(key, true), uri_encode(value, true)))
.collect();
pairs.sort();
pairs
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join("&")
}
fn uri_encode(value: &str, encode_slash: bool) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
let keep = byte.is_ascii_alphanumeric()
|| matches!(byte, b'-' | b'.' | b'_' | b'~')
|| (byte == b'/' && !encode_slash);
if keep {
out.push(char::from(byte));
} else {
let _ = write!(out, "%{byte:02X}");
}
}
out
}
fn credential_scope(date: &str, region: &str, service: &str) -> String {
format!("{date}/{region}/{service}/aws4_request")
}
fn sign(
credentials: &Credentials,
amz_date: &str,
region: &str,
service: &str,
canonical: &str,
) -> String {
let date = &amz_date[..8];
let scope = credential_scope(date, region, service);
let string_to_sign = format!(
"{ALGORITHM}\n{amz_date}\n{scope}\n{}",
sha256_hex(canonical.as_bytes())
);
let mut key = hmac(
format!("AWS4{}", credentials.secret_access_key).as_bytes(),
date.as_bytes(),
);
for part in [region, service] {
key = hmac(&key, part.as_bytes());
}
let key = hmac(&key, b"aws4_request");
hex(&hmac(&key, string_to_sign.as_bytes()))
}
fn hmac(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 hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
fn amz_date(now: OffsetDateTime) -> String {
let now = now.to_offset(time::UtcOffset::UTC);
format!(
"{:04}{:02}{:02}T{:02}{:02}{:02}Z",
now.year(),
u8::from(now.month()),
now.day(),
now.hour(),
now.minute(),
now.second(),
)
}
fn parse_amz_date(value: &str) -> Option<OffsetDateTime> {
if value.len() != 16 || value.as_bytes()[8] != b'T' || value.as_bytes()[15] != b'Z' {
return None;
}
let at = |range: std::ops::Range<usize>| value.get(range)?.parse::<u8>().ok();
let year = value.get(0..4)?.parse::<i32>().ok()?;
let date =
time::Date::from_calendar_date(year, time::Month::try_from(at(4..6)?).ok()?, at(6..8)?)
.ok()?;
let at = time::Time::from_hms(at(9..11)?, at(11..13)?, at(13..15)?).ok()?;
Some(time::PrimitiveDateTime::new(date, at).assume_utc())
}
#[cfg(test)]
mod tests {
use super::*;
const VANILLA: &str = "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31";
const QUERY: &str = "b97d918cfa904a5beff61c982a1b6f458b799221646efd99d3219ec94cdf2500";
const POST: &str = "5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b";
const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
fn at(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> OffsetDateTime {
let date =
time::Date::from_calendar_date(year, time::Month::try_from(month).expect("month"), day)
.expect("a valid test date");
let time = time::Time::from_hms(hour, minute, second).expect("a valid test time");
time::PrimitiveDateTime::new(date, time).assume_utc()
}
fn suite() -> Credentials {
Credentials::new("AKIDEXAMPLE", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY")
}
fn expected(signature: &str) -> String {
format!(
"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature={signature}"
)
}
fn suite_header(method: &str, query: &[(String, String)]) -> String {
authorization_header(
&suite(),
"us-east-1",
"service",
&SignableRequest {
method,
host: "example.amazonaws.com",
path: "/",
query,
headers: &[],
},
EMPTY,
at(2015, 8, 30, 12, 36, 0),
)
}
fn bucket(method: &str) -> SignableRequest<'_> {
SignableRequest {
method,
host: "bucket.example.com",
path: "/cms/clip.mp3",
query: &[],
headers: &[],
}
}
fn verify(
url: &str,
method: &str,
creds: &Credentials,
now: OffsetDateTime,
) -> Result<(), SigV4Error> {
verify_presigned(url, method, &[], creds, "auto", "s3", now)
}
#[test]
fn published_vectors() {
let ordered = [
("Param2".to_owned(), "value2".to_owned()),
("Param1".to_owned(), "value1".to_owned()),
];
assert_eq!(suite_header("GET", &[]), expected(VANILLA));
assert_eq!(suite_header("POST", &[]), expected(POST));
assert_eq!(suite_header("GET", &ordered), expected(QUERY));
let credentials = Credentials::new(
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
);
let request = SignableRequest {
method: "GET",
host: "examplebucket.s3.amazonaws.com",
path: "/test.txt",
query: &[],
headers: &[],
};
let url = presign(
&credentials,
"us-east-1",
"s3",
&request,
86400,
at(2013, 5, 24, 0, 0, 0),
);
assert_eq!(
url,
"https://examplebucket.s3.amazonaws.com/test.txt\
?X-Amz-Algorithm=AWS4-HMAC-SHA256\
&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request\
&X-Amz-Date=20130524T000000Z\
&X-Amz-Expires=86400\
&X-Amz-SignedHeaders=host\
&X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"
);
}
#[test]
fn presign_round_trips_and_refuses_tampering_or_wrong_scope() {
let credentials = suite();
let other = Credentials::new("AKIDOTHER", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY");
let now = at(2026, 1, 1, 0, 0, 0);
let url = presign(&credentials, "auto", "s3", &bucket("GET"), 3600, now);
verify(&url, "GET", &credentials, now).expect("the URL verifies against the request");
let sig = url.replace("X-Amz-Signature=", "X-Amz-Signature=0");
let path = url.replace("/cms/clip.mp3", "/cms/other.mp3");
let bad = Err(SigV4Error::BadSignature);
assert_eq!(verify(&sig, "GET", &credentials, now), bad);
assert_eq!(verify(&path, "GET", &credentials, now), bad);
assert_eq!(verify(&url, "GET", &other, now), bad);
let region = verify_presigned(&url, "GET", &[], &credentials, "us-east-1", "s3", now);
assert_eq!(region, bad);
let put = bucket("PUT");
let put = SignableRequest {
headers: &[("content-type".to_owned(), "audio/mpeg".to_owned())],
..put
};
let ct = presign(&credentials, "auto", "s3", &put, 3600, now);
let wrong = [("content-type".to_owned(), "image/png".to_owned())];
let mismatch = verify_presigned(&ct, "PUT", &wrong, &credentials, "auto", "s3", now);
assert_eq!(mismatch, bad);
}
#[test]
fn verify_refuses_expiry_and_malformed_urls() {
let credentials = suite();
let now = at(2026, 1, 1, 0, 0, 0);
let url = presign(&credentials, "auto", "s3", &bucket("GET"), 3600, now);
let past = at(2026, 1, 1, 1, 0, 1);
let deadline = at(2026, 1, 1, 1, 0, 0);
assert_eq!(
verify(&url, "GET", &credentials, past),
Err(SigV4Error::Expired)
);
verify(&url, "GET", &credentials, deadline).expect("the deadline is valid");
let huge = url.replace("X-Amz-Expires=3600", "X-Amz-Expires=9223372036854775807");
assert!(matches!(
verify(&huge, "GET", &credentials, now),
Err(SigV4Error::Malformed(_))
));
for url in [
"not a url",
"https://bucket.example.com/key.mp3",
"https://bucket.example.com/key.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256",
] {
let result = verify(url, "GET", &credentials, now);
assert!(matches!(result, Err(SigV4Error::Malformed(_))), "{url}");
}
}
#[test]
fn s3_key_path_and_debug_redaction() {
assert_eq!(s3_key_path("CMS/clip.mp3"), "/CMS/clip.mp3");
assert_eq!(s3_key_path("cms/avatar 1.png"), "/cms/avatar%201.png");
assert_eq!(s3_key_path("a/b+c"), "/a/b%2Bc");
let debug = format!("{:?}", suite());
assert!(debug.contains("AKIDEXAMPLE"), "{debug}");
assert!(!debug.contains("wJalrXUtnFEMI"), "{debug}");
}
}