1use crate::Credential;
19use http::request::Parts;
20use http::{HeaderValue, header};
21use log::debug;
22use reqsign_aws_core::signing::{
23 append_query_fragment, append_query_pairs, canonical_request_string, canonicalize_headers,
24 canonicalize_headers_with_standard_session_token, canonicalize_query,
25};
26use reqsign_core::hash::{hex_hmac_sha256, hex_sha256, hmac_sha256};
27use reqsign_core::time::Timestamp;
28use reqsign_core::{Context, Result, SignRequest, SigningCredential, SigningRequest};
29use std::fmt::Write;
30use std::time::Duration;
31
32const CREDENTIAL_OPERATION_HEADROOM: Duration = Duration::from_secs(10);
33
34#[derive(Debug)]
38pub struct RequestSigner {
39 service: String,
40 region: String,
41 use_standard_session_token: bool,
42
43 time: Option<Timestamp>,
44}
45
46impl RequestSigner {
47 pub fn new(service: &str, region: &str) -> Self {
49 Self {
50 service: service.into(),
51 region: region.into(),
52 use_standard_session_token: false,
53
54 time: None,
55 }
56 }
57
58 pub fn with_standard_session_token(mut self) -> Self {
89 self.use_standard_session_token = true;
90 self
91 }
92
93 #[cfg(test)]
100 pub fn with_time(mut self, time: Timestamp) -> Self {
101 self.time = Some(time);
102 self
103 }
104
105 fn get_time(&self) -> Timestamp {
106 self.time.unwrap_or_else(Timestamp::now)
107 }
108
109 fn required_valid_until_at(
110 &self,
111 signing_time: Timestamp,
112 expires_in: Option<Duration>,
113 ) -> Timestamp {
114 signing_time + expires_in.unwrap_or(CREDENTIAL_OPERATION_HEADROOM)
115 }
116}
117impl SignRequest for RequestSigner {
118 type Credential = Credential;
119
120 fn required_valid_until(
121 &self,
122 _credential: &Self::Credential,
123 expires_in: Option<Duration>,
124 ) -> Timestamp {
125 self.required_valid_until_at(self.get_time(), expires_in)
126 }
127
128 async fn sign_request(
129 &self,
130 _: &Context,
131 req: &mut Parts,
132 credential: Option<&Self::Credential>,
133 expires_in: Option<Duration>,
134 ) -> Result<()> {
135 let Some(cred) = credential else {
136 return Ok(());
137 };
138
139 let now = self.get_time();
140 let required_until = self.required_valid_until_at(now, expires_in);
141 if !cred.is_valid_at(required_until) {
142 return Err(reqsign_core::Error::credential_invalid(
143 "credential expires before the requested signing operation deadline",
144 ));
145 }
146
147 let original_uri = req.uri.clone();
148 let mut signed_req = SigningRequest::build(req)?;
149
150 if self.use_standard_session_token {
152 canonicalize_headers_with_standard_session_token(
153 &mut signed_req,
154 cred,
155 expires_in,
156 now,
157 )?;
158 } else {
159 canonicalize_headers(&mut signed_req, cred, expires_in, now)?;
160 }
161 let authentication_query = authentication_query(
162 &signed_req,
163 cred,
164 expires_in,
165 now,
166 &self.service,
167 &self.region,
168 self.use_standard_session_token,
169 );
170 let canonical_query = canonicalize_query(&signed_req, &authentication_query);
171
172 let creq = canonical_request_string(&signed_req, &canonical_query)?;
174 let encoded_req = hex_sha256(creq.as_bytes());
175
176 let scope = format!(
178 "{}/{}/{}/aws4_request",
179 now.format_date(),
180 self.region,
181 self.service
182 );
183 debug!("calculated scope: {scope}");
184
185 let string_to_sign = {
192 let mut f = String::new();
193 writeln!(f, "AWS4-HMAC-SHA256").map_err(|e| {
194 reqsign_core::Error::unexpected(format!("failed to write algorithm: {e}"))
195 })?;
196 writeln!(f, "{}", now.format_iso8601()).map_err(|e| {
197 reqsign_core::Error::unexpected(format!("failed to write timestamp: {e}"))
198 })?;
199 writeln!(f, "{}", scope).map_err(|e| {
200 reqsign_core::Error::unexpected(format!("failed to write scope: {e}"))
201 })?;
202 write!(f, "{}", encoded_req).map_err(|e| {
203 reqsign_core::Error::unexpected(format!("failed to write encoded request: {e}"))
204 })?;
205 f
206 };
207 debug!("calculated string to sign: {string_to_sign}");
208
209 let signing_key =
210 generate_signing_key(&cred.secret_access_key, now, &self.region, &self.service);
211 let signature = hex_hmac_sha256(&signing_key, string_to_sign.as_bytes());
212
213 let final_uri = if expires_in.is_some() {
214 let unsigned_uri = append_query_pairs(&original_uri, &authentication_query)?;
215 Some(append_query_fragment(
216 &unsigned_uri,
217 &format!("X-Amz-Signature={signature}"),
218 )?)
219 } else {
220 let mut authorization = HeaderValue::from_str(&format!(
221 "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
222 cred.access_key_id,
223 scope,
224 signed_req.header_name_to_vec_sorted().join(";"),
225 signature
226 ))
227 .map_err(|e| {
228 reqsign_core::Error::unexpected(format!(
229 "failed to create authorization header: {e}"
230 ))
231 })?;
232 authorization.set_sensitive(true);
233
234 signed_req
235 .headers
236 .insert(header::AUTHORIZATION, authorization);
237 None
238 };
239
240 signed_req.apply(req)?;
242 if let Some(uri) = final_uri {
243 req.uri = uri;
244 }
245 Ok(())
246 }
247}
248
249fn authentication_query(
250 ctx: &SigningRequest,
251 cred: &Credential,
252 expires_in: Option<Duration>,
253 now: Timestamp,
254 service: &str,
255 region: &str,
256 use_standard_session_token: bool,
257) -> Vec<(String, String)> {
258 let mut query = Vec::new();
259 if let Some(expire) = expires_in {
260 query.push(("X-Amz-Algorithm".into(), "AWS4-HMAC-SHA256".into()));
261 query.push((
262 "X-Amz-Credential".into(),
263 format!(
264 "{}/{}/{}/{}/aws4_request",
265 cred.access_key_id,
266 now.format_date(),
267 region,
268 service
269 ),
270 ));
271 query.push(("X-Amz-Date".into(), now.format_iso8601()));
272 query.push(("X-Amz-Expires".into(), expire.as_secs().to_string()));
273 query.push((
274 "X-Amz-SignedHeaders".into(),
275 ctx.header_name_to_vec_sorted().join(";"),
276 ));
277
278 if let Some(token) = &cred.session_token {
279 let is_s3_express = !use_standard_session_token
280 && (ctx.authority.as_str().contains("s3express")
281 || ctx.authority.as_str().contains("--x-s3"));
282 let name = if is_s3_express {
283 "X-Amz-S3session-Token"
284 } else {
285 "X-Amz-Security-Token"
286 };
287 query.push((name.into(), token.into()));
288 }
289 }
290 query
291}
292
293fn generate_signing_key(secret: &str, time: Timestamp, region: &str, service: &str) -> Vec<u8> {
294 let secret = format!("AWS4{secret}");
296 let sign_date = hmac_sha256(secret.as_bytes(), time.format_date().as_bytes());
298 let sign_region = hmac_sha256(sign_date.as_slice(), region.as_bytes());
300 let sign_service = hmac_sha256(sign_region.as_slice(), service.as_bytes());
302 hmac_sha256(sign_service.as_slice(), "aws4_request".as_bytes())
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::StaticCredentialProvider;
310 use anyhow::Result;
311 use aws_credential_types::Credentials;
312 use aws_sigv4::http_request::PayloadChecksumKind;
313 use aws_sigv4::http_request::PercentEncodingMode;
314 use aws_sigv4::http_request::SignableBody;
315 use aws_sigv4::http_request::SignableRequest;
316 use aws_sigv4::http_request::SignatureLocation;
317 use aws_sigv4::http_request::SigningSettings;
318 use aws_sigv4::sign::v4;
319 use http::Request;
320 use http::header;
321 use reqsign_aws_core::constants::X_AMZ_CONTENT_SHA_256;
322 use reqsign_aws_core::signing::canonical_uri;
323 use reqsign_core::{ErrorKind, ProvideCredential, Signer};
324 use reqsign_file_read_tokio::TokioFileRead;
325 use reqsign_http_send_reqwest::ReqwestHttpSend;
326 use std::collections::VecDeque;
327 use std::sync::atomic::{AtomicUsize, Ordering};
328 use std::sync::{Arc, Mutex};
329
330 const RAW_QUERY: &str = "slash=%2F&hash=%23&=%26&equals=%3D&space=%20&encoded-plus=%2B&literal-plus=+&double=%252F&dup=first&dup=second&=empty-key&empty=&flag&flag=&";
331 const S3_EXPRESS_URI: &str =
332 "https://bucket--use2-az1--x-s3.s3express-use2-az1.us-east-2.amazonaws.com/object";
333 const S3_EXPRESS_REGION: &str = "us-east-2";
334 const TEST_ACCESS_KEY: &str = "AKIDEXAMPLE";
335 const TEST_SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
336 const TEST_SESSION_TOKEN: &str = "test-session-token";
337
338 fn s3_express_credential(now: Timestamp) -> Credential {
339 Credential {
340 access_key_id: TEST_ACCESS_KEY.to_string(),
341 secret_access_key: TEST_SECRET_KEY.to_string(),
342 session_token: Some(TEST_SESSION_TOKEN.to_string()),
343 expires_in: Some(now + Duration::from_secs(3600)),
344 }
345 }
346
347 fn aws_signed_s3_express_request(
348 mut request: Request<&'static str>,
349 now: Timestamp,
350 presigned: bool,
351 session_token_name_override: Option<&'static str>,
352 ) -> Result<Request<&'static str>> {
353 let mut settings = SigningSettings::default();
354 settings.percent_encoding_mode = PercentEncodingMode::Double;
355 settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
356 settings.session_token_name_override = session_token_name_override;
357 if presigned {
358 settings.signature_location = SignatureLocation::QueryParams;
359 settings.expires_in = Some(Duration::from_secs(60));
360 }
361
362 let identity = Credentials::new(
363 TEST_ACCESS_KEY,
364 TEST_SECRET_KEY,
365 Some(TEST_SESSION_TOKEN.to_string()),
366 None,
367 "hardcoded-credentials",
368 )
369 .into();
370 let params = v4::SigningParams::builder()
371 .identity(&identity)
372 .region(S3_EXPRESS_REGION)
373 .name("s3express")
374 .time(now.as_system_time())
375 .settings(settings)
376 .build()
377 .expect("signing params must be valid");
378 let output = aws_sigv4::http_request::sign(
379 SignableRequest::new(
380 request.method().as_str(),
381 request.uri().to_string(),
382 request
383 .headers()
384 .iter()
385 .map(|(name, value)| (name.as_str(), value.to_str().unwrap())),
386 SignableBody::UnsignedPayload,
387 )?,
388 ¶ms.into(),
389 )?;
390 let (instructions, _) = output.into_parts();
391 instructions.apply_to_request_http1x(&mut request);
392 Ok(request)
393 }
394
395 fn reqsign_s3_express_canonical_request(
396 now: Timestamp,
397 presigned: bool,
398 use_standard_session_token: bool,
399 ) -> Result<String> {
400 let credential = s3_express_credential(now);
401 let mut parts = Request::get(S3_EXPRESS_URI).body(())?.into_parts().0;
402 let mut signing_request = SigningRequest::build(&mut parts)?;
403 let expires_in = presigned.then_some(Duration::from_secs(60));
404 if use_standard_session_token {
405 canonicalize_headers_with_standard_session_token(
406 &mut signing_request,
407 &credential,
408 expires_in,
409 now,
410 )?;
411 } else {
412 canonicalize_headers(&mut signing_request, &credential, expires_in, now)?;
413 }
414 let authentication_query = authentication_query(
415 &signing_request,
416 &credential,
417 expires_in,
418 now,
419 "s3express",
420 S3_EXPRESS_REGION,
421 use_standard_session_token,
422 );
423 let canonical_query = canonicalize_query(&signing_request, &authentication_query);
424 canonical_request_string(&signing_request, &canonical_query).map_err(Into::into)
425 }
426
427 #[derive(Debug)]
428 struct SequenceProvider {
429 credentials: Mutex<VecDeque<Credential>>,
430 calls: Arc<AtomicUsize>,
431 }
432
433 impl ProvideCredential for SequenceProvider {
434 type Credential = Credential;
435
436 async fn provide_credential(
437 &self,
438 _ctx: &Context,
439 ) -> reqsign_core::Result<Option<Self::Credential>> {
440 self.calls.fetch_add(1, Ordering::SeqCst);
441 Ok(self.credentials.lock().unwrap().pop_front())
442 }
443 }
444
445 #[test]
446 fn presign_deadline_uses_signing_time_and_requested_expiry() {
447 let now: Timestamp = "2026-07-22T00:00:00Z"
448 .parse()
449 .expect("timestamp must parse");
450 let signer = RequestSigner::new("s3", "test").with_time(now);
451 let credential = Credential::default();
452
453 assert_eq!(
454 signer.required_valid_until(&credential, Some(Duration::from_secs(3600))),
455 now + Duration::from_secs(3600)
456 );
457 assert_eq!(
458 signer.required_valid_until(&credential, None),
459 now + CREDENTIAL_OPERATION_HEADROOM
460 );
461 }
462
463 #[tokio::test]
464 async fn s3_express_header_token_modes_match_aws_sigv4() -> Result<()> {
465 let now: Timestamp = "2026-07-22T00:00:00Z".parse()?;
466 let credential = s3_express_credential(now);
467
468 let expected_session = aws_signed_s3_express_request(
469 Request::get(S3_EXPRESS_URI).body("")?,
470 now,
471 false,
472 Some("x-amz-s3session-token"),
473 )?;
474 let (mut session_parts, body) = Request::get(S3_EXPRESS_URI).body("")?.into_parts();
475 RequestSigner::new("s3express", S3_EXPRESS_REGION)
476 .with_time(now)
477 .sign_request(&Context::new(), &mut session_parts, Some(&credential), None)
478 .await?;
479 let actual_session = Request::from_parts(session_parts, body);
480 compare_request(
481 "S3 Express CreateSession header mode",
482 &expected_session,
483 &actual_session,
484 );
485 assert_eq!(
486 actual_session.headers()["x-amz-s3session-token"],
487 TEST_SESSION_TOKEN
488 );
489 assert!(actual_session.headers()["x-amz-s3session-token"].is_sensitive());
490 assert!(
491 !actual_session
492 .headers()
493 .contains_key("x-amz-security-token")
494 );
495
496 let expected_iam = aws_signed_s3_express_request(
497 Request::get(S3_EXPRESS_URI).body("")?,
498 now,
499 false,
500 None,
501 )?;
502 let (mut iam_parts, body) = Request::get(S3_EXPRESS_URI).body("")?.into_parts();
503 RequestSigner::new("s3express", S3_EXPRESS_REGION)
504 .with_standard_session_token()
505 .with_time(now)
506 .sign_request(&Context::new(), &mut iam_parts, Some(&credential), None)
507 .await?;
508 let actual_iam = Request::from_parts(iam_parts, body);
509 compare_request("S3 Express IAM header mode", &expected_iam, &actual_iam);
510 assert_eq!(
511 actual_iam.headers()["x-amz-security-token"],
512 TEST_SESSION_TOKEN
513 );
514 assert!(actual_iam.headers()["x-amz-security-token"].is_sensitive());
515 assert!(!actual_iam.headers().contains_key("x-amz-s3session-token"));
516
517 let session_authorization = actual_session.headers()[header::AUTHORIZATION].to_str()?;
518 let iam_authorization = actual_iam.headers()[header::AUTHORIZATION].to_str()?;
519 assert!(session_authorization.contains("x-amz-s3session-token"));
520 assert!(iam_authorization.contains("x-amz-security-token"));
521 assert_ne!(session_authorization, iam_authorization);
522 let session_canonical = reqsign_s3_express_canonical_request(now, false, false)?;
523 let iam_canonical = reqsign_s3_express_canonical_request(now, false, true)?;
524 assert_eq!(
525 session_canonical,
526 format!(
527 "GET\n/object\n\nhost:bucket--use2-az1--x-s3.s3express-use2-az1.us-east-2.amazonaws.com\nx-amz-content-sha256:UNSIGNED-PAYLOAD\nx-amz-date:20260722T000000Z\nx-amz-s3session-token:{TEST_SESSION_TOKEN}\n\nhost;x-amz-content-sha256;x-amz-date;x-amz-s3session-token\nUNSIGNED-PAYLOAD"
528 )
529 );
530 assert_eq!(
531 iam_canonical,
532 format!(
533 "GET\n/object\n\nhost:bucket--use2-az1--x-s3.s3express-use2-az1.us-east-2.amazonaws.com\nx-amz-content-sha256:UNSIGNED-PAYLOAD\nx-amz-date:20260722T000000Z\nx-amz-security-token:{TEST_SESSION_TOKEN}\n\nhost;x-amz-content-sha256;x-amz-date;x-amz-security-token\nUNSIGNED-PAYLOAD"
534 )
535 );
536 assert_ne!(session_canonical, iam_canonical);
537 assert_eq!(
538 credential.session_token.as_deref(),
539 Some(TEST_SESSION_TOKEN)
540 );
541
542 Ok(())
543 }
544
545 #[tokio::test]
546 async fn s3_express_query_token_modes_match_aws_sigv4() -> Result<()> {
547 let now: Timestamp = "2026-07-22T00:00:00Z".parse()?;
548 let credential = s3_express_credential(now);
549 let expires_in = Some(Duration::from_secs(60));
550
551 let expected_session = aws_signed_s3_express_request(
552 Request::get(S3_EXPRESS_URI).body("")?,
553 now,
554 true,
555 Some("X-Amz-S3session-Token"),
556 )?;
557 let (mut session_parts, body) = Request::get(S3_EXPRESS_URI).body("")?.into_parts();
558 RequestSigner::new("s3express", S3_EXPRESS_REGION)
559 .with_time(now)
560 .sign_request(
561 &Context::new(),
562 &mut session_parts,
563 Some(&credential),
564 expires_in,
565 )
566 .await?;
567 let actual_session = Request::from_parts(session_parts, body);
568 compare_request(
569 "S3 Express CreateSession query mode",
570 &expected_session,
571 &actual_session,
572 );
573
574 let expected_iam =
575 aws_signed_s3_express_request(Request::get(S3_EXPRESS_URI).body("")?, now, true, None)?;
576 let (mut iam_parts, body) = Request::get(S3_EXPRESS_URI).body("")?.into_parts();
577 RequestSigner::new("s3express", S3_EXPRESS_REGION)
578 .with_standard_session_token()
579 .with_time(now)
580 .sign_request(
581 &Context::new(),
582 &mut iam_parts,
583 Some(&credential),
584 expires_in,
585 )
586 .await?;
587 let actual_iam = Request::from_parts(iam_parts, body);
588 compare_request("S3 Express IAM query mode", &expected_iam, &actual_iam);
589
590 let session_query = actual_session.uri().query().expect("query must exist");
591 let iam_query = actual_iam.uri().query().expect("query must exist");
592 assert!(session_query.contains("X-Amz-S3session-Token="));
593 assert!(!session_query.contains("X-Amz-Security-Token="));
594 assert!(iam_query.contains("X-Amz-Security-Token="));
595 assert!(!iam_query.contains("X-Amz-S3session-Token="));
596 let session_signature = form_urlencoded::parse(session_query.as_bytes())
597 .find(|(name, _)| name == "X-Amz-Signature")
598 .expect("signature must exist")
599 .1;
600 let iam_signature = form_urlencoded::parse(iam_query.as_bytes())
601 .find(|(name, _)| name == "X-Amz-Signature")
602 .expect("signature must exist")
603 .1;
604 assert_ne!(session_signature, iam_signature);
605 let session_canonical = reqsign_s3_express_canonical_request(now, true, false)?;
606 let iam_canonical = reqsign_s3_express_canonical_request(now, true, true)?;
607 assert_eq!(
608 session_canonical,
609 format!(
610 "GET\n/object\nX-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIDEXAMPLE%2F20260722%2Fus-east-2%2Fs3express%2Faws4_request&X-Amz-Date=20260722T000000Z&X-Amz-Expires=60&X-Amz-S3session-Token={TEST_SESSION_TOKEN}&X-Amz-SignedHeaders=host\nhost:bucket--use2-az1--x-s3.s3express-use2-az1.us-east-2.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD"
611 )
612 );
613 assert_eq!(
614 iam_canonical,
615 format!(
616 "GET\n/object\nX-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIDEXAMPLE%2F20260722%2Fus-east-2%2Fs3express%2Faws4_request&X-Amz-Date=20260722T000000Z&X-Amz-Expires=60&X-Amz-Security-Token={TEST_SESSION_TOKEN}&X-Amz-SignedHeaders=host\nhost:bucket--use2-az1--x-s3.s3express-use2-az1.us-east-2.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD"
617 )
618 );
619 assert_ne!(session_canonical, iam_canonical);
620 assert_eq!(
621 credential.session_token.as_deref(),
622 Some(TEST_SESSION_TOKEN)
623 );
624
625 Ok(())
626 }
627
628 #[tokio::test]
629 async fn session_token_is_redacted_from_debug_and_signing_errors() -> Result<()> {
630 const TOKEN_MARKER: &str = "SENSITIVE_SESSION_TOKEN";
631
632 let now: Timestamp = "2026-07-22T00:00:00Z".parse()?;
633 let credential = Credential {
634 session_token: Some(format!("{TOKEN_MARKER}\n")),
635 ..s3_express_credential(now)
636 };
637 assert!(!format!("{credential:?}").contains(TOKEN_MARKER));
638
639 for use_standard_session_token in [false, true] {
640 let signer = RequestSigner::new("s3express", S3_EXPRESS_REGION);
641 let signer = if use_standard_session_token {
642 signer.with_standard_session_token()
643 } else {
644 signer
645 }
646 .with_time(now);
647 assert!(!format!("{signer:?}").contains(TOKEN_MARKER));
648
649 let mut parts = Request::get(S3_EXPRESS_URI).body(())?.into_parts().0;
650 let original = parts.clone();
651 let error = signer
652 .sign_request(&Context::new(), &mut parts, Some(&credential), None)
653 .await
654 .expect_err("invalid session token must be rejected");
655
656 assert!(!format!("{error}").contains(TOKEN_MARKER));
657 assert!(!format!("{error:?}").contains(TOKEN_MARKER));
658 assert_eq!(parts.uri, original.uri);
659 assert_eq!(parts.headers, original.headers);
660 assert_eq!(
661 credential.session_token.as_deref(),
662 Some("SENSITIVE_SESSION_TOKEN\n")
663 );
664 }
665
666 Ok(())
667 }
668
669 #[tokio::test]
670 async fn presign_refreshes_credential_that_cannot_cover_url_lifetime() -> Result<()> {
671 let now = Timestamp::now();
672 let short_lived = Credential {
673 access_key_id: "short-lived-ak".to_string(),
674 secret_access_key: "short-lived-sk".to_string(),
675 session_token: Some("short-lived-token".to_string()),
676 expires_in: Some(now + Duration::from_secs(600)),
677 };
678 let long_lived = Credential {
679 access_key_id: "long-lived-ak".to_string(),
680 secret_access_key: "long-lived-sk".to_string(),
681 session_token: Some("long-lived-token".to_string()),
682 expires_in: Some(now + Duration::from_secs(7200)),
683 };
684 let calls = Arc::new(AtomicUsize::new(0));
685 let provider = SequenceProvider {
686 credentials: Mutex::new(VecDeque::from([short_lived, long_lived])),
687 calls: calls.clone(),
688 };
689 let signer = Signer::new(
690 Context::new(),
691 provider,
692 RequestSigner::new("s3", "test").with_time(now),
693 );
694
695 let mut header_parts = Request::get("https://example.com/object")
696 .body(())?
697 .into_parts()
698 .0;
699 signer.sign(&mut header_parts, None).await?;
700
701 let mut query_parts = Request::get("https://example.com/object")
702 .body(())?
703 .into_parts()
704 .0;
705 signer
706 .sign(&mut query_parts, Some(Duration::from_secs(3600)))
707 .await?;
708
709 assert_eq!(calls.load(Ordering::SeqCst), 2);
710 assert!(
711 query_parts
712 .uri
713 .query()
714 .expect("presigned query must exist")
715 .contains("X-Amz-Credential=long-lived-ak%2F")
716 );
717 Ok(())
718 }
719
720 #[tokio::test]
721 async fn presign_rejects_refreshed_credential_that_cannot_cover_url_lifetime() -> Result<()> {
722 let now = Timestamp::now();
723 let short_lived = Credential {
724 access_key_id: "short-lived-ak".to_string(),
725 secret_access_key: "short-lived-sk".to_string(),
726 session_token: Some("short-lived-token".to_string()),
727 expires_in: Some(now + Duration::from_secs(600)),
728 };
729 let calls = Arc::new(AtomicUsize::new(0));
730 let provider = SequenceProvider {
731 credentials: Mutex::new(VecDeque::from([short_lived])),
732 calls: calls.clone(),
733 };
734 let signer = Signer::new(
735 Context::new(),
736 provider,
737 RequestSigner::new("s3", "test").with_time(now),
738 );
739 let mut parts = Request::get("https://example.com/object")
740 .body(())?
741 .into_parts()
742 .0;
743 let original = parts.clone();
744
745 let err = signer
746 .sign(&mut parts, Some(Duration::from_secs(3600)))
747 .await
748 .expect_err("credential must cover the entire presigned URL lifetime");
749
750 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
751 assert_eq!(calls.load(Ordering::SeqCst), 1);
752 assert_eq!(parts.uri, original.uri);
753 assert_eq!(parts.headers, original.headers);
754
755 let mut header_parts = Request::get("https://example.com/object")
756 .body(())?
757 .into_parts()
758 .0;
759 signer.sign(&mut header_parts, None).await?;
760 assert_eq!(calls.load(Ordering::SeqCst), 1);
761 assert!(
762 header_parts
763 .headers
764 .get(header::AUTHORIZATION)
765 .expect("authorization header must exist")
766 .to_str()?
767 .contains("Credential=short-lived-ak/")
768 );
769 Ok(())
770 }
771
772 type TestCase = (&'static str, fn() -> Request<&'static str>);
774
775 fn test_cases() -> Vec<TestCase> {
776 vec![
777 ("get_request", test_get_request),
778 ("get_request_with_sse", test_get_request_with_sse),
779 ("get_request_with_query", test_get_request_with_query),
780 ("get_request_virtual_host", test_get_request_virtual_host),
781 (
782 "get_request_with_query_virtual_host",
783 test_get_request_with_query_virtual_host,
784 ),
785 ("put_request", test_put_request),
786 (
787 "put_request_with_body_digest",
788 test_put_request_with_body_digest,
789 ),
790 ("put_request_virtual_host", test_put_request_virtual_host),
791 ]
792 }
793
794 fn test_get_request() -> Request<&'static str> {
795 let mut req = Request::new("");
796 *req.method_mut() = http::Method::GET;
797 *req.uri_mut() = "http://127.0.0.1:9000/hello"
798 .parse()
799 .expect("url must be valid");
800
801 req
802 }
803
804 fn test_get_request_with_sse() -> Request<&'static str> {
805 let mut req = Request::new("");
806 *req.method_mut() = http::Method::GET;
807 *req.uri_mut() = "http://127.0.0.1:9000/hello"
808 .parse()
809 .expect("url must be valid");
810 req.headers_mut().insert(
811 "x-amz-server-side-encryption",
812 "a".parse().expect("must be valid"),
813 );
814 req.headers_mut().insert(
815 "x-amz-server-side-encryption-customer-algorithm",
816 "b".parse().expect("must be valid"),
817 );
818 req.headers_mut().insert(
819 "x-amz-server-side-encryption-customer-key",
820 "c".parse().expect("must be valid"),
821 );
822 req.headers_mut().insert(
823 "x-amz-server-side-encryption-customer-key-md5",
824 "d".parse().expect("must be valid"),
825 );
826 req.headers_mut().insert(
827 "x-amz-server-side-encryption-aws-kms-key-id",
828 "e".parse().expect("must be valid"),
829 );
830
831 req
832 }
833
834 fn test_get_request_with_query() -> Request<&'static str> {
835 let mut req = Request::new("");
836 *req.method_mut() = http::Method::GET;
837 *req.uri_mut() = "http://127.0.0.1:9000/hello?list-type=2&max-keys=3&prefix=CI/&start-after=ExampleGuide.pdf"
838 .parse()
839 .expect("url must be valid");
840
841 req
842 }
843
844 fn test_get_request_virtual_host() -> Request<&'static str> {
845 let mut req = Request::new("");
846 *req.method_mut() = http::Method::GET;
847 *req.uri_mut() = "http://hello.s3.test.example.com"
848 .parse()
849 .expect("url must be valid");
850
851 req
852 }
853
854 fn test_get_request_with_query_virtual_host() -> Request<&'static str> {
855 let mut req = Request::new("");
856 *req.method_mut() = http::Method::GET;
857 *req.uri_mut() = "http://hello.s3.test.example.com?list-type=2&max-keys=3&prefix=CI/&start-after=ExampleGuide.pdf"
858 .parse()
859 .expect("url must be valid");
860
861 req
862 }
863
864 fn test_put_request() -> Request<&'static str> {
865 let content = "Hello,World!";
866 let mut req = Request::new(content);
867 *req.method_mut() = http::Method::PUT;
868 *req.uri_mut() = "http://127.0.0.1:9000/hello"
869 .parse()
870 .expect("url must be valid");
871
872 req.headers_mut().insert(
873 header::CONTENT_LENGTH,
874 HeaderValue::from_str(&content.len().to_string()).expect("must be valid"),
875 );
876
877 req
878 }
879
880 fn test_put_request_with_body_digest() -> Request<&'static str> {
881 let content = "Hello,World!";
882 let mut req = Request::new(content);
883 *req.method_mut() = http::Method::PUT;
884 *req.uri_mut() = "http://127.0.0.1:9000/hello"
885 .parse()
886 .expect("url must be valid");
887
888 req.headers_mut().insert(
889 header::CONTENT_LENGTH,
890 HeaderValue::from_str(&content.len().to_string()).expect("must be valid"),
891 );
892
893 let body = hex_sha256(content.as_bytes());
894 req.headers_mut().insert(
895 "x-amz-content-sha256",
896 HeaderValue::from_str(&body).expect("must be valid"),
897 );
898
899 req
900 }
901
902 fn test_put_request_virtual_host() -> Request<&'static str> {
903 let content = "Hello,World!";
904 let mut req = Request::new(content);
905 *req.method_mut() = http::Method::PUT;
906 *req.uri_mut() = "http://hello.s3.test.example.com"
907 .parse()
908 .expect("url must be valid");
909
910 req.headers_mut().insert(
911 header::CONTENT_LENGTH,
912 HeaderValue::from_str(&content.len().to_string()).expect("must be valid"),
913 );
914
915 req
916 }
917
918 #[track_caller]
919 fn compare_request(name: &str, l: &Request<&str>, r: &Request<&str>) {
920 fn format_headers(req: &Request<&str>) -> Vec<String> {
921 let mut hs = req
922 .headers()
923 .iter()
924 .map(|(k, v)| format!("{}:{}", k, v.to_str().expect("must be valid")))
925 .collect::<Vec<_>>();
926
927 if !hs.contains(&format!("host:{}", req.uri().authority().unwrap())) {
929 hs.push(format!("host:{}", req.uri().authority().unwrap()))
930 }
931
932 hs.sort();
933 hs
934 }
935
936 assert_eq!(
937 format_headers(l),
938 format_headers(r),
939 "{name} header mismatch"
940 );
941
942 fn format_query(req: &Request<&str>) -> Vec<String> {
943 let query = req.uri().query().unwrap_or_default();
944 let mut query = form_urlencoded::parse(query.as_bytes())
945 .map(|(k, v)| format!("{k}={v}"))
946 .collect::<Vec<_>>();
947 query.sort();
948 query
949 }
950
951 assert_eq!(format_query(l), format_query(r), "{name} query mismatch");
952 }
953
954 #[tokio::test]
955 async fn canonicalization_preserves_wire_uri() -> Result<()> {
956 let credential = Credential {
957 access_key_id: "access_key_id".to_string(),
958 secret_access_key: "secret_access_key".to_string(),
959 ..Default::default()
960 };
961 let now: Timestamp = "2026-07-22T00:00:00Z".parse()?;
962 let signer = RequestSigner::new("s3", "test").with_time(now);
963 let original_uri = format!("https://example.com/object%2Fname?{RAW_QUERY}");
964
965 let mut canonical_parts = Request::get(&original_uri).body(())?.into_parts().0;
966 let mut signing_req = SigningRequest::build(&mut canonical_parts)?;
967 canonicalize_headers(
968 &mut signing_req,
969 &credential,
970 Some(Duration::from_secs(60)),
971 now,
972 )?;
973 let auth_query = authentication_query(
974 &signing_req,
975 &credential,
976 Some(Duration::from_secs(60)),
977 now,
978 "s3",
979 "test",
980 false,
981 );
982 let canonical_query = canonicalize_query(&signing_req, &auth_query);
983
984 assert_eq!(canonical_uri(&signing_req.path)?, "/object%2Fname");
985 assert!(canonical_query.contains(&("literal-plus".to_string(), "%2B".to_string())));
986 assert!(canonical_query.contains(&("double".to_string(), "%252F".to_string())));
987 assert!(canonical_query.contains(&(String::new(), "empty-key".to_string())));
988 assert!(canonical_query.contains(&("flag".to_string(), String::new())));
989
990 let mut header_parts = Request::get(&original_uri)
991 .header("x-custom", " value ")
992 .body(())?
993 .into_parts()
994 .0;
995 signer
996 .sign_request(&Context::new(), &mut header_parts, Some(&credential), None)
997 .await?;
998 assert_eq!(header_parts.uri.to_string(), original_uri);
999 assert_eq!(header_parts.headers["x-custom"], " value ");
1000
1001 let mut query_parts = Request::get(&original_uri).body(())?.into_parts().0;
1002 signer
1003 .sign_request(
1004 &Context::new(),
1005 &mut query_parts,
1006 Some(&credential),
1007 Some(Duration::from_secs(60)),
1008 )
1009 .await?;
1010 assert!(
1011 query_parts
1012 .uri
1013 .to_string()
1014 .starts_with(&format!("{original_uri}X-Amz-Algorithm="))
1015 );
1016 assert!(
1017 query_parts
1018 .uri
1019 .query()
1020 .unwrap()
1021 .contains("X-Amz-Signature=")
1022 );
1023
1024 Ok(())
1025 }
1026
1027 #[tokio::test]
1028 async fn test() -> Result<()> {
1029 for (name, req) in test_cases() {
1030 calculate(req)
1031 .await
1032 .unwrap_or_else(|err| panic!("calculate {name} should pass: {err:?}"));
1033 calculate_in_query(req)
1034 .await
1035 .unwrap_or_else(|err| panic!("calculate_in_query {name} should pass: {err:?}"));
1036 test_calculate_with_token(req).await.unwrap_or_else(|err| {
1037 panic!("test_calculate_with_token {name} should pass: {err:?}")
1038 });
1039 test_calculate_with_token_in_query(req)
1040 .await
1041 .unwrap_or_else(|err| {
1042 panic!("test_calculate_with_token_in_query {name} should pass: {err:?}")
1043 });
1044 }
1045 Ok(())
1046 }
1047
1048 async fn calculate(req_fn: fn() -> Request<&'static str>) -> Result<()> {
1049 let _ = env_logger::builder().is_test(true).try_init();
1050
1051 let mut req = req_fn();
1052 let name = format!(
1053 "{} {} {:?}",
1054 req.method(),
1055 req.uri().path(),
1056 req.uri().query(),
1057 );
1058 let now = Timestamp::now();
1059
1060 let mut ss = SigningSettings::default();
1061 ss.percent_encoding_mode = PercentEncodingMode::Double;
1062 ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
1063 let id = Credentials::new(
1064 "access_key_id",
1065 "secret_access_key",
1066 None,
1067 None,
1068 "hardcoded-credentials",
1069 )
1070 .into();
1071 let sp = v4::SigningParams::builder()
1072 .identity(&id)
1073 .region("test")
1074 .name("s3")
1075 .time(now.as_system_time())
1076 .settings(ss)
1077 .build()
1078 .expect("signing params must be valid");
1079
1080 let mut body = SignableBody::UnsignedPayload;
1081 if req.headers().get(X_AMZ_CONTENT_SHA_256).is_some() {
1082 body = SignableBody::Bytes(req.body().as_bytes());
1083 }
1084
1085 let output = aws_sigv4::http_request::sign(
1086 SignableRequest::new(
1087 req.method().as_str(),
1088 req.uri().to_string(),
1089 req.headers()
1090 .iter()
1091 .map(|(k, v)| (k.as_str(), std::str::from_utf8(v.as_bytes()).unwrap())),
1092 body,
1093 )
1094 .unwrap(),
1095 &sp.into(),
1096 )?;
1097 let (aws_sig, _) = output.into_parts();
1098 aws_sig.apply_to_request_http1x(&mut req);
1099 let expected_req = req;
1100
1101 let req = req_fn();
1102 let (mut parts, body) = req.into_parts();
1103
1104 let ctx = Context::new()
1105 .with_file_read(TokioFileRead)
1106 .with_http_send(ReqwestHttpSend::default());
1107 let loader = StaticCredentialProvider::new("access_key_id", "secret_access_key");
1108 let cred = loader.provide_credential(&ctx).await?.unwrap();
1109
1110 let builder = RequestSigner::new("s3", "test").with_time(now);
1111 builder
1112 .sign_request(&ctx, &mut parts, Some(&cred), None)
1113 .await
1114 .expect("must apply success");
1115
1116 let actual_req = Request::from_parts(parts, body);
1117
1118 compare_request(&name, &expected_req, &actual_req);
1119
1120 Ok(())
1121 }
1122
1123 async fn calculate_in_query(req_fn: fn() -> Request<&'static str>) -> Result<()> {
1124 let _ = env_logger::builder().is_test(true).try_init();
1125
1126 let mut req = req_fn();
1127 let name = format!(
1128 "{} {} {:?}",
1129 req.method(),
1130 req.uri().path(),
1131 req.uri().query(),
1132 );
1133 let now = Timestamp::now();
1134
1135 let mut ss = SigningSettings::default();
1136 ss.percent_encoding_mode = PercentEncodingMode::Double;
1137 ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
1138 ss.signature_location = SignatureLocation::QueryParams;
1139 ss.expires_in = Some(Duration::from_secs(3600));
1140 let id = Credentials::new(
1141 "access_key_id",
1142 "secret_access_key",
1143 None,
1144 None,
1145 "hardcoded-credentials",
1146 )
1147 .into();
1148 let sp = v4::SigningParams::builder()
1149 .identity(&id)
1150 .region("test")
1151 .name("s3")
1152 .time(now.as_system_time())
1153 .settings(ss)
1154 .build()
1155 .expect("signing params must be valid");
1156
1157 let mut body = SignableBody::UnsignedPayload;
1158 if req.headers().get(X_AMZ_CONTENT_SHA_256).is_some() {
1159 body = SignableBody::Bytes(req.body().as_bytes());
1160 }
1161
1162 let output = aws_sigv4::http_request::sign(
1163 SignableRequest::new(
1164 req.method().as_str(),
1165 req.uri().to_string(),
1166 req.headers()
1167 .iter()
1168 .map(|(k, v)| (k.as_str(), std::str::from_utf8(v.as_bytes()).unwrap())),
1169 body,
1170 )
1171 .unwrap(),
1172 &sp.into(),
1173 )
1174 .expect("signing must succeed");
1175 let (aws_sig, _) = output.into_parts();
1176 aws_sig.apply_to_request_http1x(&mut req);
1177 let expected_req = req;
1178
1179 let req = req_fn();
1180 let (mut parts, body) = req.into_parts();
1181
1182 let ctx = Context::new()
1183 .with_file_read(TokioFileRead)
1184 .with_http_send(ReqwestHttpSend::default());
1185 let loader = StaticCredentialProvider::new("access_key_id", "secret_access_key");
1186 let cred = loader.provide_credential(&ctx).await?.unwrap();
1187
1188 let builder = RequestSigner::new("s3", "test").with_time(now);
1189
1190 builder
1191 .sign_request(
1192 &ctx,
1193 &mut parts,
1194 Some(&cred),
1195 Some(Duration::from_secs(3600)),
1196 )
1197 .await?;
1198 let actual_req = Request::from_parts(parts, body);
1199
1200 compare_request(&name, &expected_req, &actual_req);
1201
1202 Ok(())
1203 }
1204
1205 async fn test_calculate_with_token(req_fn: fn() -> Request<&'static str>) -> Result<()> {
1206 let _ = env_logger::builder().is_test(true).try_init();
1207
1208 let mut req = req_fn();
1209 let name = format!(
1210 "{} {} {:?}",
1211 req.method(),
1212 req.uri().path(),
1213 req.uri().query(),
1214 );
1215 let now = Timestamp::now();
1216
1217 let mut ss = SigningSettings::default();
1218 ss.percent_encoding_mode = PercentEncodingMode::Double;
1219 ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
1220 let id = Credentials::new(
1221 "access_key_id",
1222 "secret_access_key",
1223 Some("security_token".to_string()),
1224 None,
1225 "hardcoded-credentials",
1226 )
1227 .into();
1228 let sp = v4::SigningParams::builder()
1229 .identity(&id)
1230 .region("test")
1231 .name("s3")
1232 .time(now.as_system_time())
1233 .settings(ss)
1234 .build()
1235 .expect("signing params must be valid");
1236
1237 let mut body = SignableBody::UnsignedPayload;
1238 if req.headers().get(X_AMZ_CONTENT_SHA_256).is_some() {
1239 body = SignableBody::Bytes(req.body().as_bytes());
1240 }
1241
1242 let output = aws_sigv4::http_request::sign(
1243 SignableRequest::new(
1244 req.method().as_str(),
1245 req.uri().to_string(),
1246 req.headers()
1247 .iter()
1248 .map(|(k, v)| (k.as_str(), std::str::from_utf8(v.as_bytes()).unwrap())),
1249 body,
1250 )
1251 .unwrap(),
1252 &sp.into(),
1253 )
1254 .expect("signing must succeed");
1255 let (aws_sig, _) = output.into_parts();
1256 aws_sig.apply_to_request_http1x(&mut req);
1257 let expected_req = req;
1258
1259 let req = req_fn();
1260 let (mut parts, body) = req.into_parts();
1261
1262 let ctx = Context::new()
1263 .with_file_read(TokioFileRead)
1264 .with_http_send(ReqwestHttpSend::default());
1265 let loader = StaticCredentialProvider::new("access_key_id", "secret_access_key")
1266 .with_session_token("security_token");
1267 let cred = loader.provide_credential(&ctx).await?.unwrap();
1268
1269 let builder = RequestSigner::new("s3", "test").with_time(now);
1270 builder
1271 .sign_request(&ctx, &mut parts, Some(&cred), None)
1272 .await
1273 .expect("must apply success");
1274 let actual_req = Request::from_parts(parts, body);
1275
1276 compare_request(&name, &expected_req, &actual_req);
1277
1278 Ok(())
1279 }
1280
1281 async fn test_calculate_with_token_in_query(
1282 req_fn: fn() -> Request<&'static str>,
1283 ) -> Result<()> {
1284 let _ = env_logger::builder().is_test(true).try_init();
1285
1286 let mut req = req_fn();
1287 let name = format!(
1288 "{} {} {:?}",
1289 req.method(),
1290 req.uri().path(),
1291 req.uri().query(),
1292 );
1293 let now = Timestamp::now();
1294
1295 let mut ss = SigningSettings::default();
1296 ss.percent_encoding_mode = PercentEncodingMode::Double;
1297 ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
1298 ss.signature_location = SignatureLocation::QueryParams;
1299 ss.expires_in = Some(Duration::from_secs(3600));
1300 let id = Credentials::new(
1301 "access_key_id",
1302 "secret_access_key",
1303 Some("security_token".to_string()),
1304 None,
1305 "hardcoded-credentials",
1306 )
1307 .into();
1308 let sp = v4::SigningParams::builder()
1309 .identity(&id)
1310 .region("test")
1311 .name("s3")
1313 .time(now.as_system_time())
1314 .settings(ss)
1315 .build()
1316 .expect("signing params must be valid");
1317
1318 let mut body = SignableBody::UnsignedPayload;
1319 if req.headers().get(X_AMZ_CONTENT_SHA_256).is_some() {
1320 body = SignableBody::Bytes(req.body().as_bytes());
1321 }
1322
1323 let output = aws_sigv4::http_request::sign(
1324 SignableRequest::new(
1325 req.method().as_str(),
1326 req.uri().to_string(),
1327 req.headers()
1328 .iter()
1329 .map(|(k, v)| (k.as_str(), std::str::from_utf8(v.as_bytes()).unwrap())),
1330 body,
1331 )
1332 .unwrap(),
1333 &sp.into(),
1334 )
1335 .expect("signing must succeed");
1336 let (aws_sig, _) = output.into_parts();
1337 aws_sig.apply_to_request_http1x(&mut req);
1338 let expected_req = req;
1339
1340 let req = req_fn();
1341 let (mut parts, body) = req.into_parts();
1342
1343 let ctx = Context::new()
1344 .with_file_read(TokioFileRead)
1345 .with_http_send(ReqwestHttpSend::default());
1346 let loader = StaticCredentialProvider::new("access_key_id", "secret_access_key")
1347 .with_session_token("security_token");
1348 let cred = loader.provide_credential(&ctx).await?.unwrap();
1349
1350 let builder = RequestSigner::new("s3", "test").with_time(now);
1351 builder
1352 .sign_request(
1353 &ctx,
1354 &mut parts,
1355 Some(&cred),
1356 Some(Duration::from_secs(3600)),
1357 )
1358 .await
1359 .expect("must apply success");
1360 let actual_req = Request::from_parts(parts, body);
1361
1362 compare_request(&name, &expected_req, &actual_req);
1363
1364 Ok(())
1365 }
1366}