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
12const 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 pub access_key: KeySource,
41 pub secret_key: KeySource,
43 pub region: String,
45 pub service: String,
47 pub method: String,
49 pub uri: String,
51 #[serde(default)]
53 pub query_params: BTreeMap<String, String>,
54 #[serde(default)]
56 pub headers: BTreeMap<String, String>,
57 #[serde(default)]
59 pub payload: String,
60 pub timestamp: Option<DateTime<Utc>>,
62 pub session_token: Option<String>,
64}
65
66#[derive(Deserialize, Serialize, Debug)]
67pub struct SigV4Response {
68 pub authorization: String,
70 pub timestamp: String,
72 pub canonical_request: String,
74 pub string_to_sign: String,
76 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 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 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
131fn 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 let canonical_headers: BTreeMap<String, String> = headers
180 .iter()
181 .map(|(k, v)| (k.to_lowercase(), trimall(v)))
182 .collect();
183
184 let header_string = canonical_headers
186 .iter()
187 .map(|(k, v)| format!("{}:{}", k, v))
188 .collect::<Vec<_>>()
189 .join("\n");
190
191 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 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 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 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 let payload_hash = sha256_hex(req.payload.as_bytes());
231
232 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 if req.service == "s3" {
252 headers.insert("x-amz-content-sha256".to_string(), payload_hash.clone());
253 }
254
255 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 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 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 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 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 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(¶ms), "");
346
347 params.insert("key".to_string(), "value".to_string());
348 assert_eq!(create_canonical_query_string(¶ms), "key=value");
349
350 params.insert("another".to_string(), "test".to_string());
351 assert_eq!(
352 create_canonical_query_string(¶ms),
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 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 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 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 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 assert!(response.canonical_request.contains("Action=ListUsers"));
470 assert!(response.canonical_request.contains("Version=2010-05-08"));
471 }
472
473 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 #[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 k9::assert_equal!(trimall(" foo "), "foo");
568 k9::assert_equal!(trimall("foo bar baz"), "foo bar baz");
569 k9::assert_equal!(trimall("\tfoo\t\tbar\t"), "foo bar");
571 k9::assert_equal!(
573 trimall(" a \"keep these spaces\" b "),
574 "a \"keep these spaces\" b"
575 );
576 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 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(), 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 assert!(response.canonical_request.contains("x-amz-security-token"));
718 }
719}