use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
const DEFAULT_SERVICE: &str = "secretsmanager";
pub fn sha256_hex(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
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_key: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
let k_date = hmac_sha256(format!("AWS4{secret_key}").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")
}
pub struct SigningOutput {
pub authorization: String,
pub x_amz_date: String,
pub x_amz_security_token: Option<String>,
}
struct SigningRequest<'a> {
service: &'a str,
region: &'a str,
host: &'a str,
target: &'a str,
body: &'a str,
access_key_id: &'a str,
secret_access_key: &'a str,
session_token: Option<&'a str>,
datetime: &'a str,
}
#[allow(clippy::too_many_arguments)]
pub fn sign_at(
region: &str,
host: &str,
target: &str,
body: &str,
access_key_id: &str,
secret_access_key: &str,
session_token: Option<&str>,
datetime: &str,
) -> SigningOutput {
sign_request(SigningRequest {
service: DEFAULT_SERVICE,
region,
host,
target,
body,
access_key_id,
secret_access_key,
session_token,
datetime,
})
}
fn sign_request(req: SigningRequest<'_>) -> SigningOutput {
let date = &req.datetime[..8]; let content_type = "application/x-amz-json-1.1";
let payload_hash = sha256_hex(req.body.as_bytes());
let (canonical_headers, signed_headers) = build_canonical_headers(
content_type,
req.host,
req.datetime,
req.target,
req.session_token,
);
let canonical_request =
format!("POST\n/\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}");
let scope = format!("{}/{}/{}/aws4_request", date, req.region, req.service);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
req.datetime,
scope,
sha256_hex(canonical_request.as_bytes())
);
let signing_key = derive_signing_key(req.secret_access_key, date, req.region, req.service);
let signature: String = hmac_sha256(&signing_key, string_to_sign.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let authorization = format!(
"AWS4-HMAC-SHA256 Credential={}/{},\
SignedHeaders={},Signature={}",
req.access_key_id, scope, signed_headers, signature
);
SigningOutput {
authorization,
x_amz_date: req.datetime.to_string(),
x_amz_security_token: req.session_token.map(|s| s.to_string()),
}
}
pub fn sign(
region: &str,
host: &str,
target: &str,
body: &str,
access_key_id: &str,
secret_access_key: &str,
session_token: Option<&str>,
) -> SigningOutput {
sign_for_service(
DEFAULT_SERVICE,
region,
host,
target,
body,
access_key_id,
secret_access_key,
session_token,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn sign_for_service(
service: &str,
region: &str,
host: &str,
target: &str,
body: &str,
access_key_id: &str,
secret_access_key: &str,
session_token: Option<&str>,
) -> SigningOutput {
let datetime = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
sign_request(SigningRequest {
service,
region,
host,
target,
body,
access_key_id,
secret_access_key,
session_token,
datetime: &datetime,
})
}
fn build_canonical_headers(
content_type: &str,
host: &str,
datetime: &str,
target: &str,
session_token: Option<&str>,
) -> (String, String) {
if let Some(token) = session_token {
let canonical = format!(
"content-type:{content_type}\nhost:{host}\n\
x-amz-date:{datetime}\nx-amz-security-token:{token}\nx-amz-target:{target}\n"
);
let signed = "content-type;host;x-amz-date;x-amz-security-token;x-amz-target".to_string();
(canonical, signed)
} else {
let canonical = format!(
"content-type:{content_type}\nhost:{host}\n\
x-amz-date:{datetime}\nx-amz-target:{target}\n"
);
let signed = "content-type;host;x-amz-date;x-amz-target".to_string();
(canonical, signed)
}
}
#[cfg(test)]
mod tests {
use super::*;
const FIXED_DATETIME: &str = "20150830T123600Z";
const REGION: &str = "us-east-1";
const TARGET: &str = "secretsmanager.ListSecrets";
const BODY: &str = r#"{"MaxResults":100}"#;
const KEY_ID: &str = "AKIDEXAMPLE";
const SECRET: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
#[test]
fn sha256_hex_known_value() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn sha256_hex_nonempty() {
let h = sha256_hex(b"abc");
assert!(h.starts_with("ba7816bf"));
assert_eq!(h.len(), 64);
}
#[test]
fn sign_at_produces_valid_authorization_header() {
let out = sign_at(
REGION,
"secretsmanager.us-east-1.amazonaws.com",
TARGET,
BODY,
KEY_ID,
SECRET,
None,
FIXED_DATETIME,
);
assert!(out.authorization.starts_with("AWS4-HMAC-SHA256 "));
assert!(out
.authorization
.contains("Credential=AKIDEXAMPLE/20150830/"));
assert!(out
.authorization
.contains("SignedHeaders=content-type;host;x-amz-date;x-amz-target"));
assert!(out.authorization.contains("Signature="));
assert_eq!(out.x_amz_date, FIXED_DATETIME);
assert!(out.x_amz_security_token.is_none());
}
#[test]
fn sign_at_with_session_token_includes_security_token_header() {
let out = sign_at(
REGION,
"secretsmanager.us-east-1.amazonaws.com",
TARGET,
BODY,
KEY_ID,
SECRET,
Some("session-token-abc"),
FIXED_DATETIME,
);
assert!(out.authorization.contains("x-amz-security-token"));
assert_eq!(
out.x_amz_security_token.as_deref(),
Some("session-token-abc")
);
}
#[test]
fn sign_at_deterministic_for_same_inputs() {
let a = sign_at(
REGION,
"secretsmanager.us-east-1.amazonaws.com",
TARGET,
BODY,
KEY_ID,
SECRET,
None,
FIXED_DATETIME,
);
let b = sign_at(
REGION,
"secretsmanager.us-east-1.amazonaws.com",
TARGET,
BODY,
KEY_ID,
SECRET,
None,
FIXED_DATETIME,
);
assert_eq!(a.authorization, b.authorization);
}
#[test]
fn sign_at_different_body_produces_different_signature() {
let a = sign_at(
REGION,
"secretsmanager.us-east-1.amazonaws.com",
TARGET,
r#"{"MaxResults":100}"#,
KEY_ID,
SECRET,
None,
FIXED_DATETIME,
);
let b = sign_at(
REGION,
"secretsmanager.us-east-1.amazonaws.com",
TARGET,
r#"{"MaxResults":50}"#,
KEY_ID,
SECRET,
None,
FIXED_DATETIME,
);
assert_ne!(a.authorization, b.authorization);
}
#[test]
fn sign_at_different_key_produces_different_signature() {
let a = sign_at(
REGION,
"secretsmanager.us-east-1.amazonaws.com",
TARGET,
BODY,
KEY_ID,
SECRET,
None,
FIXED_DATETIME,
);
let b = sign_at(
REGION,
"secretsmanager.us-east-1.amazonaws.com",
TARGET,
BODY,
KEY_ID,
"different-secret",
None,
FIXED_DATETIME,
);
assert_ne!(a.authorization, b.authorization);
}
#[test]
fn sign_at_different_host_produces_different_signature() {
let standard = sign_at(
REGION,
"secretsmanager.us-east-1.amazonaws.com",
TARGET,
BODY,
KEY_ID,
SECRET,
None,
FIXED_DATETIME,
);
let custom = sign_at(
REGION,
"localhost:4566",
TARGET,
BODY,
KEY_ID,
SECRET,
None,
FIXED_DATETIME,
);
assert_ne!(standard.authorization, custom.authorization);
}
#[test]
fn canonical_headers_without_token_sorted_correctly() {
let (canonical, signed) = build_canonical_headers(
"application/x-amz-json-1.1",
"secretsmanager.us-east-1.amazonaws.com",
"20150830T123600Z",
"secretsmanager.ListSecrets",
None,
);
assert!(canonical.starts_with("content-type:"));
assert!(canonical.contains("\nhost:"));
assert!(canonical.contains("\nx-amz-date:"));
assert!(canonical.contains("\nx-amz-target:"));
assert!(!canonical.contains("x-amz-security-token"));
assert_eq!(signed, "content-type;host;x-amz-date;x-amz-target");
}
#[test]
fn canonical_headers_with_token_sorted_correctly() {
let (canonical, signed) = build_canonical_headers(
"application/x-amz-json-1.1",
"secretsmanager.us-east-1.amazonaws.com",
"20150830T123600Z",
"secretsmanager.ListSecrets",
Some("tok"),
);
let st_pos = canonical.find("x-amz-security-token").unwrap();
let target_pos = canonical.find("x-amz-target").unwrap();
assert!(
st_pos < target_pos,
"x-amz-security-token must sort before x-amz-target"
);
assert_eq!(
signed,
"content-type;host;x-amz-date;x-amz-security-token;x-amz-target"
);
}
}