1use chrono::{DateTime, Utc};
21use iceberg::{Error, ErrorKind, Result};
22use sha2::{Digest, Sha256};
23use typed_builder::TypedBuilder;
24
25use super::Credentials;
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum PayloadHashMode {
31 IcebergRest,
36 StandardAws,
38}
39
40fn encode_hex(bytes: &[u8]) -> String {
41 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
42}
43
44fn base64_encode(bytes: &[u8]) -> String {
45 base64::engine::Engine::encode(&base64::engine::general_purpose::STANDARD, bytes)
46}
47
48fn payload_hashes(body: Option<&[u8]>, mode: PayloadHashMode) -> (String, String) {
50 let digest = Sha256::digest(body.unwrap_or_default());
51 let hex = encode_hex(&digest);
52 let header = match mode {
53 PayloadHashMode::IcebergRest if body.is_some() => base64_encode(&digest),
54 _ => hex.clone(),
55 };
56 (header, hex)
57}
58
59#[derive(Clone, Debug, TypedBuilder)]
64pub struct SigV4Signer {
65 #[builder(setter(into))]
67 region: String,
68 #[builder(setter(into))]
70 service: String,
71 mode: PayloadHashMode,
73}
74
75impl SigV4Signer {
76 pub fn sign(&self, request: &mut crate::HttpRequest, credentials: &Credentials) -> Result<()> {
98 self.sign_at(request, credentials, Utc::now())
99 }
100
101 fn sign_at(
102 &self,
103 request: &mut crate::HttpRequest,
104 credentials: &Credentials,
105 now: DateTime<Utc>,
106 ) -> Result<()> {
107 let headers = request.headers().clone();
108 let url = request.url().clone();
109 let signed = self.sign_in_place(request, credentials, now);
110 if signed.is_err() {
111 *request.headers_mut() = headers;
112 *request.url_mut() = url;
113 }
114 signed
115 }
116
117 fn sign_in_place(
118 &self,
119 request: &mut crate::HttpRequest,
120 credentials: &Credentials,
121 now: DateTime<Utc>,
122 ) -> Result<()> {
123 use aws_sigv4::http_request::{SignableBody, SignableRequest, sign};
124 use aws_sigv4::sign::v4;
125 use tracing::dispatcher::Dispatch;
126 use tracing::level_filters::LevelFilter;
127
128 signable_headers(request).try_for_each(|header| header.map(|_| ()))?;
130 let (content_header, payload_hash) = payload_hashes(signable_body(request)?, self.mode);
131
132 convert_headers(request);
133
134 let displaced_content_hash: Vec<_> = request
136 .headers()
137 .get_all(CONTENT_SHA256)
138 .iter()
139 .filter(|v| v.as_bytes() != content_header.as_bytes())
140 .cloned()
141 .collect();
142 let content_value = content_header.parse().map_err(|e| {
143 Error::new(ErrorKind::Unexpected, "invalid x-amz-content-sha256 value").with_source(e)
144 })?;
145 request.headers_mut().insert(CONTENT_SHA256, content_value);
146
147 rewrite_url_for_signing(request)?;
148
149 let identity = credentials.clone().into();
150 let params = v4::SigningParams::builder()
151 .identity(&identity)
152 .region(&self.region)
153 .name(&self.service)
154 .time(now.into())
155 .settings(signing_settings())
156 .build()
157 .map_err(|e| {
158 Error::new(ErrorKind::Unexpected, "failed to build SigV4 params").with_source(e)
159 })?
160 .into();
161
162 let headers = signable_headers(request).collect::<Result<Vec<_>>>()?;
163 let signable = SignableRequest::new(
164 request.method().as_str(),
165 request.url_str(),
166 headers.into_iter(),
167 SignableBody::Precomputed(payload_hash),
168 )
169 .map_err(|e| {
170 Error::new(ErrorKind::DataInvalid, "request is not signable").with_source(e)
171 })?;
172
173 let signed = if LevelFilter::current() == LevelFilter::TRACE {
179 tracing::dispatcher::with_default(&Dispatch::none(), || sign(signable, ¶ms))
180 } else {
181 sign(signable, ¶ms)
182 };
183 let (instructions, _signature) = signed
184 .map_err(|e| Error::new(ErrorKind::Unexpected, "SigV4 signing failed").with_source(e))?
185 .into_parts();
186
187 update_request_headers(request, instructions, displaced_content_hash)
188 }
189}
190
191fn signable_body(request: &crate::HttpRequest) -> Result<Option<&[u8]>> {
193 match request.body() {
194 crate::HttpRequestBody::Empty => Ok(None),
195 crate::HttpRequestBody::Buffered(bytes) => Ok(Some(bytes)),
196 crate::HttpRequestBody::Streaming => Err(Error::new(
197 ErrorKind::FeatureUnsupported,
198 "cannot sign a streaming request body",
199 )),
200 }
201}
202
203fn signable_headers(request: &crate::HttpRequest) -> impl Iterator<Item = Result<(&str, &str)>> {
206 request.headers().iter().map(|(n, v)| {
207 let v = std::str::from_utf8(v.as_bytes()).map_err(|e| {
208 Error::new(
209 ErrorKind::DataInvalid,
210 format!("cannot sign non-UTF-8 header value for `{n}`"),
211 )
212 .with_source(e)
213 })?;
214 Ok((n.as_str(), v))
215 })
216}
217
218fn rewrite_url_for_signing(request: &mut crate::HttpRequest) -> Result<()> {
222 if !request.url().username().is_empty() || request.url().password().is_some() {
223 let url = request.url_mut();
224 url.set_username("")
225 .and_then(|()| url.set_password(None))
226 .map_err(|()| {
227 Error::new(
228 ErrorKind::DataInvalid,
229 "cannot strip userinfo from the request URL",
230 )
231 })?;
232 }
233 if let Some(query) = request.url().query().filter(|q| q.contains('+')) {
234 let unambiguous = query.replace('+', "%20");
235 request.url_mut().set_query(Some(&unambiguous));
236 }
237 Ok(())
238}
239
240fn signing_settings() -> aws_sigv4::http_request::SigningSettings {
243 use aws_sigv4::http_request::{
244 PayloadChecksumKind, PercentEncodingMode, SigningSettings, UriPathNormalizationMode,
245 };
246
247 let mut settings = SigningSettings::default();
248 settings.percent_encoding_mode = PercentEncodingMode::Double;
249 settings.uri_path_normalization_mode = UriPathNormalizationMode::Enabled;
250 settings.payload_checksum_kind = PayloadChecksumKind::NoHeader;
252 let mut excluded = settings.excluded_headers.take().unwrap_or_default();
253 excluded.extend([
254 "connection".into(),
256 "expect".into(),
257 "transfer-encoding".into(),
258 "user-agent".into(),
259 "x-amzn-trace-id".into(),
260 "x-forwarded-for".into(),
262 "original-x-amz-date".into(),
264 "original-x-amz-content-sha256".into(),
265 "original-x-amz-security-token".into(),
266 ]);
267 settings.excluded_headers = Some(excluded);
268 settings
269}
270
271fn convert_headers(request: &mut crate::HttpRequest) {
274 let displaced: Vec<_> = request
275 .headers()
276 .get_all(reqwest::header::AUTHORIZATION)
277 .iter()
278 .cloned()
279 .collect();
280 if displaced.is_empty() {
281 return;
282 }
283 request.headers_mut().remove(reqwest::header::AUTHORIZATION);
284 for mut value in displaced {
285 value.set_sensitive(true);
286 request.headers_mut().append(RELOCATED_AUTHORIZATION, value);
287 }
288}
289
290fn update_request_headers(
293 request: &mut crate::HttpRequest,
294 instructions: aws_sigv4::http_request::SigningInstructions,
295 displaced_content_hash: Vec<reqwest::header::HeaderValue>,
296) -> Result<()> {
297 let (signed_headers, _params) = instructions.into_parts();
298 let h = request.headers_mut();
299 for mut value in displaced_content_hash {
300 value.set_sensitive(true);
302 h.append(RELOCATED_CONTENT_SHA256, value);
303 }
304 for header in signed_headers {
305 let name: reqwest::header::HeaderName = header.name().parse().map_err(|e| {
306 Error::new(ErrorKind::Unexpected, "invalid signed header name").with_source(e)
307 })?;
308 if let Some(relocated) = relocated_name(name.as_str()) {
309 relocate_conflicting(h, name.as_str(), header.value(), relocated);
310 }
311 let mut value: reqwest::header::HeaderValue = header.value().parse().map_err(|e| {
312 Error::new(ErrorKind::Unexpected, "invalid signed header value").with_source(e)
313 })?;
314 if name == reqwest::header::AUTHORIZATION || name == SECURITY_TOKEN {
315 value.set_sensitive(true);
316 }
317 h.insert(name, value);
318 }
319 Ok(())
320}
321
322const CONTENT_SHA256: reqwest::header::HeaderName =
323 reqwest::header::HeaderName::from_static("x-amz-content-sha256");
324const AMZ_DATE: reqwest::header::HeaderName =
325 reqwest::header::HeaderName::from_static("x-amz-date");
326const SECURITY_TOKEN: reqwest::header::HeaderName =
327 reqwest::header::HeaderName::from_static("x-amz-security-token");
328
329const RELOCATED_AUTHORIZATION: reqwest::header::HeaderName =
330 reqwest::header::HeaderName::from_static("original-authorization");
331const RELOCATED_AMZ_DATE: reqwest::header::HeaderName =
332 reqwest::header::HeaderName::from_static("original-x-amz-date");
333const RELOCATED_CONTENT_SHA256: reqwest::header::HeaderName =
334 reqwest::header::HeaderName::from_static("original-x-amz-content-sha256");
335const RELOCATED_SECURITY_TOKEN: reqwest::header::HeaderName =
336 reqwest::header::HeaderName::from_static("original-x-amz-security-token");
337
338fn relocated_name(name: &str) -> Option<reqwest::header::HeaderName> {
340 match name {
341 n if n == AMZ_DATE => Some(RELOCATED_AMZ_DATE),
342 n if n == SECURITY_TOKEN => Some(RELOCATED_SECURITY_TOKEN),
343 _ => None,
344 }
345}
346
347fn relocate_conflicting(
349 headers: &mut reqwest::header::HeaderMap,
350 name: &str,
351 signed: &str,
352 relocated: reqwest::header::HeaderName,
353) {
354 let conflicting: Vec<_> = headers
355 .get_all(name)
356 .iter()
357 .filter(|value| value.as_bytes() != signed.as_bytes())
358 .cloned()
359 .collect();
360 for mut value in conflicting {
361 value.set_sensitive(true);
363 headers.append(relocated.clone(), value);
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use chrono::TimeZone;
370
371 use super::*;
372 use crate::HttpRequest;
373
374 const EMPTY_HEX: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
375
376 #[test]
377 fn signing_rewrites_an_ambiguous_plus_out_of_the_query() {
378 let mut request = HttpRequest::new(
380 reqwest::Client::new()
381 .get("https://rest.example.com/v1/namespaces")
382 .query(&[("parent", "my ns")])
383 .build()
384 .unwrap(),
385 );
386 assert!(request.url().query().unwrap().contains("my+ns"));
387
388 let signer = test_signer(PayloadHashMode::StandardAws);
389 let now = test_time();
390 signer
391 .sign_at(&mut request, &test_credentials(), now)
392 .unwrap();
393
394 let query = request.url().query().unwrap();
395 assert!(!query.contains('+'), "{query}");
396 assert!(query.contains("my%20ns"), "{query}");
397 assert_signature_is(
398 &request,
399 "b7bb5a323a1ce0ace18454171084deef2dac44c933c3949771fc70179d3cce2b",
400 );
401 }
402
403 #[test]
404 fn payload_hashes_iceberg_mode() {
405 let v = payload_hashes(Some(b"hello"), PayloadHashMode::IcebergRest).0;
406 assert_eq!(v, "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=");
407 let e = payload_hashes(None, PayloadHashMode::IcebergRest).0;
408 assert_eq!(e, EMPTY_HEX);
409 }
410
411 #[test]
413 fn signing_separates_an_empty_body_from_an_absent_one() {
414 let signer = test_signer(PayloadHashMode::IcebergRest);
415 let now = test_time();
416 let hash_of = |builder: reqwest::RequestBuilder| {
417 let mut req = HttpRequest::new(builder.build().unwrap());
418 signer.sign_at(&mut req, &test_credentials(), now).unwrap();
419 req.headers()
420 .get("x-amz-content-sha256")
421 .unwrap()
422 .to_str()
423 .unwrap()
424 .to_string()
425 };
426
427 let client = reqwest::Client::new();
428 let url = "https://rest.example.com/v1/namespaces";
429 assert_eq!(
430 hash_of(client.post(url).body("")),
431 "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
432 );
433 assert_eq!(hash_of(client.post(url)), EMPTY_HEX);
434 }
435
436 #[test]
437 fn payload_hashes_standard_mode() {
438 let v = payload_hashes(Some(b"hello"), PayloadHashMode::StandardAws).0;
439 assert_eq!(
440 v,
441 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
442 );
443 }
444
445 fn assert_signature_is(req: &HttpRequest, expected: &str) {
448 let auth = req
449 .headers()
450 .get("authorization")
451 .unwrap()
452 .to_str()
453 .unwrap();
454 assert!(auth.ends_with(&format!("Signature={expected}")), "{auth}");
455 }
456
457 fn test_signer(mode: PayloadHashMode) -> SigV4Signer {
458 SigV4Signer::builder()
459 .region("us-east-1")
460 .service("execute-api")
461 .mode(mode)
462 .build()
463 }
464
465 fn test_credentials() -> Credentials {
466 Credentials::new("ak", "sk", None::<String>, None, "test")
467 }
468
469 fn example_credentials(session_token: Option<&str>) -> Credentials {
470 Credentials::new(
471 "AKIDEXAMPLE",
472 "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
473 session_token.map(str::to_string),
474 None,
475 "test",
476 )
477 }
478
479 fn test_time() -> DateTime<Utc> {
480 Utc.with_ymd_and_hms(2015, 8, 30, 12, 36, 0).unwrap()
481 }
482
483 fn signed_headers(req: &HttpRequest) -> Vec<String> {
484 let auth = req
485 .headers()
486 .get("authorization")
487 .unwrap()
488 .to_str()
489 .unwrap();
490 let signed = auth.split("SignedHeaders=").nth(1).unwrap();
491 let signed = signed.split(',').next().unwrap();
492 signed.split(';').map(str::to_string).collect()
493 }
494
495 fn header_list(req: &HttpRequest) -> Vec<(String, String)> {
497 let mut headers: Vec<_> = req
498 .headers()
499 .iter()
500 .map(|(n, v)| (n.to_string(), v.to_str().unwrap().to_string()))
501 .collect();
502 headers.sort();
503 headers
504 }
505
506 fn sorted_pairs(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
507 let mut pairs: Vec<_> = pairs
508 .iter()
509 .map(|(n, v)| (n.to_string(), v.to_string()))
510 .collect();
511 pairs.sort();
512 pairs
513 }
514
515 #[derive(Clone, Default)]
517 struct CapturedLog(std::sync::Arc<std::sync::Mutex<String>>);
518
519 impl tracing::field::Visit for CapturedLog {
520 fn record_debug(&mut self, _: &tracing::field::Field, value: &dyn std::fmt::Debug) {
521 self.0.lock().unwrap().push_str(&format!("{value:?}"));
522 }
523 }
524
525 impl tracing::Subscriber for CapturedLog {
526 fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
527 true
528 }
529 fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::Id {
530 tracing::Id::from_u64(1)
531 }
532 fn record(&self, _: &tracing::Id, _: &tracing::span::Record<'_>) {}
533 fn record_follows_from(&self, _: &tracing::Id, _: &tracing::Id) {}
534 fn event(&self, event: &tracing::Event<'_>) {
535 event.record(&mut self.clone());
536 }
537 fn enter(&self, _: &tracing::Id) {}
538 fn exit(&self, _: &tracing::Id) {}
539 }
540
541 #[test]
543 fn signing_does_not_trace_a_relocated_bearer_token() {
544 const TOKEN: &str = "Bearer topsecretdelegatetoken";
545 let signer = test_signer(PayloadHashMode::IcebergRest);
546 let mut req = HttpRequest::new(
547 reqwest::Client::new()
548 .get("https://rest.example.com/v1/config")
549 .header(reqwest::header::AUTHORIZATION, TOKEN)
550 .build()
551 .unwrap(),
552 );
553
554 let log = CapturedLog::default();
555 tracing::subscriber::with_default(log.clone(), || {
556 assert_eq!(
558 tracing::level_filters::LevelFilter::current(),
559 tracing::level_filters::LevelFilter::TRACE
560 );
561 signer
562 .sign_at(&mut req, &test_credentials(), test_time())
563 .unwrap();
564 tracing::trace!(canary = "subscriber-is-live");
565 });
566
567 let captured = log.0.lock().unwrap().clone();
568 assert!(captured.contains("subscriber-is-live"), "captured nothing");
569 assert!(!captured.contains(TOKEN), "{captured}");
570 assert_eq!(req.headers().get(RELOCATED_AUTHORIZATION).unwrap(), TOKEN);
571 }
572
573 #[test]
574 fn a_non_utf8_header_is_rejected_without_changing_the_request() {
575 let signer = test_signer(PayloadHashMode::IcebergRest);
576 for name in ["x-amz-meta-tenant", "authorization", "x-amz-content-sha256"] {
577 let mut req = HttpRequest::new(reqwest::Request::new(
578 reqwest::Method::GET,
579 "https://user:pw@rest.example.com/v1/config?warehouse=my+catalog"
580 .parse()
581 .unwrap(),
582 ));
583 req.headers_mut()
584 .insert("authorization", "Bearer delegate-token".parse().unwrap());
585 req.headers_mut()
586 .insert("x-amz-content-sha256", "caller-hash".parse().unwrap());
587 req.headers_mut().insert(
588 name,
589 reqwest::header::HeaderValue::from_bytes(b"acme\xfa").unwrap(),
590 );
591 let headers = req.headers().clone();
592 let url = req.url().clone();
593
594 let err = signer
595 .sign_at(&mut req, &test_credentials(), test_time())
596 .unwrap_err();
597 assert_eq!(err.kind(), ErrorKind::DataInvalid);
598 assert!(err.message().contains(name), "{err}");
599 assert_eq!(req.headers(), &headers, "{name}");
600 assert_eq!(req.url(), &url, "{name}");
601 }
602 }
603
604 #[test]
605 fn a_non_ascii_utf8_header_is_signed() {
606 let signer = test_signer(PayloadHashMode::StandardAws);
607 let mut req = HttpRequest::new(reqwest::Request::new(
608 reqwest::Method::GET,
609 "https://rest.example.com/v1/config".parse().unwrap(),
610 ));
611 req.headers_mut().insert(
612 "x-tenant",
613 reqwest::header::HeaderValue::from_bytes("Zürich".as_bytes()).unwrap(),
614 );
615
616 signer
617 .sign_at(&mut req, &test_credentials(), test_time())
618 .unwrap();
619
620 assert_eq!(signed_headers(&req), [
621 "host",
622 "x-amz-content-sha256",
623 "x-amz-date",
624 "x-tenant"
625 ]);
626 }
627
628 #[test]
629 fn a_signing_failure_after_relocation_restores_the_request() {
630 let signer = test_signer(PayloadHashMode::IcebergRest);
631 let url = format!(
634 "https://rest.example.com/v1/config?warehouse=my+catalog&pad={}",
635 "a".repeat(70_000)
636 );
637 let mut req = HttpRequest::new(
638 reqwest::Client::new()
639 .get(url)
640 .header("authorization", "Bearer delegate-token")
641 .header("x-amz-content-sha256", "caller-hash")
642 .build()
643 .unwrap(),
644 );
645 let headers = req.headers().clone();
646 let url = req.url().clone();
647
648 let err = signer
649 .sign_at(&mut req, &test_credentials(), test_time())
650 .unwrap_err();
651
652 assert_eq!(err.kind(), ErrorKind::DataInvalid, "{err}");
653 assert_eq!(req.headers(), &headers);
654 assert_eq!(req.url(), &url);
655 }
656
657 #[test]
660 fn a_caller_supplied_relocation_header_is_not_signed() {
661 let signer = test_signer(PayloadHashMode::StandardAws);
662 let mut req = HttpRequest::new(
663 reqwest::Client::new()
664 .get("https://rest.example.com/v1/config")
665 .header("x-amz-content-sha256", "caller-hash")
666 .header("original-x-amz-content-sha256", "previous")
667 .build()
668 .unwrap(),
669 );
670
671 signer
672 .sign_at(&mut req, &test_credentials(), test_time())
673 .unwrap();
674
675 assert_eq!(signed_headers(&req), [
676 "host",
677 "x-amz-content-sha256",
678 "x-amz-date"
679 ]);
680 let relocated: Vec<_> = req
681 .headers()
682 .get_all("original-x-amz-content-sha256")
683 .iter()
684 .map(|v| v.to_str().unwrap())
685 .collect();
686 assert_eq!(relocated, ["previous", "caller-hash"]);
687 }
688
689 #[test]
690 fn userinfo_is_stripped_before_signing() {
691 let signer = test_signer(PayloadHashMode::StandardAws);
693 let mut req = HttpRequest::new(reqwest::Request::new(
694 reqwest::Method::GET,
695 "https://user:pw@rest.example.com/v1/config"
696 .parse()
697 .unwrap(),
698 ));
699 assert_eq!(req.url().username(), "user");
700 let now = test_time();
701
702 signer.sign_at(&mut req, &test_credentials(), now).unwrap();
703
704 assert_eq!(req.url().username(), "");
705 assert_eq!(req.url().password(), None);
706 let mut plain = HttpRequest::new(reqwest::Request::new(
707 reqwest::Method::GET,
708 "https://rest.example.com/v1/config".parse().unwrap(),
709 ));
710 signer
711 .sign_at(&mut plain, &test_credentials(), now)
712 .unwrap();
713 assert_eq!(
714 req.headers().get("authorization"),
715 plain.headers().get("authorization"),
716 );
717 }
718
719 #[test]
720 fn a_doubled_slash_in_the_path_is_normalized() {
721 let signer = test_signer(PayloadHashMode::StandardAws);
724 let mut req = HttpRequest::new(reqwest::Request::new(
725 reqwest::Method::GET,
726 "https://rest.example.com//v1//config".parse().unwrap(),
727 ));
728 let now = test_time();
729
730 signer.sign_at(&mut req, &test_credentials(), now).unwrap();
731
732 let mut plain = HttpRequest::new(reqwest::Request::new(
733 reqwest::Method::GET,
734 "https://rest.example.com/v1/config".parse().unwrap(),
735 ));
736 signer
737 .sign_at(&mut plain, &test_credentials(), now)
738 .unwrap();
739 assert_eq!(
740 req.headers().get("authorization"),
741 plain.headers().get("authorization"),
742 );
743 }
744
745 #[test]
746 fn caller_headers_the_signer_overwrites_are_relocated() {
747 let creds = Credentials::new(
749 "ak".to_string(),
750 "sk".to_string(),
751 Some("signer-token".to_string()),
752 None,
753 "test",
754 );
755 let signer = test_signer(PayloadHashMode::StandardAws);
756 let mut req = HttpRequest::new(
757 reqwest::Client::new()
758 .get("https://rest.example.com/v1/config")
759 .header("authorization", "Bearer caller-token")
760 .header("x-amz-date", "19700101T000000Z")
761 .header("x-amz-security-token", "caller-session")
762 .header("x-amz-content-sha256", "caller-hash")
763 .build()
764 .unwrap(),
765 );
766
767 signer.sign_at(&mut req, &creds, test_time()).unwrap();
768
769 assert_eq!(
770 header_list(&req),
771 sorted_pairs(&[
772 (
773 "authorization",
774 "AWS4-HMAC-SHA256 Credential=ak/20150830/us-east-1/execute-api/aws4_request, \
775 SignedHeaders=host;original-authorization;x-amz-content-sha256;x-amz-date;\
776 x-amz-security-token, \
777 Signature=87560cb735277b284a547d63f57cfe3a721f9bce0b08a9402a58d754d2ed707c"
778 ),
779 ("original-authorization", "Bearer caller-token"),
780 ("original-x-amz-content-sha256", "caller-hash"),
781 ("original-x-amz-date", "19700101T000000Z"),
782 ("original-x-amz-security-token", "caller-session"),
783 ("x-amz-content-sha256", EMPTY_HEX),
784 ("x-amz-date", "20150830T123600Z"),
785 ("x-amz-security-token", "signer-token"),
786 ])
787 );
788 for relocated in [RELOCATED_SECURITY_TOKEN, RELOCATED_CONTENT_SHA256] {
790 assert!(req.headers().get(relocated).unwrap().is_sensitive());
791 }
792 }
793
794 #[test]
795 fn an_existing_authorization_is_never_signed() {
796 let signer = test_signer(PayloadHashMode::StandardAws);
799 let mut req = HttpRequest::new(
800 reqwest::Client::new()
801 .get("https://rest.example.com/v1/config")
802 .header("authorization", "Bearer caller-token")
803 .header("user-agent", "example/1.0")
804 .build()
805 .unwrap(),
806 );
807
808 signer
809 .sign_at(&mut req, &test_credentials(), test_time())
810 .unwrap();
811
812 assert_eq!(signed_headers(&req), [
813 "host",
814 "original-authorization",
815 "x-amz-content-sha256",
816 "x-amz-date"
817 ]);
818 }
819
820 #[test]
822 fn every_repeated_authorization_is_relocated_and_kept_sensitive() {
823 let signer = test_signer(PayloadHashMode::StandardAws);
824 let mut req = HttpRequest::new(
825 reqwest::Client::new()
826 .get("https://rest.example.com/v1/config")
827 .header("authorization", "Bearer first")
828 .header("authorization", "Bearer second")
829 .build()
830 .unwrap(),
831 );
832
833 signer
834 .sign_at(&mut req, &test_credentials(), test_time())
835 .unwrap();
836
837 let relocated: Vec<_> = req
838 .headers()
839 .get_all(RELOCATED_AUTHORIZATION)
840 .iter()
841 .collect();
842 assert_eq!(relocated.len(), 2, "{relocated:?}");
843 assert_eq!(relocated[0], "Bearer first");
844 assert_eq!(relocated[1], "Bearer second");
845 assert!(relocated.iter().all(|v| v.is_sensitive()), "{relocated:?}");
846 }
847
848 #[test]
849 fn hop_by_hop_headers_are_not_signed() {
850 let signer = test_signer(PayloadHashMode::StandardAws);
852 let mut req = HttpRequest::new(
853 reqwest::Client::new()
854 .get("https://rest.example.com/v1/config")
855 .header("expect", "100-continue")
856 .header("connection", "keep-alive")
857 .header("x-forwarded-for", "203.0.113.7")
858 .header("x-tenant", "acme")
859 .build()
860 .unwrap(),
861 );
862 let now = test_time();
863
864 signer.sign_at(&mut req, &test_credentials(), now).unwrap();
865
866 assert_eq!(signed_headers(&req), [
867 "host",
868 "x-amz-content-sha256",
869 "x-amz-date",
870 "x-tenant"
871 ]);
872 assert_signature_is(
873 &req,
874 "f938221412ed6b55cf3db380ce6ded476419ad3d7db4c76d932031c98465ce79",
875 );
876 }
877
878 #[test]
879 fn signed_credentials_are_marked_sensitive() {
880 let creds = Credentials::new(
882 "ak".to_string(),
883 "sk".to_string(),
884 Some("session-token".to_string()),
885 None,
886 "test",
887 );
888 let signer = test_signer(PayloadHashMode::StandardAws);
889 let mut req = HttpRequest::new(
890 reqwest::Client::new()
891 .get("https://rest.example.com/v1/config")
892 .build()
893 .unwrap(),
894 );
895
896 signer.sign_at(&mut req, &creds, test_time()).unwrap();
897
898 assert!(req.headers().get("authorization").unwrap().is_sensitive());
899 assert!(
900 req.headers()
901 .get("x-amz-security-token")
902 .unwrap()
903 .is_sensitive()
904 );
905 let debug = format!("{:?}", req.headers());
906 assert!(!debug.contains("session-token"), "{debug}");
907 }
908
909 #[test]
910 fn signs_with_a_non_default_service_and_session_token() {
911 let creds = Credentials::new(
914 "STS.EXAMPLEACCESSKEYID",
915 "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
916 Some("example-session-token".to_string()),
917 None,
918 "test",
919 );
920 let signer = SigV4Signer::builder()
921 .region("us-east-1")
922 .service("custom-service")
923 .mode(PayloadHashMode::IcebergRest)
924 .build();
925 let mut req = HttpRequest::new(
926 reqwest::Client::new()
927 .get("https://catalog.example.com/v1/config?warehouse=my-catalog")
928 .build()
929 .unwrap(),
930 );
931 let now = Utc.with_ymd_and_hms(2026, 8, 26, 12, 0, 0).unwrap();
932
933 signer.sign_at(&mut req, &creds, now).unwrap();
934
935 assert_signature_is(
936 &req,
937 "6b7065e5f44da4f5c3654126b8d5fe29599905afb6b98f4de65b6ec6e1be783f",
938 );
939 let auth = req
940 .headers()
941 .get("authorization")
942 .unwrap()
943 .to_str()
944 .unwrap();
945 assert!(
946 auth.contains("/us-east-1/custom-service/aws4_request"),
947 "{auth}"
948 );
949 assert_eq!(
950 req.headers().get("x-amz-security-token").unwrap(),
951 "example-session-token"
952 );
953 }
954
955 #[test]
956 fn signs_request_iceberg_mode() {
957 let creds = example_credentials(Some("SESSIONTOKEN"));
958 let signer = SigV4Signer::builder()
959 .region("us-east-1")
960 .service("glue")
961 .mode(PayloadHashMode::IcebergRest)
962 .build();
963 let client = reqwest::Client::new();
964 let mut req = HttpRequest::new(
965 client
966 .post("https://rest.example.com/v1/namespaces")
967 .body("{}")
968 .build()
969 .unwrap(),
970 );
971
972 signer.sign_at(&mut req, &creds, test_time()).unwrap();
973
974 assert_eq!(
975 header_list(&req),
976 sorted_pairs(&[
977 (
978 "authorization",
979 "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/glue/aws4_request, \
980 SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, \
981 Signature=effad6acde583dd14ba7aff52b2b83776a54421c010fe82067f166819057cb32"
982 ),
983 (
984 "x-amz-content-sha256",
985 "RBNvo1WzZ4oRRq0W9+hknpT7T8If536DEMBg9hyq/4o="
986 ),
987 ("x-amz-date", "20150830T123600Z"),
988 ("x-amz-security-token", "SESSIONTOKEN"),
989 ])
990 );
991 }
992
993 #[test]
996 fn signs_empty_body_and_all_headers() {
997 let creds = example_credentials(None);
998 let signer = SigV4Signer::builder()
999 .region("us-east-1")
1000 .service("glue")
1001 .mode(PayloadHashMode::IcebergRest)
1002 .build();
1003 let client = reqwest::Client::new();
1004 let mut req = HttpRequest::new(
1005 client
1006 .get("https://rest.example.com/v1/config")
1007 .header("content-type", "application/json")
1008 .header("content-encoding", "gzip")
1009 .build()
1010 .unwrap(),
1011 );
1012
1013 signer.sign_at(&mut req, &creds, test_time()).unwrap();
1014
1015 assert_eq!(
1016 header_list(&req),
1017 sorted_pairs(&[
1018 (
1019 "authorization",
1020 "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/glue/aws4_request, \
1021 SignedHeaders=content-encoding;content-type;host;x-amz-content-sha256;x-amz-date, \
1022 Signature=eaef7eb88d9cd810031684748671d8a3c9394ea5168622a212ed041b670b9777"
1023 ),
1024 ("content-encoding", "gzip"),
1025 ("content-type", "application/json"),
1026 ("x-amz-content-sha256", EMPTY_HEX),
1027 ("x-amz-date", "20150830T123600Z"),
1028 ])
1029 );
1030 }
1031
1032 #[test]
1033 fn iceberg_mode_signs_the_hex_payload_hash_not_the_base64_header() {
1034 let creds = example_credentials(None);
1036 let signer = test_signer(PayloadHashMode::IcebergRest);
1037 let body = br#"{"namespace":["ns"]}"#;
1038 let mut req = HttpRequest::new(
1039 reqwest::Client::new()
1040 .post("https://rest.example.com/v1/namespaces")
1041 .body(body.to_vec())
1042 .build()
1043 .unwrap(),
1044 );
1045 let now = test_time();
1046
1047 signer.sign_at(&mut req, &creds, now).unwrap();
1048
1049 assert_eq!(
1050 req.headers().get("x-amz-content-sha256").unwrap(),
1051 payload_hashes(Some(body), PayloadHashMode::IcebergRest)
1052 .0
1053 .as_str()
1054 );
1055 assert_ne!(
1056 req.headers().get("x-amz-content-sha256").unwrap(),
1057 encode_hex(&Sha256::digest(body)).as_str()
1058 );
1059 assert_signature_is(
1060 &req,
1061 "c68682c26cab6a781256f83b0076f50014f4922c3907f4ff09c204a74d61fc1d",
1062 );
1063 }
1064
1065 #[test]
1068 fn signatures_match_iceberg_java() {
1069 let creds = example_credentials(Some("example-session-token"));
1070 let signer = test_signer(PayloadHashMode::IcebergRest);
1071 let now = Utc.with_ymd_and_hms(2026, 10, 2, 11, 40, 30).unwrap();
1072 let client = reqwest::Client::new();
1073
1074 let mut post = HttpRequest::new(
1076 client
1077 .post("https://rest.example.com/v1/namespaces")
1078 .header("content-type", "application/json")
1079 .header("authorization", "Bearer delegate-token")
1080 .body(r#"{"namespace":["a","b"],"properties":{}}"#)
1081 .build()
1082 .unwrap(),
1083 );
1084 signer.sign_at(&mut post, &creds, now).unwrap();
1085 assert_eq!(
1086 post.headers().get("authorization").unwrap(),
1087 "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20261002/us-east-1/execute-api/aws4_request, \
1088 SignedHeaders=content-type;host;original-authorization;\
1089 x-amz-content-sha256;x-amz-date;x-amz-security-token, \
1090 Signature=c5d6bfcecd19c5421e8696c67465f6909b5e09398608d8e79a9eb391e04de556"
1091 );
1092 assert_eq!(
1093 post.headers().get("x-amz-content-sha256").unwrap(),
1094 "n1CB8Yl4L77sBwHTc5qTpUEhlsHHOXFzg0dV0fbOwXs="
1095 );
1096
1097 let mut get = HttpRequest::new(
1099 client
1100 .get("https://rest.example.com/v1/namespaces/a%1Fb/tables/x,y?pageToken=a%20b%2Fc")
1101 .build()
1102 .unwrap(),
1103 );
1104 signer.sign_at(&mut get, &creds, now).unwrap();
1105 assert_eq!(
1106 get.headers().get("authorization").unwrap(),
1107 "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20261002/us-east-1/execute-api/aws4_request, \
1108 SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, \
1109 Signature=5f6832ec82fc821b08cd3eac3a86ffc3e0c333571fd0a5796e43f2534f23329f"
1110 );
1111 }
1112
1113 #[test]
1115 fn signs_host_with_non_default_port() {
1116 let creds = example_credentials(None);
1117 let signer = SigV4Signer::builder()
1118 .region("us-east-1")
1119 .service("glue")
1120 .mode(PayloadHashMode::IcebergRest)
1121 .build();
1122 let client = reqwest::Client::new();
1123 let mut req = HttpRequest::new(
1124 client
1125 .get("https://rest.example.com:8181/v1/config")
1126 .build()
1127 .unwrap(),
1128 );
1129 let now = test_time();
1130
1131 signer.sign_at(&mut req, &creds, now).unwrap();
1132 assert_signature_is(
1133 &req,
1134 "f7801a4ecac5fe6dcc4ee385223428ec4833f21d0c2fc10d2fb00694b2c0def7",
1135 );
1136 }
1137
1138 #[test]
1141 fn canonical_uri_is_aws_double_encoded() {
1142 let creds = example_credentials(None);
1143 let signer = SigV4Signer::builder()
1144 .region("us-east-1")
1145 .service("glue")
1146 .mode(PayloadHashMode::IcebergRest)
1147 .build();
1148 let client = reqwest::Client::new();
1149 let mut req = HttpRequest::new(
1150 client
1151 .get("https://rest.example.com/v1/namespaces/a%2Cb/tables/x,y")
1152 .build()
1153 .unwrap(),
1154 );
1155 let now = test_time();
1156
1157 signer.sign_at(&mut req, &creds, now).unwrap();
1158 assert_signature_is(
1159 &req,
1160 "4d966b6fc2dfb62be5a603e4e07e5dc85b2af1c7e6a181c5646bfbf38ddfa543",
1161 );
1162 }
1163
1164 #[test]
1167 fn standard_mode_matches_the_aws_test_suite() {
1168 let creds = example_credentials(None);
1169 let signer = SigV4Signer::builder()
1170 .region("us-east-1")
1171 .service("service")
1172 .mode(PayloadHashMode::StandardAws)
1173 .build();
1174 let mut req = HttpRequest::new(
1175 reqwest::Client::new()
1176 .post("https://example.amazonaws.com/")
1177 .header("content-type", "application/x-www-form-urlencoded")
1178 .header("content-length", "13")
1179 .body("Param1=value1")
1180 .build()
1181 .unwrap(),
1182 );
1183
1184 signer.sign_at(&mut req, &creds, test_time()).unwrap();
1185
1186 assert_eq!(
1187 req.headers().get("x-amz-content-sha256").unwrap(),
1188 "9095672bbd1f56dfc5b65f3e153adc8731a4a654192329106275f4c7b24d0b6e"
1189 );
1190 assert_eq!(
1191 req.headers().get("authorization").unwrap(),
1192 "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \
1193 SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date, \
1194 Signature=d3875051da38690788ef43de4db0d8f280229d82040bfac253562e56c3f20e0b"
1195 );
1196 }
1197
1198 #[test]
1200 fn sign_stamps_the_current_time() {
1201 use chrono::SubsecRound;
1202
1203 let signer = test_signer(PayloadHashMode::IcebergRest);
1204 let mut req = HttpRequest::new(
1205 reqwest::Client::new()
1206 .get("https://rest.example.com/v1/config")
1207 .build()
1208 .unwrap(),
1209 );
1210 let before = Utc::now();
1211 signer.sign(&mut req, &test_credentials()).unwrap();
1212 let after = Utc::now();
1213
1214 let date = req.headers().get("x-amz-date").unwrap().to_str().unwrap();
1215 let stamped = chrono::NaiveDateTime::parse_from_str(date, "%Y%m%dT%H%M%SZ")
1216 .unwrap()
1217 .and_utc();
1218 assert_eq!(date.len(), "YYYYMMDDTHHMMSSZ".len(), "{date}");
1219 assert!(
1221 before.trunc_subsecs(0) <= stamped && stamped <= after,
1222 "{date}"
1223 );
1224 }
1225}