Skip to main content

microvms_edges/control/
transport.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The signed transport: the default credential chain, SigV4, and reqwest.
3//!
4//! [`Transport`] and the calls it carries are `microvms_app::control::transport`. This is the
5//! implementation that reaches AWS, and the credential and signing helpers the build services
6//! and the Bedrock minter share with it, so the three resolve one identity and sign one way.
7
8use 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
16/// The real transport: resolve credentials, sign SigV4, send with reqwest.
17pub 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    /// Hand-written because a derived one would print the credentials provider, and the
26    /// provider's own `Debug` is not something this crate controls.
27    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    /// Resolves the default credential chain for `region` and builds the HTTP client.
37    ///
38    /// # Why the chain rather than explicit keys
39    ///
40    /// Because the credentials a developer actually has are in SSO, a credential process,
41    /// or an instance profile, and re-implementing that resolution is how a client ends up
42    /// working only on the machine it was written on. `aws-config`'s default chain is the
43    /// whole reason to depend on it at all. Its HTTP client serves credential endpoints;
44    /// reqwest sends the signed service requests. SSO and credential processes stay enabled.
45    ///
46    /// The shared provider keeps the SDK identity cache: temporary credentials refresh
47    /// before expiry, and concurrent requests share the same refresh.
48    pub async fn new(region: Region) -> Result<Self, Error> {
49        let credentials = default_credentials(&region).await?;
50        // Generous rather than tight: a control-plane call is not a hot path, and a timeout
51        // shorter than the service's own tail latency turns a slow answer into a retry storm
52        // against an operation that may not be idempotent.
53        let http = http_client(std::time::Duration::from_secs(60))?;
54
55        Ok(Self {
56            endpoint: endpoint_for(&region),
57            region,
58            credentials,
59            http,
60        })
61    }
62
63    /// The URL this transport would send `call` to. Public for the test that checks a path
64    /// against the model without needing credentials.
65    fn url(&self, call: &Call) -> String {
66        format!("{}{}", self.endpoint, call.path)
67    }
68}
69
70/// The process-wide default credential chain and identity cache for `region`.
71///
72/// Shared by [`SignedTransport`] and [`crate::control::SignedBuildServices`], so the STS and
73/// S3 calls `ensure_image` makes resolve exactly the identity the control-plane calls do.
74pub(crate) async fn default_credentials(
75    region: &Region,
76) -> Result<aws_credential_types::provider::SharedCredentialsProvider, Error> {
77    // Loading SDK configuration makes a large future. Keep it off callers' async
78    // state so a debug CLI dispatch fits the Windows main thread's stack even when
79    // the selected command never loads credentials (for example, `manifest`).
80    Box::pin(super::credentials::shared(region)).await
81}
82
83/// A reqwest client with a 10-second connect timeout and the given overall `timeout`.
84pub(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
98/// Resolves the current credentials from `provider`, naming `operation` on failure.
99pub(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            // The shared wrapper resolves through the SDK identity cache, which refreshes
156            // temporary credentials before expiry and coalesces concurrent cache misses.
157            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            // Built before signing, because the signature covers the headers that are on
163            // it: content-type is signed, and adding one afterwards invalidates it.
164            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            // A failure here produced no status, so it says nothing about service state:
190            // Retryable, matching the daemon transport's own rule.
191            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            // Read before the body consumes the response. The service names its error
204            // here, and the name decides what a status alone would get wrong: its
205            // throttle has arrived as HTTP 400.
206            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
235/// Signs `request` in place with SigV4 for `region`.
236///
237/// Split out so the signing step is one readable unit and so the test below can sign a
238/// request with static credentials and inspect the headers, which is the only way to check
239/// the signing name and region without a live call.
240fn 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
254/// Signs `request` in place with SigV4 for `service` in `region`, under `settings`.
255///
256/// The control plane signs as `lambda` with the default settings; the build services sign
257/// as `sts`, and as `s3` with S3's own settings (a single-encoded path and a signed
258/// `x-amz-content-sha256`), which is why the name and settings are parameters here.
259pub(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    // The headers view has to borrow from `request`, so the signable view is built and
287    // consumed before the mutable borrow that applies the signature.
288    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                // A header whose value is not ASCII cannot be part of a canonical
294                // request. This client sets only content-type, so the filter is a
295                // guard against a future header rather than a live case — and dropping
296                // one is correct: an unsigned header is still sent, it is just not
297                // covered.
298                value.to_str().ok().map(|value| (name.as_str(), value))
299            }),
300            // `Bytes`, never `UnsignedPayload`: non-S3 services reject an unsigned
301            // payload, and an empty body still needs its empty-payload SHA256 signed.
302            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, &params)
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    // `http1x` rather than `http0x`: aws-sigv4's default features are sign-http + http1,
325    // which is the version reqwest 0.13 consumes through TryFrom. Mixing the two http
326    // versions would mean converting on every call.
327    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    /// The denial classifier reads the transport port's region. Build the real transport
337    /// with a static provider so checking that wiring never resolves the default chain.
338    #[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::UsEast1,
345            Region::EuWest1,
346            Region::unlisted("eu-central-1"),
347        ] {
348            let transport = SignedTransport {
349                endpoint: endpoint_for(&region),
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(&region));
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::UsEast1,
402            endpoint: endpoint_for(&Region::UsEast1),
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    /// The regression that blocked the whole live tier: with aws-config's
432    /// `default-https-client` off, `load()` panics with "a http_client is
433    /// required" before any credential question is even asked. Constructing
434    /// through the real chain must yield a `Result` — either outcome is fine
435    /// here (this host may or may not have credentials); a panic is the bug.
436    /// IMDS is the only endpoint this can touch: link-local, free, and fast.
437    #[tokio::test]
438    async fn constructing_the_real_transport_returns_a_result_rather_than_panicking() {
439        let _ = SignedTransport::new(Region::UsEast1).await;
440    }
441
442    /// Signing puts an `authorization` header on the request naming the region and the
443    /// signing name, and an `x-amz-security-token` when the credentials are temporary.
444    ///
445    /// Static credentials rather than a resolved chain, so this runs with no AWS
446    /// configuration and no network — the signature is a pure function of the inputs.
447    #[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::EuWest1).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    /// A `GET` with no body still gets signed, with the empty-payload SHA256 —
491    /// `SignableBody::Bytes(&[])` rather than `UnsignedPayload`, which non-S3 services
492    /// reject.
493    #[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::UsEast1).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    /// A `PATCH` gets signed like every other method, with the empty-payload rule not applying
512    /// because it has a body.
513    ///
514    /// Worth its own case because the signing path reads the method as a string for the
515    /// canonical request: a method the enum spells and the signer does not is a signature over
516    /// a different request than the one sent, which reads as bad credentials.
517    #[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::UsEast1).expect("signs");
531        assert_eq!(request.method().as_str(), "PATCH");
532        assert!(request.headers().contains_key("authorization"));
533    }
534}