use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone)]
pub(super) struct AwsCredentials {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: Option<String>,
}
pub(super) struct SignRequest<'a> {
pub method: &'a str,
pub host: &'a str,
pub path: &'a str,
pub query: &'a str,
pub region: &'a str,
pub service: &'a str,
pub body: &'a [u8],
pub amz_date: &'a str,
pub date_stamp: &'a str,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct SignedHeaders {
pub host: String,
pub x_amz_date: String,
pub x_amz_content_sha256: String,
pub x_amz_security_token: Option<String>,
pub authorization: String,
}
pub(super) fn sign_request(req: &SignRequest<'_>, creds: &AwsCredentials) -> SignedHeaders {
let payload_hash = hex::encode(Sha256::digest(req.body));
let mut signed_headers_list: Vec<(&str, String)> = vec![
("host", req.host.to_ascii_lowercase()),
("x-amz-content-sha256", payload_hash.clone()),
("x-amz-date", req.amz_date.to_string()),
];
if let Some(token) = &creds.session_token {
signed_headers_list.push(("x-amz-security-token", token.clone()));
}
signed_headers_list.sort_by(|a, b| a.0.cmp(b.0));
let signed_header_names: String = signed_headers_list
.iter()
.map(|(n, _)| *n)
.collect::<Vec<_>>()
.join(";");
let canonical_headers: String = signed_headers_list
.iter()
.map(|(n, v)| format!("{n}:{}\n", v.trim()))
.collect();
let canonical_request = format!(
"{method}\n{path}\n{query}\n{canonical_headers}\n{signed}\n{payload}",
method = req.method,
path = req.path,
query = req.query,
canonical_headers = canonical_headers,
signed = signed_header_names,
payload = payload_hash,
);
let credential_scope = format!(
"{date}/{region}/{service}/aws4_request",
date = req.date_stamp,
region = req.region,
service = req.service,
);
let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{hash}",
amz_date = req.amz_date,
scope = credential_scope,
hash = canonical_request_hash,
);
let k_secret = format!("AWS4{}", creds.secret_access_key);
let k_date = hmac_sha256(k_secret.as_bytes(), req.date_stamp.as_bytes());
let k_region = hmac_sha256(&k_date, req.region.as_bytes());
let k_service = hmac_sha256(&k_region, req.service.as_bytes());
let k_signing = hmac_sha256(&k_service, b"aws4_request");
let signature = hex::encode(hmac_sha256(&k_signing, string_to_sign.as_bytes()));
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={ak}/{scope}, SignedHeaders={signed}, Signature={sig}",
ak = creds.access_key_id,
scope = credential_scope,
signed = signed_header_names,
sig = signature,
);
SignedHeaders {
host: req.host.to_string(),
x_amz_date: req.amz_date.to_string(),
x_amz_content_sha256: payload_hash,
x_amz_security_token: creds.session_token.clone(),
authorization,
}
}
fn hmac_sha256(key: &[u8], msg: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(msg);
mac.finalize().into_bytes().to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signing_is_deterministic() {
let creds = AwsCredentials {
access_key_id: "AKIDEXAMPLE".into(),
secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(),
session_token: None,
};
let common = SignRequest {
method: "POST",
host: "bedrock-runtime.us-east-1.amazonaws.com",
path: "/model/anthropic.claude/invoke-with-response-stream",
query: "",
region: "us-east-1",
service: "bedrock",
body: b"{\"messages\":[]}",
amz_date: "20260521T120000Z",
date_stamp: "20260521",
};
let a = sign_request(&common, &creds);
let b = sign_request(&common, &creds);
assert_eq!(a.authorization, b.authorization);
assert!(
a.authorization
.contains("Credential=AKIDEXAMPLE/20260521/us-east-1/bedrock/aws4_request")
);
assert!(
a.authorization
.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date")
);
let other = SignRequest {
body: b"{\"messages\":[{\"role\":\"user\"}]}",
..common
};
let c = sign_request(&other, &creds);
assert_ne!(a.authorization, c.authorization);
}
#[test]
fn session_token_appears_in_signed_headers() {
let creds = AwsCredentials {
access_key_id: "AKIDEXAMPLE".into(),
secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(),
session_token: Some("SESSIONTOKEN".into()),
};
let signed = sign_request(
&SignRequest {
method: "POST",
host: "bedrock-runtime.us-east-1.amazonaws.com",
path: "/model/anthropic.claude-3-5-sonnet-20241022-v2%3A0/invoke-with-response-stream",
query: "",
region: "us-east-1",
service: "bedrock",
body: b"{}",
amz_date: "20260521T120000Z",
date_stamp: "20260521",
},
&creds,
);
assert!(
signed.authorization.contains(
"SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token"
)
);
assert_eq!(signed.x_amz_security_token.as_deref(), Some("SESSIONTOKEN"));
}
#[test]
fn empty_body_hashes_to_known_constant() {
let creds = AwsCredentials {
access_key_id: "x".into(),
secret_access_key: "y".into(),
session_token: None,
};
let signed = sign_request(
&SignRequest {
method: "POST",
host: "h",
path: "/",
query: "",
region: "us-east-1",
service: "bedrock",
body: b"",
amz_date: "20260521T000000Z",
date_stamp: "20260521",
},
&creds,
);
assert_eq!(
signed.x_amz_content_sha256,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
}