tsafe-aws 1.2.0

AWS Secrets Manager and SSM HTTP client for tsafe — pull/push secrets from AWS to the local encrypted vault
Documentation
//! AWS Signature Version 4 signing for AWS JSON-service HTTP requests.
//!
//! Only the POST-with-JSON-body case is implemented (the AWS integrations in
//! this crate share this shape). SigV4 reference:
//! <https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html>

use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};

type HmacSha256 = Hmac<Sha256>;

const DEFAULT_SERVICE: &str = "secretsmanager";

/// Hex-encode the SHA-256 digest of `data`.
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()
}

/// Derive the SigV4 signing key from the secret access key.
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")
}

/// Output of the signing step — the HTTP headers to include in the 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,
    target: &'a str,
    body: &'a str,
    access_key_id: &'a str,
    secret_access_key: &'a str,
    session_token: Option<&'a str>,
    datetime: &'a str,
}

/// Sign a POST request to AWS Secrets Manager at a given `datetime`
/// (format: `YYYYMMDDTHHMMSSZ`).  The `datetime` parameter is taken as an
/// argument so tests can supply a fixed value.
pub fn sign_at(
    region: &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,
        target,
        body,
        access_key_id,
        secret_access_key,
        session_token,
        datetime,
    })
}

fn sign_request(req: SigningRequest<'_>) -> SigningOutput {
    let date = &req.datetime[..8]; // first 8 chars are YYYYMMDD
    let host = format!("{}.{}.amazonaws.com", req.service, req.region);
    let content_type = "application/x-amz-json-1.1";
    let payload_hash = sha256_hex(req.body.as_bytes());

    // Build canonical headers and signed-headers list (both sorted alphabetically).
    let (canonical_headers, signed_headers) = build_canonical_headers(
        content_type,
        &host,
        req.datetime,
        req.target,
        req.session_token,
    );

    // Canonical request (POST + / + empty query string + headers + payload hash)
    let canonical_request =
        format!("POST\n/\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}");

    // Credential scope
    let scope = format!("{}/{}/{}/aws4_request", date, req.region, req.service);

    // String to sign
    let string_to_sign = format!(
        "AWS4-HMAC-SHA256\n{}\n{}\n{}",
        req.datetime,
        scope,
        sha256_hex(canonical_request.as_bytes())
    );

    // Sign
    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()),
    }
}

/// Sign a POST request using the current UTC time.
pub fn sign(
    region: &str,
    target: &str,
    body: &str,
    access_key_id: &str,
    secret_access_key: &str,
    session_token: Option<&str>,
) -> SigningOutput {
    sign_for_service(
        DEFAULT_SERVICE,
        region,
        target,
        body,
        access_key_id,
        secret_access_key,
        session_token,
    )
}

/// Sign a POST request for an AWS JSON service using the current UTC time.
pub(crate) fn sign_for_service(
    service: &str,
    region: &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,
        target,
        body,
        access_key_id,
        secret_access_key,
        session_token,
        datetime: &datetime,
    })
}

/// Build (canonical_headers_block, signed_headers_string).
/// Headers are sorted alphabetically by name as required by SigV4.
///
/// Without session token:  content-type, host, x-amz-date, x-amz-target
/// With session token:     content-type, host, x-amz-date, x-amz-security-token, x-amz-target
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 {
        // x-amz-security-token sorts before x-amz-target ('s' < 't')
        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::*;

    // Known-good SigV4 test vector derived from the AWS documentation.
    // We verify our implementation produces the expected canonical request hash
    // and a structurally valid Authorization header.
    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() {
        // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
        assert_eq!(
            sha256_hex(b""),
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
    }

    #[test]
    fn sha256_hex_nonempty() {
        // SHA-256("abc") = ba7816bf8f01cfea414140de5dae2ec73b00361bbef0469348423f656b7c8dba (truncated for readability)
        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, 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,
            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, TARGET, BODY, KEY_ID, SECRET, None, FIXED_DATETIME);
        let b = sign_at(REGION, 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,
            TARGET,
            r#"{"MaxResults":100}"#,
            KEY_ID,
            SECRET,
            None,
            FIXED_DATETIME,
        );
        let b = sign_at(
            REGION,
            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, TARGET, BODY, KEY_ID, SECRET, None, FIXED_DATETIME);
        let b = sign_at(
            REGION,
            TARGET,
            BODY,
            KEY_ID,
            "different-secret",
            None,
            FIXED_DATETIME,
        );
        assert_ne!(a.authorization, b.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"),
        );
        // x-amz-security-token must appear before x-amz-target in the block
        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"
        );
    }
}