use hmac::{Hmac, KeyInit, Mac};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use tracing::debug;
use crate::{
canonical::build_canonical_request, credentials::CredentialProvider, error::AuthError,
};
const SUPPORTED_ALGORITHM: &str = "AWS4-HMAC-SHA256";
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone)]
pub struct AuthResult {
pub access_key_id: String,
pub region: String,
pub service: String,
pub signed_headers: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ParsedAuth {
pub algorithm: String,
pub access_key_id: String,
pub date: String,
pub region: String,
pub service: String,
pub signed_headers: Vec<String>,
pub signature: String,
}
pub fn parse_authorization_header(header: &str) -> Result<ParsedAuth, AuthError> {
let (algorithm, rest) = header.split_once(' ').ok_or(AuthError::InvalidAuthHeader)?;
if algorithm != SUPPORTED_ALGORITHM {
return Err(AuthError::UnsupportedAlgorithm(algorithm.to_owned()));
}
let mut credential = None;
let mut signed_headers = None;
let mut signature = None;
for part in rest.split(',') {
let part = part.trim();
if let Some(value) = part.strip_prefix("Credential=") {
credential = Some(value);
} else if let Some(value) = part.strip_prefix("SignedHeaders=") {
signed_headers = Some(value);
} else if let Some(value) = part.strip_prefix("Signature=") {
signature = Some(value);
}
}
let credential = credential.ok_or(AuthError::InvalidAuthHeader)?;
let signed_headers = signed_headers.ok_or(AuthError::InvalidAuthHeader)?;
let signature = signature.ok_or(AuthError::InvalidAuthHeader)?;
let cred_parts: Vec<&str> = credential.splitn(5, '/').collect();
if cred_parts.len() != 5 || cred_parts[4] != "aws4_request" {
return Err(AuthError::InvalidCredential);
}
let parsed_signed_headers: Vec<String> =
signed_headers.split(';').map(ToOwned::to_owned).collect();
Ok(ParsedAuth {
algorithm: algorithm.to_owned(),
access_key_id: cred_parts[0].to_owned(),
date: cred_parts[1].to_owned(),
region: cred_parts[2].to_owned(),
service: cred_parts[3].to_owned(),
signed_headers: parsed_signed_headers,
signature: signature.to_owned(),
})
}
#[must_use]
pub fn build_string_to_sign(
timestamp: &str,
credential_scope: &str,
canonical_request_hash: &str,
) -> String {
format!("{SUPPORTED_ALGORITHM}\n{timestamp}\n{credential_scope}\n{canonical_request_hash}")
}
#[must_use]
pub fn derive_signing_key(secret_key: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
let date_key = hmac_sha256(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
let date_region_key = hmac_sha256(&date_key, region.as_bytes());
let date_region_service_key = hmac_sha256(&date_region_key, service.as_bytes());
hmac_sha256(&date_region_service_key, b"aws4_request")
}
#[must_use]
pub fn compute_signature(signing_key: &[u8], data: &str) -> String {
let sig = hmac_sha256(signing_key, data.as_bytes());
hex::encode(sig)
}
pub fn verify_sigv4(
parts: &http::request::Parts,
body_hash: &str,
credential_provider: &dyn CredentialProvider,
) -> Result<AuthResult, AuthError> {
let auth_header = parts
.headers
.get(http::header::AUTHORIZATION)
.ok_or(AuthError::MissingAuthHeader)?
.to_str()
.map_err(|_| AuthError::InvalidAuthHeader)?;
debug!(auth_header, "Parsing SigV4 authorization header");
let parsed = parse_authorization_header(auth_header)?;
let secret_key = credential_provider.get_secret_key(&parsed.access_key_id)?;
let timestamp = extract_header_value(parts, "x-amz-date")?;
debug!(
access_key_id = %parsed.access_key_id,
date = %parsed.date,
region = %parsed.region,
service = %parsed.service,
"Verifying SigV4 signature"
);
let method = parts.method.as_str();
let uri = parts.uri.path();
let query = parts.uri.query().unwrap_or("");
let signed_header_refs: Vec<&str> = parsed.signed_headers.iter().map(String::as_str).collect();
let header_pairs: Vec<(&str, &str)> = collect_signed_headers(parts, &signed_header_refs)?;
let payload_hash = parts
.headers
.get("x-amz-content-sha256")
.and_then(|v| v.to_str().ok())
.unwrap_or(body_hash);
let canonical_request = build_canonical_request(
method,
uri,
query,
&header_pairs,
&signed_header_refs,
payload_hash,
);
let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
let credential_scope = format!(
"{}/{}/{}/aws4_request",
parsed.date, parsed.region, parsed.service
);
let string_to_sign = build_string_to_sign(×tamp, &credential_scope, &canonical_hash);
let signing_key =
derive_signing_key(&secret_key, &parsed.date, &parsed.region, &parsed.service);
let expected_signature = compute_signature(&signing_key, &string_to_sign);
let provided_bytes = parsed.signature.as_bytes();
let expected_bytes = expected_signature.as_bytes();
if provided_bytes.ct_eq(expected_bytes).into() {
debug!(access_key_id = %parsed.access_key_id, "Signature verification succeeded");
Ok(AuthResult {
access_key_id: parsed.access_key_id,
region: parsed.region,
service: parsed.service,
signed_headers: parsed.signed_headers,
})
} else {
debug!(
expected = %expected_signature,
provided = %parsed.signature,
"Signature mismatch"
);
Err(AuthError::SignatureDoesNotMatch)
}
}
fn extract_header_value(parts: &http::request::Parts, name: &str) -> Result<String, AuthError> {
parts
.headers
.get(name)
.ok_or_else(|| AuthError::MissingHeader(name.to_owned()))?
.to_str()
.map(ToOwned::to_owned)
.map_err(|_| AuthError::MissingHeader(name.to_owned()))
}
fn collect_signed_headers<'a>(
parts: &'a http::request::Parts,
signed_headers: &[&'a str],
) -> Result<Vec<(&'a str, &'a str)>, AuthError> {
let mut result = Vec::with_capacity(signed_headers.len());
for &name in signed_headers {
let value = parts
.headers
.get(name)
.ok_or_else(|| AuthError::MissingHeader(name.to_owned()))?
.to_str()
.map_err(|_| AuthError::MissingHeader(name.to_owned()))?;
result.push((name, value));
}
Ok(result)
}
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can accept keys of any length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
#[must_use]
pub fn hash_payload(payload: &[u8]) -> String {
hex::encode(Sha256::digest(payload))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{canonical::build_signed_headers_string, credentials::StaticCredentialProvider};
const TEST_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
const TEST_SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
const TEST_DATE: &str = "20130524";
const TEST_REGION: &str = "us-east-1";
const TEST_SERVICE: &str = "s3";
fn test_credential_provider() -> StaticCredentialProvider {
StaticCredentialProvider::new(vec![(
TEST_ACCESS_KEY.to_owned(),
TEST_SECRET_KEY.to_owned(),
)])
}
#[test]
fn test_should_derive_signing_key_matching_aws_test_vector() {
let key = derive_signing_key(TEST_SECRET_KEY, TEST_DATE, TEST_REGION, TEST_SERVICE);
assert_eq!(key.len(), 32); }
#[test]
fn test_should_parse_authorization_header() {
let header = "AWS4-HMAC-SHA256 \
Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,\
SignedHeaders=host;range;x-amz-content-sha256;x-amz-date,\
Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41";
let parsed = parse_authorization_header(header).unwrap();
assert_eq!(parsed.algorithm, "AWS4-HMAC-SHA256");
assert_eq!(parsed.access_key_id, "AKIAIOSFODNN7EXAMPLE");
assert_eq!(parsed.date, "20130524");
assert_eq!(parsed.region, "us-east-1");
assert_eq!(parsed.service, "s3");
assert_eq!(
parsed.signed_headers,
vec!["host", "range", "x-amz-content-sha256", "x-amz-date"]
);
assert_eq!(
parsed.signature,
"f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
);
}
#[test]
fn test_should_reject_unsupported_algorithm() {
let header = "AWS4-HMAC-SHA512 \
Credential=AKID/20130524/us-east-1/s3/aws4_request,SignedHeaders=host,\
Signature=abc";
let result = parse_authorization_header(header);
assert!(matches!(result, Err(AuthError::UnsupportedAlgorithm(_))));
}
#[test]
fn test_should_reject_invalid_credential_format() {
let header =
"AWS4-HMAC-SHA256 Credential=AKID/20130524/us-east-1,SignedHeaders=host,Signature=abc";
let result = parse_authorization_header(header);
assert!(matches!(result, Err(AuthError::InvalidCredential)));
}
#[test]
fn test_should_build_string_to_sign_matching_aws_example() {
let canonical_hash = "7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
let sts = build_string_to_sign(
"20130524T000000Z",
"20130524/us-east-1/s3/aws4_request",
canonical_hash,
);
#[rustfmt::skip]
let expected = "AWS4-HMAC-SHA256\n\
20130524T000000Z\n\
20130524/us-east-1/s3/aws4_request\n\
7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
assert_eq!(sts, expected);
}
#[test]
fn test_should_compute_correct_signature_for_aws_get_object_example() {
let signing_key = derive_signing_key(TEST_SECRET_KEY, TEST_DATE, TEST_REGION, TEST_SERVICE);
#[rustfmt::skip]
let string_to_sign = "AWS4-HMAC-SHA256\n\
20130524T000000Z\n\
20130524/us-east-1/s3/aws4_request\n\
7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972";
let signature = compute_signature(&signing_key, string_to_sign);
assert_eq!(
signature,
"f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
);
}
#[test]
fn test_should_verify_sigv4_success() {
let provider = test_credential_provider();
let empty_hash = hash_payload(b"");
let mut builder = http::Request::builder()
.method("GET")
.uri("http://examplebucket.s3.amazonaws.com/test.txt")
.header("host", "examplebucket.s3.amazonaws.com")
.header("range", "bytes=0-9")
.header("x-amz-content-sha256", &empty_hash)
.header("x-amz-date", "20130524T000000Z");
let auth_value = format!(
"AWS4-HMAC-SHA256 \
Credential={TEST_ACCESS_KEY}/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;\
range;x-amz-content-sha256;x-amz-date,\
Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
);
builder = builder.header(http::header::AUTHORIZATION, &auth_value);
let (parts, _body) = builder.body(()).unwrap().into_parts();
let result = verify_sigv4(&parts, &empty_hash, &provider);
assert!(result.is_ok());
let auth_result = result.unwrap();
assert_eq!(auth_result.access_key_id, TEST_ACCESS_KEY);
assert_eq!(auth_result.region, "us-east-1");
assert_eq!(auth_result.service, "s3");
}
#[test]
fn test_should_fail_sigv4_with_wrong_key() {
let provider = StaticCredentialProvider::new(vec![(
TEST_ACCESS_KEY.to_owned(),
"WRONG_SECRET_KEY".to_owned(),
)]);
let empty_hash = hash_payload(b"");
let auth_value = format!(
"AWS4-HMAC-SHA256 \
Credential={TEST_ACCESS_KEY}/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;\
range;x-amz-content-sha256;x-amz-date,\
Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
);
let (parts, _body) = http::Request::builder()
.method("GET")
.uri("http://examplebucket.s3.amazonaws.com/test.txt")
.header("host", "examplebucket.s3.amazonaws.com")
.header("range", "bytes=0-9")
.header("x-amz-content-sha256", &empty_hash)
.header("x-amz-date", "20130524T000000Z")
.header(http::header::AUTHORIZATION, &auth_value)
.body(())
.unwrap()
.into_parts();
let result = verify_sigv4(&parts, &empty_hash, &provider);
assert!(matches!(result, Err(AuthError::SignatureDoesNotMatch)));
}
#[test]
fn test_should_fail_sigv4_with_missing_auth_header() {
let provider = test_credential_provider();
let empty_hash = hash_payload(b"");
let (parts, _body) = http::Request::builder()
.method("GET")
.uri("http://example.com/")
.header("host", "example.com")
.body(())
.unwrap()
.into_parts();
let result = verify_sigv4(&parts, &empty_hash, &provider);
assert!(matches!(result, Err(AuthError::MissingAuthHeader)));
}
#[test]
fn test_should_fail_sigv4_with_unknown_access_key() {
let provider = StaticCredentialProvider::new(vec![]);
let empty_hash = hash_payload(b"");
let auth_value = "AWS4-HMAC-SHA256 \
Credential=UNKNOWN_KEY/20130524/us-east-1/s3/aws4_request,\
SignedHeaders=host;x-amz-date,Signature=abc123"
.to_owned();
let (parts, _body) = http::Request::builder()
.method("GET")
.uri("http://example.com/")
.header("host", "example.com")
.header("x-amz-date", "20130524T000000Z")
.header(http::header::AUTHORIZATION, &auth_value)
.body(())
.unwrap()
.into_parts();
let result = verify_sigv4(&parts, &empty_hash, &provider);
assert!(matches!(result, Err(AuthError::AccessKeyNotFound(_))));
}
#[test]
fn test_should_hash_empty_payload() {
assert_eq!(
hash_payload(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn test_should_hash_nonempty_payload() {
let hash = hash_payload(b"Hello, World!");
assert_eq!(hash.len(), 64); assert_ne!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn test_should_build_signed_headers_string_from_parsed() {
let headers = [
"host".to_owned(),
"range".to_owned(),
"x-amz-content-sha256".to_owned(),
"x-amz-date".to_owned(),
];
let refs: Vec<&str> = headers.iter().map(String::as_str).collect();
let result = build_signed_headers_string(&refs);
assert_eq!(result, "host;range;x-amz-content-sha256;x-amz-date");
}
}