s3-headers-lite 0.1.0

A minimal, lightweight alternative to the official AWS crates. Only the headers necessary for communicating with S3-like services.
Documentation
use crate::{
    aws_math::get_sha256,
    s3::{self, S3DateTime, S3HeadersBuilder, ValidS3Url},
};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use time::Date;

// --- AWS IoT SigV4 SDK KAT vectors (from tests/aws-sig-v4-test-suite/) ---

/// Build the Authorization header for the AWS IoT SDK's nominal test params
/// (fixed keys/region/service/date/headers/payload; caller supplies the URL).
fn aws_iot_sign(url: &ValidS3Url) -> String {
    let dt = Date::from_calendar_date(2021, time::Month::August, 11)
        .unwrap()
        .with_hms(0, 15, 58)
        .unwrap()
        .assume_utc();
    let headers: Vec<(&'static str, String)> = vec![
        ("Host", "iam.amazonaws.com".to_owned()),
        (
            "Content-Type",
            "application/x-www-form-urlencoded; charset=utf-8".to_owned(),
        ),
        ("X-Amz-Date", "20210811T001558Z".to_owned()),
    ];
    let options = S3HeadersBuilder::new(url)
        .set_access_key("AKIAIOSFODNN7EXAMPLE")
        .set_secret_key("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY")
        .set_region("us-east-1")
        .set_service("iam")
        .set_datetime(S3DateTime::UnixTimestamp(dt.unix_timestamp()))
        .set_method("GET")
        .set_headers(&headers)
        .set_payload_hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
    s3::get_authorization_header(options)
}

/// Extract the hex signature from an Authorization header.
fn extract_signature(auth_header: &str) -> &str {
    auth_header.rsplit("Signature=").next().unwrap()
}

// --- Issue #2 regression: x-amz-date and credential scope must use the same date ---

#[test]
fn get_headers_uses_consistent_datetime_issue_2() {
    // Even with S3DateTime::Now (wall-clock), the x-amz-date header and the
    // credential-scope date in the Authorization header must never diverge
    // across a second boundary. The fix resolves the datetime once via
    // S3DateTime::Resolved and reuses it for both (issue #2).
    let url = ValidS3Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
    let options = S3HeadersBuilder::new(&url)
        .set_access_key("AKIAIOSFODNN7EXAMPLE")
        .set_secret_key("wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY")
        .set_region("us-east-1")
        .set_service("s3")
        .set_datetime(S3DateTime::Now)
        .set_method("GET")
        .set_payload_hash(crate::s3::EMPTY_PAYLOAD_SHA);
    let headers = s3::get_headers(options);

    let amz_date = headers
        .iter()
        .find(|(k, _)| *k == "x-amz-date")
        .map(|(_, v)| v.as_str())
        .expect("x-amz-date header must be present");

    let auth = headers
        .iter()
        .find(|(k, _)| *k == "Authorization")
        .map(|(_, v)| v.as_str())
        .expect("Authorization header must be present");

    // The credential scope is: Credential=ACCESS_KEY/YYYYMMDD/REGION/SERVICE/aws4_request.
    let scope_date = auth
        .split('/')
        .nth(1)
        .expect("credential scope must contain a date segment");

    assert_eq!(
        &amz_date[..8],
        scope_date,
        "x-amz-date date ({}) must match credential scope date ({}) — issue #2",
        &amz_date[..8],
        scope_date,
    );
}

#[test]
fn resolved_datetime_returns_held_value() {
    let dt = time::OffsetDateTime::from_unix_timestamp(1_234_567_890).unwrap();
    assert_eq!(S3DateTime::Resolved(dt).get_offset_datetime(), dt);
}

#[test]
fn aws_iot_kat_iam_listusers_nominal() {
    let url = ValidS3Url::parse("https://iam.amazonaws.com/?Action=ListUsers&Version=2010-05-08")
        .unwrap();
    assert_eq!(
        extract_signature(&aws_iot_sign(&url)),
        "20fdb62349e7104f9ce4184a444fedfbd19e40a5e31d57d433689c5a5138fa99"
    );
}

#[test]
#[ignore = "diverges: the IoT SDK double-encodes '=' in query values; our crate follows the SigV4 spec (single-encode to %3D). Investigate if needed."]
fn aws_iot_kat_query_value_has_equals() {
    // IoT SDK: QUERY_VALUE_HAS_EQUALS = "quantum==value" — double-encoded equals.
    let url = ValidS3Url::parse("https://iam.amazonaws.com/?quantum==value").unwrap();
    assert_eq!(
        extract_signature(&aws_iot_sign(&url)),
        "2e005dbe8d1223309467fc3f3b14310110bd45358a4f598e9f5e32723036461d"
    );
}

