diff --git a/.github/workflows/ci_rust.yml b/.github/workflows/ci_rust.yml index b6ad2b01842..48e93cd8aa8 100644 --- a/.github/workflows/ci_rust.yml +++ b/.github/workflows/ci_rust.yml @@ -33,7 +33,7 @@ jobs: rustup override set stable # https://github.com/aws/aws-lc-rs/blob/main/aws-lc-fips-sys/README.md#build-prerequisites - # go required to build aws-lc-rs in FIPS mode + # go required for generate.sh to build aws-lc-rs in FIPS mode - name: Install go uses: actions/setup-go@v4 with: @@ -46,7 +46,8 @@ jobs: - name: Tests working-directory: ${{env.ROOT_PATH}} - run: cargo test --all-features + # Test all features except for FIPS, which is tested separately. + run: cargo test --features unstable-fingerprint,unstable-ktls,quic,pq # Ensure that all tests pass with the default feature set - name: Default Tests @@ -159,7 +160,11 @@ jobs: run: cargo test --all-features fips: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macOS-latest] steps: - uses: actions/checkout@v3 with: @@ -186,7 +191,16 @@ jobs: - name: Test fips working-directory: ${{env.ROOT_PATH}} run: | - cargo test --features fips + # The doc tests fail to link to AWS-LC in FIPS mode due to + # https://github.com/rust-lang/cargo/issues/8531. The --tests flag is provided to disable + # the doc tests. The doc tests are tested in the generate test, where FIPS is disabled. + cargo test --tests --features fips + + # Test all features, including FIPS + - name: Test all + working-directory: ${{env.ROOT_PATH}} + run: | + cargo test --tests --all-features rustfmt: runs-on: ubuntu-latest diff --git a/bindings/rust/Cargo.toml b/bindings/rust/Cargo.toml index 5b82a43e898..15b00cc7009 100644 --- a/bindings/rust/Cargo.toml +++ b/bindings/rust/Cargo.toml @@ -4,6 +4,7 @@ members = [ "s2n-tls", "s2n-tls-sys", "s2n-tls-tokio", + "s2n-tls-hyper", ] # generate can't be included in the workspace because of a bootstrapping problem # s2n-tls-sys/Cargo.toml (part of the workspace) is generated by diff --git a/bindings/rust/s2n-tls-hyper/Cargo.toml b/bindings/rust/s2n-tls-hyper/Cargo.toml new file mode 100644 index 00000000000..f29f27e0c93 --- /dev/null +++ b/bindings/rust/s2n-tls-hyper/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "s2n-tls-hyper" +description = "A compatbility crate allowing s2n-tls to be used with the hyper HTTP library" +version = "0.0.1" +authors = ["AWS s2n"] +edition = "2021" +rust-version = "1.63.0" +repository = "https://github.com/aws/s2n-tls" +license = "Apache-2.0" + +[features] +default = [] + +[dependencies] +s2n-tls = { version = "=0.2.9", path = "../s2n-tls" } +s2n-tls-tokio = { version = "=0.2.9", path = "../s2n-tls-tokio" } +hyper = { version = "1" } +hyper-util = { version = "0.1", features = ["client-legacy", "tokio", "http1"] } +tower-service = { version = "0.3" } +http = { version= "1" } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "test-util"] } +http-body-util = "0.1" +bytes = "1" diff --git a/bindings/rust/s2n-tls-hyper/README.md b/bindings/rust/s2n-tls-hyper/README.md new file mode 100644 index 00000000000..0f2e2d51c89 --- /dev/null +++ b/bindings/rust/s2n-tls-hyper/README.md @@ -0,0 +1,3 @@ +`s2n-tls-hyper` provides compatibility structs for [hyper](https://hyper.rs/), allowing s2n-tls to be used as the underlying TLS implementation with hyper clients. + +This crate is currently being developed and is unstable. diff --git a/bindings/rust/s2n-tls-hyper/src/connector.rs b/bindings/rust/s2n-tls-hyper/src/connector.rs new file mode 100644 index 00000000000..a160e605471 --- /dev/null +++ b/bindings/rust/s2n-tls-hyper/src/connector.rs @@ -0,0 +1,168 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +use crate::{error::Error, stream::MaybeHttpsStream}; +use http::uri::Uri; +use hyper::rt::{Read, Write}; +use hyper_util::{ + client::legacy::connect::{Connection, HttpConnector}, + rt::TokioIo, +}; +use s2n_tls::{config::Config, connection}; +use s2n_tls_tokio::TlsConnector; +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll}, +}; +use tower_service::Service; + +/// hyper-compatible connector used to negotiate HTTPS. +/// +/// hyper clients use a connector to send and receive HTTP requests over an underlying IO stream. By +/// default, hyper provides `hyper_util::client::legacy::connect::HttpConnector` for this purpose, +/// which sends and receives requests over TCP. The `HttpsConnector` struct wraps an HTTP connector, +/// and uses it to negotiate TLS when the HTTPS scheme is in use. +#[derive(Clone)] +pub struct HttpsConnector { + http: Http, + conn_builder: Builder, +} + +impl HttpsConnector +where + Builder: connection::Builder, + ::Output: Unpin, +{ + /// Creates a new `HttpsConnector`. + /// + /// `conn_builder` will be used to produce the s2n-tls Connections used for negotiating HTTPS, + /// which can be an `s2n_tls::config::Config` or other `s2n_tls::connection::Builder`. + /// + /// This API creates an `HttpsConnector` using the default hyper `HttpConnector`. To use an + /// existing HTTP connector, use `HttpsConnector::new_with_http()`. + pub fn new(conn_builder: Builder) -> HttpsConnector { + let mut http = HttpConnector::new(); + + // By default, the `HttpConnector` only allows the HTTP URI scheme to be used. To negotiate + // HTTP over TLS via the HTTPS scheme, `enforce_http` must be disabled. + http.enforce_http(false); + + Self { http, conn_builder } + } +} + +impl HttpsConnector +where + Builder: connection::Builder, + ::Output: Unpin, +{ + /// Creates a new `HttpsConnector`. + /// + /// `conn_builder` will be used to produce the s2n-tls Connections used for negotiating HTTPS, + /// which can be an `s2n_tls::config::Config` or other `s2n_tls::connection::Builder`. + /// + /// This API allows an `HttpsConnector` to be constructed with an existing HTTP connector, as follows: + /// ``` + /// use s2n_tls_hyper::connector::HttpsConnector; + /// use s2n_tls::config::Config; + /// use hyper_util::client::legacy::connect::HttpConnector; + /// + /// let mut http = HttpConnector::new(); + /// + /// // Ensure that the HTTP connector permits the HTTPS scheme. + /// http.enforce_http(false); + /// + /// let connector = HttpsConnector::new_with_http(http, Config::default()); + /// ``` + /// + /// `HttpsConnector::new()` can be used to create the HTTP connector automatically. + pub fn new_with_http(http: Http, conn_builder: Builder) -> HttpsConnector { + Self { http, conn_builder } + } +} + +// hyper connectors MUST implement `hyper_util::client::legacy::connect::Connect`, which is an alias +// for the `tower_service::Service` trait where `Service` is implemented for `http::uri::Uri`, and +// `Service::Response` implements traits for compatibility with hyper: +// https://docs.rs/hyper-util/latest/hyper_util/client/legacy/connect/trait.Connect.html +// +// The hyper compatibility traits for `Service::Response` are implemented in `MaybeHttpsStream`. +impl Service for HttpsConnector +where + Http: Service, + Http::Response: Read + Write + Connection + Unpin + Send + 'static, + Http::Future: Send + 'static, + Http::Error: Into>, + Builder: connection::Builder + Send + Sync + 'static, + ::Output: Unpin + Send, +{ + type Response = MaybeHttpsStream; + type Error = Error; + type Future = Pin< + Box, Error>> + Send>, + >; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + match self.http.poll_ready(cx) { + Poll::Ready(Ok(())) => Poll::Ready(Ok(())), + Poll::Ready(Err(e)) => Poll::Ready(Err(Error::HttpError(e.into()))), + Poll::Pending => Poll::Pending, + } + } + + fn call(&mut self, req: Uri) -> Self::Future { + // Currently, the only supported stream type is TLS. If the application attempts to + // negotiate HTTP over plain TCP, return an error. + if req.scheme() == Some(&http::uri::Scheme::HTTP) { + return Box::pin(async move { Err(Error::InvalidScheme) }); + } + + let builder = self.conn_builder.clone(); + let host = req.host().unwrap_or("").to_owned(); + let call = self.http.call(req); + Box::pin(async move { + // `HttpsConnector` wraps an HTTP connector that also implements `Service`. + // `call()` is invoked on the wrapped connector to get the underlying hyper TCP stream, + // which is converted into a tokio-compatible stream with `hyper_util::rt::TokioIo`. + let tcp = call.await.map_err(|e| Error::HttpError(e.into()))?; + let tcp = TokioIo::new(tcp); + + let connector = TlsConnector::new(builder); + let tls = connector + .connect(&host, tcp) + .await + .map_err(Error::TlsError)?; + + Ok(MaybeHttpsStream::Https(TokioIo::new(tls))) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use http_body_util::Empty; + use hyper_util::{client::legacy::Client, rt::TokioExecutor}; + use std::{error::Error as StdError, str::FromStr}; + + #[tokio::test] + async fn test_unsecure_http() -> Result<(), Box> { + let connector = HttpsConnector::new(Config::default()); + let client: Client<_, Empty> = + Client::builder(TokioExecutor::new()).build(connector); + + let uri = Uri::from_str("http://www.amazon.com")?; + let error = client.get(uri).await.unwrap_err(); + + // Ensure that an InvalidScheme error is returned when HTTP over TCP is attempted. + let error = error.source().unwrap().downcast_ref::().unwrap(); + assert!(matches!(error, Error::InvalidScheme)); + + // Ensure that the error can produce a valid message + assert!(!error.to_string().is_empty()); + + Ok(()) + } +} diff --git a/bindings/rust/s2n-tls-hyper/src/error.rs b/bindings/rust/s2n-tls-hyper/src/error.rs new file mode 100644 index 00000000000..c6925a020dc --- /dev/null +++ b/bindings/rust/s2n-tls-hyper/src/error.rs @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::{Display, Formatter}; + +/// Indicates which error occurred. +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + /// Indicates that the scheme in the URI provided to the `HttpsConnector` is invalid. + InvalidScheme, + /// Indicates that an error occurred in the underlying `HttpConnector`. + HttpError(Box), + /// Indicates that an error occurred in s2n-tls. + TlsError(s2n_tls::error::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Error::InvalidScheme => write!(f, "The provided URI contains an invalid scheme."), + Error::HttpError(err) => write!(f, "{}", err), + Error::TlsError(err) => write!(f, "{}", err), + } + } +} + +impl std::error::Error for Error {} diff --git a/bindings/rust/s2n-tls-hyper/src/lib.rs b/bindings/rust/s2n-tls-hyper/src/lib.rs new file mode 100644 index 00000000000..54fb2c016e4 --- /dev/null +++ b/bindings/rust/s2n-tls-hyper/src/lib.rs @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![warn(missing_docs)] + +//! This crate provides compatibility structs for the [hyper](https://hyper.rs/) HTTP library, +//! allowing s2n-tls to be used as the underlying TLS implementation to negotiate HTTPS with hyper +//! clients. +//! +//! `s2n-tls-hyper` provides an `HttpsConnector` struct which is compatible with the +//! `hyper_util::client::legacy::Client` builder, allowing hyper clients to be constructed with +//! configurable s2n-tls connections. The following example demonstrates how to construct a hyper +//! client with s2n-tls: +//! +//! ``` +//! use std::str::FromStr; +//! use hyper_util::{ +//! client::legacy::Client, +//! rt::TokioExecutor, +//! }; +//! use s2n_tls_hyper::connector::HttpsConnector; +//! use s2n_tls::config::Config; +//! use bytes::Bytes; +//! use http_body_util::Empty; +//! use http::uri::Uri; +//! +//! // An `HttpsConnector` is built with an `s2n_tls::connection::Builder`, such as an +//! // `s2n_tls::config::Config`, which allows for the underlying TLS connection to be configured. +//! let config = Config::default(); +//! +//! // The `HttpsConnector` wraps hyper's `HttpConnector`. `HttpsConnector::new()` will create +//! // a new `HttpConnector` to wrap. +//! let connector = HttpsConnector::new(Config::default()); +//! +//! // The `HttpsConnector` can then be provided to the hyper Client builder, which can be used to +//! // send HTTP requests over HTTPS by specifying the HTTPS scheme in the URL. +//! let client: Client<_, Empty> = +//! Client::builder(TokioExecutor::new()).build(connector); +//! ``` + +/// Provides the `HttpsConnector` struct. +pub mod connector; + +/// Provides errors returned by s2n-tls-hyper. +pub mod error; + +mod stream; diff --git a/bindings/rust/s2n-tls-hyper/src/stream.rs b/bindings/rust/s2n-tls-hyper/src/stream.rs new file mode 100644 index 00000000000..61bc59fe682 --- /dev/null +++ b/bindings/rust/s2n-tls-hyper/src/stream.rs @@ -0,0 +1,100 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +use hyper::rt::{Read, ReadBufCursor, Write}; +use hyper_util::{ + client::legacy::connect::{Connected, Connection as HyperConnection}, + rt::TokioIo, +}; +use s2n_tls::connection; +use s2n_tls_tokio::TlsStream; +use std::{ + io::Error, + pin::Pin, + task::{Context, Poll}, +}; + +/// `MaybeHttpsStream` is a wrapper over a hyper TCP stream, `Transport`, allowing for TLS to be +/// negotiated over the TCP stream. +/// +/// While not currently implemented, the `MaybeHttpsStream` enum will provide an `Http` type +/// corresponding to the plain TCP stream, allowing for HTTP to be negotiated in addition to HTTPS +/// when the HTTP scheme is used. +/// +/// This struct is used to implement `tower_service::Service` for `HttpsConnector`, and shouldn't +/// need to be used directly. +pub enum MaybeHttpsStream +where + Transport: Read + Write + Unpin, + Builder: connection::Builder, + ::Output: Unpin, +{ + // `Transport` is the underlying hyper TCP stream, which is wrapped in a `TokioIo` type in order + // to make it compatible with tokio (implementing AsyncRead and AsyncWrite). This allows the TCP + // stream to be provided to the `s2n_tls_tokio::TlsStream`. + // + // `MaybeHttpsStream` MUST implement hyper's `Read` and `Write` traits. So, the `TlsStream` is + // wrapped in an additional `TokioIo` type, which already implements the conversion from hyper's + // traits to tokio's. This allows the `Read` and `Write` implementations for `MaybeHttpsStream` + // to simply call the `TokioIo` `poll` functions. + Https(TokioIo, Builder::Output>>), +} + +impl HyperConnection for MaybeHttpsStream +where + Transport: Read + Write + HyperConnection + Unpin, + Builder: connection::Builder, + ::Output: Unpin, +{ + fn connected(&self) -> Connected { + match self { + MaybeHttpsStream::Https(stream) => stream.inner().get_ref().connected(), + } + } +} + +impl Read for MaybeHttpsStream +where + Transport: Read + Write + Unpin, + Builder: connection::Builder, + ::Output: Unpin, +{ + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: ReadBufCursor<'_>, + ) -> Poll> { + match Pin::get_mut(self) { + Self::Https(stream) => Pin::new(stream).poll_read(cx, buf), + } + } +} + +impl Write for MaybeHttpsStream +where + Transport: Read + Write + Unpin, + Builder: connection::Builder, + ::Output: Unpin, +{ + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match Pin::get_mut(self) { + Self::Https(stream) => Pin::new(stream).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match Pin::get_mut(self) { + MaybeHttpsStream::Https(stream) => Pin::new(stream).poll_flush(cx), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match Pin::get_mut(self) { + MaybeHttpsStream::Https(stream) => Pin::new(stream).poll_shutdown(cx), + } + } +} diff --git a/bindings/rust/s2n-tls-hyper/tests/web_client.rs b/bindings/rust/s2n-tls-hyper/tests/web_client.rs new file mode 100644 index 00000000000..41a72970ed3 --- /dev/null +++ b/bindings/rust/s2n-tls-hyper/tests/web_client.rs @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +use bytes::Bytes; +use http::{status, Uri}; +use http_body_util::{BodyExt, Empty}; +use hyper_util::{client::legacy::Client, rt::TokioExecutor}; +use s2n_tls::config::Config; +use s2n_tls_hyper::connector::HttpsConnector; +use std::{error::Error, str::FromStr}; + +#[tokio::test] +async fn test_get_request() -> Result<(), Box> { + let connector = HttpsConnector::new(Config::default()); + let client: Client<_, Empty> = Client::builder(TokioExecutor::new()).build(connector); + + let uri = Uri::from_str("https://www.amazon.com")?; + let response = client.get(uri).await?; + assert_eq!(response.status(), status::StatusCode::OK); + + let body = response.into_body().collect().await?.to_bytes(); + assert!(!body.is_empty()); + + Ok(()) +}