mod_aws_sigv4/
lib.rs

1use anyhow::Context;
2use aws_lc_rs::hmac::Key;
3use chrono::{DateTime, Utc};
4use config::{any_err, from_lua_value, get_or_create_sub_module};
5use data_encoding::HEXLOWER;
6use data_loader::KeySource;
7use mlua::{Lua, LuaSerdeExt, Value};
8use percent_encoding::{percent_encode, AsciiSet, CONTROLS};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11
12/// AWS SigV4 URI encoding set
13/// Encodes everything except: A-Z a-z 0-9 - _ . ~
14const URI_ENCODE_SET: &AsciiSet = &CONTROLS
15    .add(b' ')
16    .add(b'!')
17    .add(b'"')
18    .add(b'#')
19    .add(b'$')
20    .add(b'%')
21    .add(b'&')
22    .add(b'\'')
23    .add(b'(')
24    .add(b')')
25    .add(b'*')
26    .add(b'+')
27    .add(b',')
28    .add(b'/')
29    .add(b':')
30    .add(b';')
31    .add(b'=')
32    .add(b'?')
33    .add(b'@')
34    .add(b'[')
35    .add(b']');
36
37#[derive(Deserialize, Debug)]
38pub struct SigV4Request {
39    /// AWS access key ID (can be a KeySource)
40    pub access_key: KeySource,
41    /// AWS secret access key (can be a KeySource)
42    pub secret_key: KeySource,
43    /// AWS region (e.g., "us-east-1")
44    pub region: String,
45    /// AWS service name (e.g., "s3", "sns", "sqs")
46    pub service: String,
47    /// HTTP method (e.g., "GET", "POST")
48    pub method: String,
49    /// URI path (e.g., "/")
50    pub uri: String,
51    /// Optional query string parameters
52    #[serde(default)]
53    pub query_params: BTreeMap<String, String>,
54    /// HTTP headers to sign
55    #[serde(default)]
56    pub headers: BTreeMap<String, String>,
57    /// Request payload (body)
58    #[serde(default)]
59    pub payload: String,
60    /// Optional timestamp (defaults to current time)
61    pub timestamp: Option<DateTime<Utc>>,
62    /// Optional session token for temporary credentials
63    pub session_token: Option<String>,
64}
65
66#[derive(Deserialize, Serialize, Debug)]
67pub struct SigV4Response {
68    /// The authorization header value
69    pub authorization: String,
70    /// The timestamp used in ISO8601 format (YYYYMMDD'T'HHMMSS'Z')
71    pub timestamp: String,
72    /// The canonical request (for debugging)
73    pub canonical_request: String,
74    /// The string to sign (for debugging)
75    pub string_to_sign: String,
76    /// The signature
77    pub signature: String,
78}
79
80fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
81    let key = Key::new(aws_lc_rs::hmac::HMAC_SHA256, key);
82    let tag = aws_lc_rs::hmac::sign(&key, data);
83    tag.as_ref().to_vec()
84}
85
86fn sha256_hex(data: &[u8]) -> String {
87    use aws_lc_rs::digest;
88    let hash = digest::digest(&digest::SHA256, data);
89    HEXLOWER.encode(hash.as_ref())
90}
91
92fn uri_encode(input: &str) -> String {
93    percent_encode(input.as_bytes(), URI_ENCODE_SET).to_string()
94}
95
96fn create_canonical_uri(path: &str) -> String {
97    if path.is_empty() {
98        "/".to_string()
99    } else {
100        // Split path and encode each segment
101        path.split('/')
102            .map(uri_encode)
103            .collect::<Vec<_>>()
104            .join("/")
105    }
106}
107
108fn create_canonical_query_string(params: &BTreeMap<String, String>) -> String {
109    if params.is_empty() {
110        return String::new();
111    }
112
113    // Sort parameters and URI encode them.
114    //
115    // We collect into a Vec and sort on the *encoded* keys to ensure
116    // the ordering is correct even when encoding changes the byte
117    // ordering of the original key/value strings.
118    let mut encoded_params: Vec<(String, String)> = params
119        .iter()
120        .map(|(k, v)| (uri_encode(k), uri_encode(v)))
121        .collect();
122    encoded_params.sort();
123
124    encoded_params
125        .iter()
126        .map(|(k, v)| format!("{}={}", k, v))
127        .collect::<Vec<_>>()
128        .join("&")
129}
130
131/// AWS SigV4 Trimall: per the spec, remove leading/trailing ASCII SP/HTAB
132/// and collapse internal runs of SP/HTAB to a single space, but do NOT
133/// touch whitespace inside double-quoted string segments.
134///
135/// See: <https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html>
136/// ("Trimall function").
137fn trimall(value: &str) -> String {
138    let bytes = value.as_bytes();
139    let mut out = String::with_capacity(value.len());
140    let mut in_quotes = false;
141    let mut pending_space = false;
142    let mut have_emitted = false;
143
144    for &b in bytes {
145        if b == b'"' {
146            if pending_space && have_emitted {
147                out.push(' ');
148            }
149            pending_space = false;
150            out.push('"');
151            have_emitted = true;
152            in_quotes = !in_quotes;
153            continue;
154        }
155
156        if in_quotes {
157            out.push(b as char);
158            continue;
159        }
160
161        if b == b' ' || b == b'\t' {
162            pending_space = true;
163            continue;
164        }
165
166        if pending_space && have_emitted {
167            out.push(' ');
168        }
169        pending_space = false;
170        out.push(b as char);
171        have_emitted = true;
172    }
173
174    out
175}
176
177fn create_canonical_headers(headers: &BTreeMap<String, String>) -> (String, String) {
178    // Convert header names to lowercase and apply AWS Trimall to values.
179    let canonical_headers: BTreeMap<String, String> = headers
180        .iter()
181        .map(|(k, v)| (k.to_lowercase(), trimall(v)))
182        .collect();
183
184    // Sort headers
185    let header_string = canonical_headers
186        .iter()
187        .map(|(k, v)| format!("{}:{}", k, v))
188        .collect::<Vec<_>>()
189        .join("\n");
190
191    // Create signed headers list
192    let signed_headers = canonical_headers
193        .keys()
194        .cloned()
195        .collect::<Vec<_>>()
196        .join(";");
197
198    (header_string, signed_headers)
199}
200
201fn create_signing_key(secret_key: &str, date_stamp: &str, region: &str, service: &str) -> Vec<u8> {
202    let k_date = hmac_sha256(
203        format!("AWS4{secret_key}").as_bytes(),
204        date_stamp.as_bytes(),
205    );
206    let k_region = hmac_sha256(&k_date, region.as_bytes());
207    let k_service = hmac_sha256(&k_region, service.as_bytes());
208    hmac_sha256(&k_service, b"aws4_request")
209}
210
211pub async fn sign_request(req: SigV4Request) -> anyhow::Result<SigV4Response> {
212    // Get the access key id and secret key from their KeySource values
213    let access_key_bytes = req.access_key.get().await?;
214    let access_key = std::str::from_utf8(&access_key_bytes)
215        .context("access_key must be valid UTF-8")?
216        .to_string();
217
218    // Get the secret key
219    let secret_key_bytes = req.secret_key.get().await?;
220    let secret_key = std::str::from_utf8(&secret_key_bytes)
221        .context("secret_key must be valid UTF-8")?
222        .to_string();
223
224    // Use provided timestamp or current time
225    let timestamp = req.timestamp.unwrap_or_else(Utc::now);
226    let amz_date = timestamp.format("%Y%m%dT%H%M%SZ").to_string();
227    let date_stamp = timestamp.format("%Y%m%d").to_string();
228
229    // Create payload hash
230    let payload_hash = sha256_hex(req.payload.as_bytes());
231
232    // Prepare headers - add required AWS headers. Normalize all caller-supplied
233    // header names to lowercase up front so downstream checks (e.g. the 'host'
234    // precondition) and de-duplication work regardless of input casing.
235    let mut headers: BTreeMap<String, String> = req
236        .headers
237        .iter()
238        .map(|(k, v)| (k.to_lowercase(), v.clone()))
239        .collect();
240
241    anyhow::ensure!(headers.contains_key("host"), "headers must include 'host'");
242
243    headers.insert("x-amz-date".to_string(), amz_date.clone());
244
245    if let Some(token) = &req.session_token {
246        headers.insert("x-amz-security-token".to_string(), token.clone());
247    }
248
249    // S3 requires x-amz-content-sha256 to be signed; other services do not.
250    // Callers can include it for non-S3 services via req.headers if needed.
251    if req.service == "s3" {
252        headers.insert("x-amz-content-sha256".to_string(), payload_hash.clone());
253    }
254
255    // Create canonical request
256    let canonical_uri = create_canonical_uri(&req.uri);
257    let canonical_query_string = create_canonical_query_string(&req.query_params);
258    let (canonical_headers, signed_headers) = create_canonical_headers(&headers);
259
260    // See https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
261    // for the canonical request structure. The blank line between the
262    // canonical headers and the signed headers is required by the spec.
263    let canonical_request = format!(
264        "{method}\n{canonical_uri}\n{canonical_query_string}\n{canonical_headers}\n\n{signed_headers}\n{payload_hash}",
265        method = req.method,
266    );
267
268    // Create string to sign
269    let algorithm = "AWS4-HMAC-SHA256";
270    let credential_scope = format!(
271        "{date_stamp}/{region}/{service}/aws4_request",
272        region = req.region,
273        service = req.service
274    );
275    let canonical_request_hash = sha256_hex(canonical_request.as_bytes());
276
277    let string_to_sign =
278        format!("{algorithm}\n{amz_date}\n{credential_scope}\n{canonical_request_hash}");
279
280    // Calculate signature
281    let signing_key = create_signing_key(&secret_key, &date_stamp, &req.region, &req.service);
282    let signature_bytes = hmac_sha256(&signing_key, string_to_sign.as_bytes());
283    let signature = HEXLOWER.encode(&signature_bytes);
284
285    // Create authorization header
286    let authorization = format!(
287        "{algorithm} Credential={access_key}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}",
288        access_key = access_key
289    );
290
291    Ok(SigV4Response {
292        authorization,
293        timestamp: amz_date,
294        canonical_request,
295        string_to_sign,
296        signature,
297    })
298}
299
300pub fn register(lua: &Lua) -> anyhow::Result<()> {
301    // Register under kumo.crypto as aws_sign_v4 so that the function
302    // shows up alongside the other crypto helpers in the reference docs.
303    let aws_mod = get_or_create_sub_module(lua, "crypto")?;
304
305    aws_mod.set(
306        "aws_sign_v4",
307        lua.create_async_function(|lua, request: Value| async move {
308            let req: SigV4Request = from_lua_value(&lua, request)?;
309            let response = sign_request(req).await.map_err(any_err)?;
310
311            lua.to_value(&response)
312        })?,
313    )?;
314
315    Ok(())
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_uri_encode() {
324        assert_eq!(uri_encode("test"), "test");
325        assert_eq!(uri_encode("test value"), "test%20value");
326        assert_eq!(uri_encode("test/path"), "test%2Fpath");
327        assert_eq!(uri_encode("test-value_123.txt~"), "test-value_123.txt~");
328    }
329
330    #[test]
331    fn test_canonical_uri() {
332        assert_eq!(create_canonical_uri(""), "/");
333        assert_eq!(create_canonical_uri("/"), "/");
334        assert_eq!(create_canonical_uri("/path"), "/path");
335        assert_eq!(create_canonical_uri("/path/to/file"), "/path/to/file");
336        assert_eq!(
337            create_canonical_uri("/path with spaces"),
338            "/path%20with%20spaces"
339        );
340    }
341
342    #[test]
343    fn test_canonical_query_string() {
344        let mut params = BTreeMap::new();
345        assert_eq!(create_canonical_query_string(&params), "");
346
347        params.insert("key".to_string(), "value".to_string());
348        assert_eq!(create_canonical_query_string(&params), "key=value");
349
350        params.insert("another".to_string(), "test".to_string());
351        assert_eq!(
352            create_canonical_query_string(&params),
353            "another=test&key=value"
354        );
355    }
356
357    #[test]
358    fn test_sha256_hex() {
359        let result = sha256_hex(b"test");
360        assert_eq!(
361            result,
362            "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
363        );
364    }
365
366    #[test]
367    fn test_hmac_sha256() {
368        let result = hmac_sha256(b"key", b"message");
369        let hex = HEXLOWER.encode(&result);
370        assert_eq!(
371            hex,
372            "6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a"
373        );
374    }
375
376    #[test]
377    fn test_signing_key_derivation() {
378        // Test vector based on AWS documentation
379        let signing_key = create_signing_key(
380            "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
381            "20150830",
382            "us-east-1",
383            "iam",
384        );
385        let hex = HEXLOWER.encode(&signing_key);
386        assert_eq!(
387            hex,
388            "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9"
389        );
390    }
391
392    #[tokio::test]
393    async fn test_sign_request_basic() {
394        // Test the full sign_request function with inline key data
395        let req = SigV4Request {
396            access_key: KeySource::Data {
397                key_data: b"AKIAIOSFODNN7EXAMPLE".to_vec(),
398            },
399            secret_key: KeySource::Data {
400                key_data: b"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_vec(),
401            },
402            region: "us-east-1".to_string(),
403            service: "iam".to_string(),
404            method: "GET".to_string(),
405            uri: "/".to_string(),
406            query_params: BTreeMap::new(),
407            headers: {
408                let mut h = BTreeMap::new();
409                h.insert("host".to_string(), "iam.amazonaws.com".to_string());
410                h
411            },
412            payload: String::new(),
413            timestamp: Some(
414                DateTime::parse_from_rfc3339("2015-08-30T12:36:00Z")
415                    .unwrap()
416                    .with_timezone(&Utc),
417            ),
418            session_token: None,
419        };
420
421        let response = sign_request(req).await.expect("signing should succeed");
422
423        // Verify the response contains expected components
424        assert!(response.authorization.starts_with("AWS4-HMAC-SHA256"));
425        assert!(response
426            .authorization
427            .contains("Credential=AKIAIOSFODNN7EXAMPLE/20150830/us-east-1/iam/aws4_request"));
428        assert_eq!(response.timestamp, "20150830T123600Z");
429        // Signature should be a 64-character hex string
430        assert_eq!(response.signature.len(), 64);
431        assert!(response.signature.chars().all(|c| c.is_ascii_hexdigit()));
432    }
433
434    #[tokio::test]
435    async fn test_sign_request_with_query_params() {
436        let mut query_params = BTreeMap::new();
437        query_params.insert("Action".to_string(), "ListUsers".to_string());
438        query_params.insert("Version".to_string(), "2010-05-08".to_string());
439
440        let req = SigV4Request {
441            access_key: KeySource::Data {
442                key_data: b"AKIAIOSFODNN7EXAMPLE".to_vec(),
443            },
444            secret_key: KeySource::Data {
445                key_data: b"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_vec(),
446            },
447            region: "us-east-1".to_string(),
448            service: "iam".to_string(),
449            method: "GET".to_string(),
450            uri: "/".to_string(),
451            query_params,
452            headers: {
453                let mut h = BTreeMap::new();
454                h.insert("host".to_string(), "iam.amazonaws.com".to_string());
455                h
456            },
457            payload: String::new(),
458            timestamp: Some(
459                DateTime::parse_from_rfc3339("2015-08-30T12:36:00Z")
460                    .unwrap()
461                    .with_timezone(&Utc),
462            ),
463            session_token: None,
464        };
465
466        let response = sign_request(req).await.expect("signing should succeed");
467
468        // Verify query params are included in the canonical request
469        assert!(response.canonical_request.contains("Action=ListUsers"));
470        assert!(response.canonical_request.contains("Version=2010-05-08"));
471    }
472
473    // Credentials shared across all official AWS test vectors.
474    fn official_vector_access_key() -> KeySource {
475        KeySource::Data {
476            key_data: b"AKIDEXAMPLE".to_vec(),
477        }
478    }
479    fn official_vector_secret_key() -> KeySource {
480        KeySource::Data {
481            key_data: b"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_vec(),
482        }
483    }
484    fn official_vector_timestamp() -> Option<DateTime<Utc>> {
485        Some(
486            DateTime::parse_from_rfc3339("2015-08-30T12:36:00Z")
487                .unwrap()
488                .with_timezone(&Utc),
489        )
490    }
491    fn official_vector_headers() -> BTreeMap<String, String> {
492        let mut h = BTreeMap::new();
493        h.insert("host".to_string(), "example.amazonaws.com".to_string());
494        h
495    }
496
497    // Test vectors from the official AWS SigV4 test suite:
498    // https://docs.aws.amazon.com/general/latest/gr/sigv4-test-suite.html
499    // (mirrored at https://github.com/saibotsivad/aws-sig-v4-test-suite)
500    #[tokio::test]
501    async fn test_official_vector_get_vanilla() {
502        let req = SigV4Request {
503            access_key: official_vector_access_key(),
504            secret_key: official_vector_secret_key(),
505            region: "us-east-1".to_string(),
506            service: "service".to_string(),
507            method: "GET".to_string(),
508            uri: "/".to_string(),
509            query_params: BTreeMap::new(),
510            headers: official_vector_headers(),
511            payload: String::new(),
512            timestamp: official_vector_timestamp(),
513            session_token: None,
514        };
515
516        let response = sign_request(req).await.expect("signing should succeed");
517
518        k9::assert_equal!(
519            response.canonical_request,
520            "GET\n/\n\nhost:example.amazonaws.com\nx-amz-date:20150830T123600Z\n\nhost;x-amz-date\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
521        );
522        k9::assert_equal!(
523            response.string_to_sign,
524            "AWS4-HMAC-SHA256\n20150830T123600Z\n20150830/us-east-1/service/aws4_request\nbb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63"
525        );
526        k9::assert_equal!(
527            response.signature,
528            "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
529        );
530        k9::assert_equal!(
531            response.authorization,
532            "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
533        );
534    }
535
536    #[tokio::test]
537    async fn test_official_vector_post_vanilla() {
538        let req = SigV4Request {
539            access_key: official_vector_access_key(),
540            secret_key: official_vector_secret_key(),
541            region: "us-east-1".to_string(),
542            service: "service".to_string(),
543            method: "POST".to_string(),
544            uri: "/".to_string(),
545            query_params: BTreeMap::new(),
546            headers: official_vector_headers(),
547            payload: String::new(),
548            timestamp: official_vector_timestamp(),
549            session_token: None,
550        };
551
552        let response = sign_request(req).await.expect("signing should succeed");
553
554        k9::assert_equal!(
555            response.signature,
556            "5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b"
557        );
558        k9::assert_equal!(
559            response.authorization,
560            "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, SignedHeaders=host;x-amz-date, Signature=5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b"
561        );
562    }
563
564    #[test]
565    fn test_trimall() {
566        // Basic trim + collapse.
567        k9::assert_equal!(trimall("  foo  "), "foo");
568        k9::assert_equal!(trimall("foo   bar  baz"), "foo bar baz");
569        // Tabs treated like spaces.
570        k9::assert_equal!(trimall("\tfoo\t\tbar\t"), "foo bar");
571        // Whitespace inside quoted strings is preserved verbatim.
572        k9::assert_equal!(
573            trimall("  a   \"keep  these   spaces\"   b  "),
574            "a \"keep  these   spaces\" b"
575        );
576        // Empty and whitespace-only inputs.
577        k9::assert_equal!(trimall(""), "");
578        k9::assert_equal!(trimall("   "), "");
579    }
580
581    #[test]
582    fn test_canonical_headers_trimall() {
583        let mut headers = BTreeMap::new();
584        headers.insert("host".to_string(), "  example.com  ".to_string());
585        headers.insert("x-custom".to_string(), "foo   bar  baz".to_string());
586        let (header_string, _) = create_canonical_headers(&headers);
587        assert!(
588            header_string.contains("host:example.com"),
589            "leading/trailing whitespace should be stripped: {header_string}"
590        );
591        assert!(
592            header_string.contains("x-custom:foo bar baz"),
593            "internal whitespace runs should collapse to one space: {header_string}"
594        );
595    }
596
597    #[tokio::test]
598    async fn test_mixed_case_host_accepted() {
599        let mut headers = BTreeMap::new();
600        headers.insert("Host".to_string(), "example.amazonaws.com".to_string());
601        let req = SigV4Request {
602            access_key: official_vector_access_key(),
603            secret_key: official_vector_secret_key(),
604            region: "us-east-1".to_string(),
605            service: "service".to_string(),
606            method: "GET".to_string(),
607            uri: "/".to_string(),
608            query_params: BTreeMap::new(),
609            headers,
610            payload: String::new(),
611            timestamp: official_vector_timestamp(),
612            session_token: None,
613        };
614        // Should match the lowercase-host vector exactly.
615        let response = sign_request(req).await.expect("signing should succeed");
616        k9::assert_equal!(
617            response.signature,
618            "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
619        );
620    }
621
622    #[tokio::test]
623    async fn test_s3_includes_content_sha256() {
624        let req = SigV4Request {
625            access_key: KeySource::Data {
626                key_data: b"AKIAIOSFODNN7EXAMPLE".to_vec(),
627            },
628            secret_key: KeySource::Data {
629                key_data: b"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_vec(),
630            },
631            region: "us-east-1".to_string(),
632            service: "s3".to_string(),
633            method: "GET".to_string(),
634            uri: "/my-bucket/my-key".to_string(),
635            query_params: BTreeMap::new(),
636            headers: {
637                let mut h = BTreeMap::new();
638                h.insert("host".to_string(), "s3.amazonaws.com".to_string());
639                h
640            },
641            payload: String::new(),
642            timestamp: Some(
643                DateTime::parse_from_rfc3339("2015-08-30T12:36:00Z")
644                    .unwrap()
645                    .with_timezone(&Utc),
646            ),
647            session_token: None,
648        };
649
650        let response = sign_request(req).await.expect("signing should succeed");
651
652        assert!(
653            response.canonical_request.contains("x-amz-content-sha256"),
654            "S3 requests must include x-amz-content-sha256 in the canonical request"
655        );
656        assert!(
657            response.authorization.contains("x-amz-content-sha256"),
658            "S3 requests must sign x-amz-content-sha256"
659        );
660    }
661
662    #[tokio::test]
663    async fn test_missing_host_returns_error() {
664        let req = SigV4Request {
665            access_key: KeySource::Data {
666                key_data: b"AKIAIOSFODNN7EXAMPLE".to_vec(),
667            },
668            secret_key: KeySource::Data {
669                key_data: b"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_vec(),
670            },
671            region: "us-east-1".to_string(),
672            service: "iam".to_string(),
673            method: "GET".to_string(),
674            uri: "/".to_string(),
675            query_params: BTreeMap::new(),
676            headers: BTreeMap::new(), // no host
677            payload: String::new(),
678            timestamp: None,
679            session_token: None,
680        };
681
682        let err = sign_request(req).await.unwrap_err().to_string();
683        k9::assert_equal!(err, "headers must include 'host'");
684    }
685
686    #[tokio::test]
687    async fn test_sign_request_with_session_token() {
688        let req = SigV4Request {
689            access_key: KeySource::Data {
690                key_data: b"AKIAIOSFODNN7EXAMPLE".to_vec(),
691            },
692            secret_key: KeySource::Data {
693                key_data: b"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_vec(),
694            },
695            region: "us-east-1".to_string(),
696            service: "sts".to_string(),
697            method: "GET".to_string(),
698            uri: "/".to_string(),
699            query_params: BTreeMap::new(),
700            headers: {
701                let mut h = BTreeMap::new();
702                h.insert("host".to_string(), "sts.amazonaws.com".to_string());
703                h
704            },
705            payload: String::new(),
706            timestamp: Some(
707                DateTime::parse_from_rfc3339("2015-08-30T12:36:00Z")
708                    .unwrap()
709                    .with_timezone(&Utc),
710            ),
711            session_token: Some("AQoDYXdzEJr...".to_string()),
712        };
713
714        let response = sign_request(req).await.expect("signing should succeed");
715
716        // Verify session token header is included in signed headers
717        assert!(response.canonical_request.contains("x-amz-security-token"));
718    }
719}