#[test]
fn aws_iot_kat_query_no_param_value() {
    // IoT SDK: QUERY_STRING_NO_PARAM_VALUE = "param=&param2=" — empty values.
    let url = ValidS3Url::parse("https://iam.amazonaws.com/?param=&param2=").unwrap();
    assert_eq!(
        extract_signature(&aws_iot_sign(&url)),
        "9eed8862e36ac9861f0ea0be863ef6d825de854c8eb9da072637dcc64e5ef919"
    );
}

#[test]
fn test_get_object() {
    let url = ValidS3Url::parse("https://jsonlog.s3.amazonaws.com/test.json").unwrap();
    let options = S3HeadersBuilder::new(&url)
        .set_access_key("some_access_key")
        .set_secret_key("some_secret_key")
        .set_region("some_place")
        .set_datetime(S3DateTime::UnixTimestamp(0))
        .set_method("GET")
        .set_service("s3");
    let result = s3::get_headers(options);

    assert_eq!(result, vec![
    ("Host", "jsonlog.s3.amazonaws.com".to_owned()),
    ("x-amz-content-sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned()),
    ("x-amz-date", "19700101T000000Z".to_owned()),
    (
      "Authorization",
      "AWS4-HMAC-SHA256 Credential=some_access_key/19700101/some_place/s3/aws4_request,SignedHeaders=host;x-amz-content-sha256;x-amz-date,Signature=521595a9eeee7092d3b2cc49d4db7cb828a5db5c7ad5136c149db0b0e7277f83".to_owned()
    )
  ])
}

#[test]
fn test_put_object() {
    let url = ValidS3Url::parse("https://examplebucket.s3.amazonaws.com/test$file.text").unwrap();
    let headers = &[("x-amz-storage-class", "REDUCED_REDUNDANCY".to_owned())];
    let content = b"".as_slice();
    let sha = get_sha256(content);
    let options = S3HeadersBuilder::new(&url)
        .set_access_key("some_access_key")
        .set_secret_key("some_secret_key")
        .set_region("some_place")
        .set_datetime(S3DateTime::UnixTimestamp(1369324800)) // 20130524T000000Z
        .set_headers(headers)
        .set_method("PUT")
        .set_service("s3")
        .set_payload_hash(&sha);
    let result = s3::get_headers(options);

    assert_eq!(result, vec![
    ("x-amz-storage-class", "REDUCED_REDUNDANCY".to_owned()),
    ("Host", "examplebucket.s3.amazonaws.com".to_owned()),
    ("x-amz-content-sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned()),
    ("x-amz-date", "20130523T160000Z".to_owned()),
    (
      "Authorization",
      "AWS4-HMAC-SHA256 Credential=some_access_key/20130523/some_place/s3/aws4_request,SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-storage-class,Signature=7e2911c8225f7591609bcbdc2faf8c443a898d8c83fc35b6a23f0b0e8084da60".to_owned()
    )
  ])
}

// --- presign_get: round-trip against the KAT-verified low-level primitives ---

fn fixture_datetime() -> time::OffsetDateTime {
    Date::from_calendar_date(2013, 5.try_into().unwrap(), 24)
        .unwrap()
        .with_hms(0, 0, 0)
        .unwrap()
        .assume_utc()
}

/// Hand-built canonical query string for the fixture's presign params, kept as a
/// literal so the test is an independent oracle for what `presign_get` must build
/// via `authorization_query_params_no_sig`.
const FIXTURE_CANONICAL_QUERY: &str = "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";

#[test]
fn test_presign_get_signature_matches_kat_primitives() {
    let datetime = fixture_datetime();
    // Independently build the canonical request for a host-only UNSIGNED-PAYLOAD
    // presigned GET, and sign it with the same KAT-verified primitives
    // `presign_get` uses internally.
    let expected_canonical_request = format!(
        "GET\n/test.txt\n{}\nhost:examplebucket.s3.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD",
        FIXTURE_CANONICAL_QUERY
    );
    let string_to_sign = crate::aws_format::string_to_sign(
        &datetime,
        "us-east-1",
        "s3",
        &expected_canonical_request,
    );
    let signing_key = crate::aws_math::get_signature_key(
        &datetime,
        "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
        "us-east-1",
        "s3",
    );
    let mut hmac = Hmac::<Sha256>::new_from_slice(&signing_key).unwrap();
    hmac.update(string_to_sign.as_bytes());
    let expected_signature = hex::encode(hmac.finalize().into_bytes());

    let url = ValidS3Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
    let presigned = s3::presign_get(
        &url,
        "AKIAIOSFODNN7EXAMPLE",
        "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
        "us-east-1",
        "s3",
        S3DateTime::UnixTimestamp(datetime.unix_timestamp()),
        86400,
    );

    // The URL must carry exactly the signature derived over the independent
    // canonical request. A construction bug in `presign_get` would diverge here.
    assert_eq!(
        tail_signature(&presigned),
        expected_signature,
        "presigned url: {presigned}"
    );
}

#[test]
fn test_presign_get_url_shape() {
    let datetime = fixture_datetime();
    let url = ValidS3Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
    let presigned = s3::presign_get(
        &url,
        "AKIAIOSFODNN7EXAMPLE",
        "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
        "us-east-1",
        "s3",
        S3DateTime::UnixTimestamp(datetime.unix_timestamp()),
        86400,
    );

    assert!(
        presigned.starts_with("https://examplebucket.s3.amazonaws.com/test.txt?"),
        "presigned url: {presigned}"
    );
    assert!(presigned.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"));
    assert!(presigned.contains("X-Amz-Date=20130524T000000Z"));
    assert!(presigned.contains("X-Amz-Expires=86400"));
    assert!(presigned.contains("X-Amz-SignedHeaders=host"));
    // The signature is a 64-character lowercase hex SHA-256 HMAC.
    let sig = tail_signature(&presigned);
    assert_eq!(sig.len(), 64);
    assert!(sig.bytes().all(|b| b.is_ascii_hexdigit()));
}

#[test]
fn test_presign_get_is_deterministic() {
    let datetime = fixture_datetime();
    let url = ValidS3Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
    let a = s3::presign_get(
        &url,
        "AKIAIOSFODNN7EXAMPLE",
        "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
        "us-east-1",
        "s3",
        S3DateTime::UnixTimestamp(datetime.unix_timestamp()),
        3600,
    );
    let b = s3::presign_get(
        &url,
        "AKIAIOSFODNN7EXAMPLE",
        "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
        "us-east-1",
        "s3",
        S3DateTime::UnixTimestamp(datetime.unix_timestamp()),
        3600,
    );
    assert_eq!(a, b);
}

#[test]
fn test_presign_get_expiry_changes_signature() {
    let datetime = fixture_datetime();
    let url = ValidS3Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
    let ts = S3DateTime::UnixTimestamp(datetime.unix_timestamp());
    let short = s3::presign_get(
        &url,
        "AKIAIOSFODNN7EXAMPLE",
        "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
        "us-east-1",
        "s3",
        ts,
        3600,
    );
    let long = s3::presign_get(
        &url,
        "AKIAIOSFODNN7EXAMPLE",
        "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
        "us-east-1",
        "s3",
        ts,
        7200,
    );
    assert_ne!(tail_signature(&short), tail_signature(&long));
}

/// Extract the value of `X-Amz-Signature=...` from the tail of a presigned URL.
fn tail_signature(presigned_url: &str) -> String {
    let marker = "&X-Amz-Signature=";
    let idx = presigned_url
        .find(marker)
        .expect("presigned url must carry X-Amz-Signature");
    presigned_url[idx + marker.len()..].to_owned()
}

#[test]
fn test_presign_get_with_key_matches_presign_get() {
    let datetime = fixture_datetime();
    let url = ValidS3Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
    let ts = S3DateTime::UnixTimestamp(datetime.unix_timestamp());

    let direct = s3::presign_get(
        &url,
        "AKIAIOSFODNN7EXAMPLE",
        "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
        "us-east-1",
        "s3",
        ts,
        3600,
    );
    let signing_key = crate::aws_math::get_signature_key(
        &datetime,
        "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
        "us-east-1",
        "s3",
    );
    let with_key = s3::presign_get_with_key(
        &url,
        "AKIAIOSFODNN7EXAMPLE",
        "us-east-1",
        "s3",
        datetime,
        3600,
        &signing_key,
    );
    // The cached-key variant must produce the identical URL and signature.
    assert_eq!(direct, with_key);
    assert_eq!(tail_signature(&direct), tail_signature(&with_key));
}