1use std::time::SystemTime;
9
10use microvms_app::control::transport::{
11 Call, Reply, SIGNING_NAME, Transport, endpoint_for, error_type_name,
12};
13use microvms_app::error::{Error, ErrorKind};
14use microvms_app::region::Region;
15
16pub struct SignedTransport {
18 region: Region,
19 endpoint: String,
20 credentials: aws_credential_types::provider::SharedCredentialsProvider,
21 http: reqwest::Client,
22}
23
24impl std::fmt::Debug for SignedTransport {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 f.debug_struct("SignedTransport")
29 .field("region", &self.region)
30 .field("endpoint", &self.endpoint)
31 .finish_non_exhaustive()
32 }
33}
34
35impl SignedTransport {
36 pub async fn new(region: Region) -> Result<Self, Error> {
49 let credentials = default_credentials(®ion).await?;
50 let http = http_client(std::time::Duration::from_secs(60))?;
54
55 Ok(Self {
56 endpoint: endpoint_for(®ion),
57 region,
58 credentials,
59 http,
60 })
61 }
62
63 fn url(&self, call: &Call) -> String {
66 format!("{}{}", self.endpoint, call.path)
67 }
68}
69
70pub(crate) async fn default_credentials(
75 region: &Region,
76) -> Result<aws_credential_types::provider::SharedCredentialsProvider, Error> {
77 Box::pin(super::credentials::shared(region)).await
81}
82
83pub(crate) fn http_client(timeout: std::time::Duration) -> Result<reqwest::Client, Error> {
85 reqwest::Client::builder()
86 .connect_timeout(std::time::Duration::from_secs(10))
87 .timeout(timeout)
88 .build()
89 .map_err(|error| {
90 Error::new(
91 ErrorKind::Precondition,
92 format!("could not build the HTTP client: {error}"),
93 )
94 .with_source(error)
95 })
96}
97
98pub(crate) async fn resolve_credentials(
100 provider: &aws_credential_types::provider::SharedCredentialsProvider,
101 operation: &str,
102) -> Result<aws_credential_types::Credentials, Error> {
103 use aws_credential_types::provider::ProvideCredentials as _;
104
105 provider.provide_credentials().await.map_err(|error| {
106 let error = super::credentials::diagnostic(&error);
107 Error::new(
108 ErrorKind::Credentials,
109 format!(
110 "could not resolve credentials for {operation}: {error}. Check the provider \
111 source: credentials may be missing or their endpoint unavailable."
112 ),
113 )
114 .with_source(error)
115 })
116}
117
118impl Transport for SignedTransport {
119 fn region(&self) -> Option<&Region> {
120 Some(&self.region)
121 }
122
123 fn resolve_credentials(
124 &self,
125 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>> {
126 Box::pin(async move {
127 use aws_credential_types::provider::ProvideCredentials as _;
128 self.credentials
129 .provide_credentials()
130 .await
131 .map(|_| ())
132 .map_err(|error| {
133 let error = super::credentials::diagnostic(&error);
134 Error::new(
135 ErrorKind::Credentials,
136 format!(
137 "the default credential chain resolved no credentials for {}: \
138 {error}. It looks at environment variables, the shared config \
139 files, SSO, a credential process, then the instance metadata \
140 service.",
141 self.region
142 ),
143 )
144 .with_source(error)
145 })
146 })
147 }
148
149 fn send(
150 &self,
151 call: Call,
152 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Reply, Error>> + Send + '_>>
153 {
154 Box::pin(async move {
155 let credentials = resolve_credentials(&self.credentials, call.operation).await?;
158
159 let url = self.url(&call);
160 let body = call.body_bytes().to_vec();
161
162 let mut request = http::Request::builder()
165 .method(call.method.as_str())
166 .uri(&url)
167 .header("content-type", "application/json")
168 .body(body.clone())
169 .map_err(|error| {
170 Error::new(
171 ErrorKind::Unexpected,
172 format!("could not build the {} request: {error}", call.operation),
173 )
174 .with_source(error)
175 })?;
176
177 sign_in_place(&mut request, &credentials, &self.region)?;
178
179 let (parts, body) = request.into_parts();
180 let request = http::Request::from_parts(parts, reqwest::Body::from(body));
181 let request = reqwest::Request::try_from(request).map_err(|error| {
182 Error::new(
183 ErrorKind::Unexpected,
184 format!("could not convert the signed request: {error}"),
185 )
186 .with_source(error)
187 })?;
188
189 let response = self.http.execute(request).await.map_err(|error| {
192 Error::new(
193 ErrorKind::Retryable,
194 format!(
195 "the {} request to {url} did not complete: {error}",
196 call.operation
197 ),
198 )
199 .with_source(error)
200 })?;
201
202 let status = response.status().as_u16();
203 let error_type = response
207 .headers()
208 .get("x-amzn-errortype")
209 .and_then(|value| value.to_str().ok())
210 .map(|value| error_type_name(value).to_string());
211 let body = response
212 .bytes()
213 .await
214 .map_err(|error| {
215 Error::new(
216 ErrorKind::Retryable,
217 format!(
218 "could not read the {} response body: {error}",
219 call.operation
220 ),
221 )
222 .with_source(error)
223 })?
224 .to_vec();
225
226 Ok(Reply {
227 status,
228 body,
229 error_type,
230 })
231 })
232 }
233}
234
235fn sign_in_place(
241 request: &mut http::Request<Vec<u8>>,
242 credentials: &aws_credential_types::Credentials,
243 region: &Region,
244) -> Result<(), Error> {
245 sign_for(
246 request,
247 credentials,
248 region,
249 SIGNING_NAME,
250 aws_sigv4::http_request::SigningSettings::default(),
251 )
252}
253
254pub(crate) fn sign_for(
260 request: &mut http::Request<Vec<u8>>,
261 credentials: &aws_credential_types::Credentials,
262 region: &Region,
263 service: &str,
264 settings: aws_sigv4::http_request::SigningSettings,
265) -> Result<(), Error> {
266 use aws_sigv4::http_request::{SignableBody, SignableRequest, sign};
267 use aws_sigv4::sign::v4;
268
269 let identity = credentials.clone().into();
270 let params: aws_sigv4::http_request::SigningParams = v4::SigningParams::builder()
271 .identity(&identity)
272 .region(region.as_str())
273 .name(service)
274 .time(SystemTime::now())
275 .settings(settings)
276 .build()
277 .map_err(|error| {
278 Error::new(
279 ErrorKind::Unexpected,
280 format!("could not build SigV4 signing parameters: {error}"),
281 )
282 .with_source(error)
283 })?
284 .into();
285
286 let instructions = {
289 let signable = SignableRequest::new(
290 request.method().as_str(),
291 request.uri().to_string(),
292 request.headers().iter().filter_map(|(name, value)| {
293 value.to_str().ok().map(|value| (name.as_str(), value))
299 }),
300 SignableBody::Bytes(request.body()),
303 )
304 .map_err(|error| {
305 Error::new(
306 ErrorKind::Unexpected,
307 format!("could not build the signable request: {error}"),
308 )
309 .with_source(error)
310 })?;
311
312 sign(signable, ¶ms)
313 .map_err(|error| {
314 Error::new(
315 ErrorKind::Credentials,
316 format!("SigV4 signing failed: {error}"),
317 )
318 .with_source(error)
319 })?
320 .into_parts()
321 .0
322 };
323
324 instructions.apply_to_request_http1x(request);
328 Ok(())
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use microvms_app::control::transport::Method;
335
336 #[test]
339 fn signed_transport_exposes_its_configured_region_to_the_port() {
340 let credentials = aws_credential_types::provider::SharedCredentialsProvider::new(
341 aws_credential_types::Credentials::new("AKIDEXAMPLE", "secret", None, None, "test"),
342 );
343 for region in [
344 Region::US_EAST_1,
345 Region::EU_WEST_1,
346 "us-west-1".parse().expect("a region name"),
347 ] {
348 let transport = SignedTransport {
349 endpoint: endpoint_for(®ion),
350 region: region.clone(),
351 credentials: credentials.clone(),
352 http: reqwest::Client::new(),
353 };
354 let port: &dyn Transport = &transport;
355 assert_eq!(port.region(), Some(®ion));
356 }
357 }
358
359 #[derive(Debug)]
360 struct LeakySource(std::env::VarError);
361
362 impl std::fmt::Display for LeakySource {
363 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364 write!(f, "provider failed: {}", self.0)
365 }
366 }
367
368 impl std::error::Error for LeakySource {
369 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
370 Some(&self.0)
371 }
372 }
373
374 #[derive(Debug)]
375 struct BrokenCredentials;
376
377 impl aws_credential_types::provider::ProvideCredentials for BrokenCredentials {
378 fn provide_credentials<'a>(
379 &'a self,
380 ) -> aws_credential_types::provider::future::ProvideCredentials<'a>
381 where
382 Self: 'a,
383 {
384 aws_credential_types::provider::future::ProvideCredentials::new(async {
385 Err(
386 aws_credential_types::provider::error::CredentialsError::unhandled(
387 LeakySource(std::env::VarError::NotUnicode(
388 "SYNTHETIC_CREDENTIAL_SENTINEL".into(),
389 )),
390 ),
391 )
392 })
393 }
394 }
395
396 #[tokio::test]
397 async fn credential_diagnostics_discard_secret_values_on_both_resolution_paths() {
398 let provider =
399 aws_credential_types::provider::SharedCredentialsProvider::new(BrokenCredentials);
400 let transport = SignedTransport {
401 region: Region::US_EAST_1,
402 endpoint: endpoint_for(&Region::US_EAST_1),
403 credentials: provider.clone(),
404 http: http_client(std::time::Duration::from_secs(1)).unwrap(),
405 };
406 let errors = [
407 resolve_credentials(&provider, "TestOperation")
408 .await
409 .unwrap_err(),
410 transport.resolve_credentials().await.unwrap_err(),
411 ];
412 for error in errors {
413 assert_eq!(error.code(), "ERR_CREDENTIALS");
414 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(&error);
415 while let Some(inner) = source {
416 assert!(
417 !format!("{inner} {inner:?}").contains("SYNTHETIC_CREDENTIAL_SENTINEL"),
418 "credential diagnostics must discard secret values"
419 );
420 source = inner.source();
421 }
422 assert!(
423 error
424 .to_string()
425 .contains("credential environment variable is not valid Unicode")
426 );
427 assert!(!error.retryable());
428 }
429 }
430
431 #[tokio::test]
438 async fn constructing_the_real_transport_returns_a_result_rather_than_panicking() {
439 let _ = SignedTransport::new(Region::US_EAST_1).await;
440 }
441
442 #[test]
448 fn signing_names_the_region_and_the_service_in_the_credential_scope() {
449 let credentials = aws_credential_types::Credentials::new(
450 "AKIDEXAMPLE",
451 "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
452 Some("session-token".to_string()),
453 None,
454 "test",
455 );
456 let mut request = http::Request::builder()
457 .method("POST")
458 .uri("https://lambda.eu-west-1.amazonaws.com/2025-09-09/microvms")
459 .header("content-type", "application/json")
460 .body(b"{}".to_vec())
461 .expect("builds");
462
463 sign_in_place(&mut request, &credentials, &Region::EU_WEST_1).expect("signs");
464
465 let headers = request.headers();
466 let authorization = headers
467 .get("authorization")
468 .expect("a signature was applied")
469 .to_str()
470 .expect("ascii");
471 assert!(
472 authorization.starts_with("AWS4-HMAC-SHA256 "),
473 "{authorization}"
474 );
475 assert!(
476 authorization.contains("/eu-west-1/lambda/aws4_request"),
477 "the credential scope must name the request region and the signing name: \
478 {authorization}"
479 );
480 assert!(
481 headers.contains_key("x-amz-date"),
482 "a signed date is required"
483 );
484 assert!(
485 headers.contains_key("x-amz-security-token"),
486 "temporary credentials carry a session token in the canonical request"
487 );
488 }
489
490 #[test]
494 fn a_bodyless_get_is_signed_rather_than_sent_unsigned() {
495 let credentials =
496 aws_credential_types::Credentials::new("AKIDEXAMPLE", "secret", None, None, "test");
497 let mut request = http::Request::builder()
498 .method("GET")
499 .uri("https://lambda.us-east-1.amazonaws.com/2025-09-09/microvms/mvm-1")
500 .body(Vec::new())
501 .expect("builds");
502
503 sign_in_place(&mut request, &credentials, &Region::US_EAST_1).expect("signs");
504 assert!(request.headers().contains_key("authorization"));
505 assert!(
506 !request.headers().contains_key("x-amz-security-token"),
507 "static credentials carry no session token"
508 );
509 }
510
511 #[test]
518 fn a_patch_is_signed_with_its_method_in_the_canonical_request() {
519 let credentials =
520 aws_credential_types::Credentials::new("AKIDEXAMPLE", "secret", None, None, "test");
521 let mut request = http::Request::builder()
522 .method(Method::Patch.as_str())
523 .uri(
524 "https://lambda.us-east-1.amazonaws.com/2025-09-09/microvm-images/img/versions/2.0",
525 )
526 .header("content-type", "application/json")
527 .body(br#"{"status":"INACTIVE"}"#.to_vec())
528 .expect("builds");
529
530 sign_in_place(&mut request, &credentials, &Region::US_EAST_1).expect("signs");
531 assert_eq!(request.method().as_str(), "PATCH");
532 assert!(request.headers().contains_key("authorization"));
533 }
534}