diff --git a/object_store/CONTRIBUTING.md b/object_store/CONTRIBUTING.md index 4b0ef1f0e696..5444ec74344f 100644 --- a/object_store/CONTRIBUTING.md +++ b/object_store/CONTRIBUTING.md @@ -101,6 +101,54 @@ export AWS_SERVER_SIDE_ENCRYPTION=aws:kms:dsse cargo test --features aws ``` +#### SSE-C Encryption tests + +Unfortunately, localstack does not support SSE-C encryption (https://github.com/localstack/localstack/issues/11356). + +We will use [MinIO](https://min.io/docs/minio/container/operations/server-side-encryption.html) to test SSE-C encryption. + +First, create a self-signed certificate to enable HTTPS for MinIO, as SSE-C requires HTTPS. + +```shell +mkdir ~/certs +cd ~/certs +openssl genpkey -algorithm RSA -out private.key +openssl req -new -key private.key -out request.csr -subj "/C=US/ST=State/L=City/O=Organization/OU=Unit/CN=example.com/emailAddress=email@example.com" +openssl x509 -req -days 365 -in request.csr -signkey private.key -out public.crt +rm request.csr +``` + +Second, start MinIO with the self-signed certificate. + +```shell +docker run -d \ + -p 9000:9000 \ + --name minio \ + -v ${HOME}/certs:/root/.minio/certs \ + -e "MINIO_ROOT_USER=minio" \ + -e "MINIO_ROOT_PASSWORD=minio123" \ + minio/minio server /data +``` + +Create a test bucket. + +```shell +export AWS_BUCKET_NAME=test-bucket +export AWS_ACCESS_KEY_ID=minio +export AWS_SECRET_ACCESS_KEY=minio123 +export AWS_ENDPOINT=https://localhost:9000 +aws s3 mb s3://test-bucket --endpoint-url=https://localhost:9000 --no-verify-ssl +``` + +Run the tests. The real test is `test_s3_ssec_encryption_with_minio()` + +```shell +export TEST_S3_SSEC_ENCRYPTION=1 +cargo test --features aws --package object_store --lib aws::tests::test_s3_ssec_encryption_with_minio -- --exact --nocapture +``` + + + ### Azure To test the Azure integration diff --git a/object_store/src/aws/builder.rs b/object_store/src/aws/builder.rs index ffef3fb33d49..574345c389a2 100644 --- a/object_store/src/aws/builder.rs +++ b/object_store/src/aws/builder.rs @@ -26,7 +26,10 @@ use crate::aws::{ use crate::client::TokenCredentialProvider; use crate::config::ConfigValue; use crate::{ClientConfigKey, ClientOptions, Result, RetryConfig, StaticCredentialProvider}; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; use itertools::Itertools; +use md5::{Digest, Md5}; use reqwest::header::{HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; use snafu::{OptionExt, ResultExt, Snafu}; @@ -73,7 +76,7 @@ enum Error { #[snafu(display("Invalid Zone suffix for bucket '{bucket}'"))] ZoneSuffix { bucket: String }, - #[snafu(display("Invalid encryption type: {}. Valid values are \"AES256\", \"sse:kms\", and \"sse:kms:dsse\".", passed))] + #[snafu(display("Invalid encryption type: {}. Valid values are \"AES256\", \"sse:kms\", \"sse:kms:dsse\" and \"sse-c\".", passed))] InvalidEncryptionType { passed: String }, #[snafu(display( @@ -166,6 +169,8 @@ pub struct AmazonS3Builder { encryption_type: Option>, encryption_kms_key_id: Option, encryption_bucket_key_enabled: Option>, + /// base64-encoded 256-bit customer encryption key for SSE-C. + encryption_customer_key_base64: Option, } /// Configuration keys for [`AmazonS3Builder`] @@ -394,6 +399,9 @@ impl FromStr for AmazonS3ConfigKey { "aws_sse_bucket_key_enabled" => { Ok(Self::Encryption(S3EncryptionConfigKey::BucketKeyEnabled)) } + "aws_sse_customer_key_base64" => Ok(Self::Encryption( + S3EncryptionConfigKey::CustomerEncryptionKey, + )), _ => match s.parse() { Ok(key) => Ok(Self::Client(key)), Err(_) => Err(Error::UnknownConfigurationKey { key: s.into() }.into()), @@ -511,6 +519,9 @@ impl AmazonS3Builder { S3EncryptionConfigKey::BucketKeyEnabled => { self.encryption_bucket_key_enabled = Some(ConfigValue::Deferred(value.into())) } + S3EncryptionConfigKey::CustomerEncryptionKey => { + self.encryption_customer_key_base64 = Some(value.into()) + } }, }; self @@ -566,6 +577,9 @@ impl AmazonS3Builder { .encryption_bucket_key_enabled .as_ref() .map(ToString::to_string), + S3EncryptionConfigKey::CustomerEncryptionKey => { + self.encryption_customer_key_base64.clone() + } }, } } @@ -813,6 +827,14 @@ impl AmazonS3Builder { self } + /// Use SSE-C for server side encryption. + /// Must pass the *base64-encoded* 256-bit customer encryption key. + pub fn with_ssec_encryption(mut self, customer_key_base64: impl Into) -> Self { + self.encryption_type = Some(ConfigValue::Parsed(S3EncryptionType::SseC)); + self.encryption_customer_key_base64 = customer_key_base64.into().into(); + self + } + /// Set whether to enable bucket key for server side encryption. This overrides /// the bucket default setting for bucket keys. /// @@ -953,6 +975,7 @@ impl AmazonS3Builder { self.encryption_bucket_key_enabled .map(|val| val.get()) .transpose()?, + self.encryption_customer_key_base64, )? } else { S3EncryptionHeaders::default() @@ -994,15 +1017,14 @@ fn parse_bucket_az(bucket: &str) -> Option<&str> { /// These options are used to configure server-side encryption for S3 objects. /// To configure them, pass them to [`AmazonS3Builder::with_config`]. /// -/// Both [SSE-KMS] and [DSSE-KMS] are supported. [SSE-C] is not yet supported. -/// +/// [SSE-S3]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingServerSideEncryption.html /// [SSE-KMS]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingKMSEncryption.html /// [DSSE-KMS]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingDSSEncryption.html /// [SSE-C]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html #[derive(PartialEq, Eq, Hash, Clone, Debug, Copy, Serialize, Deserialize)] #[non_exhaustive] pub enum S3EncryptionConfigKey { - /// Type of encryption to use. If set, must be one of "AES256", "aws:kms", or "aws:kms:dsse". + /// Type of encryption to use. If set, must be one of "AES256" (SSE-S3), "aws:kms" (SSE-KMS), "aws:kms:dsse" (DSSE-KMS) or "sse-c". ServerSideEncryption, /// The KMS key ID to use for server-side encryption. If set, ServerSideEncryption /// must be "aws:kms" or "aws:kms:dsse". @@ -1010,6 +1032,10 @@ pub enum S3EncryptionConfigKey { /// If set to true, will use the bucket's default KMS key for server-side encryption. /// If set to false, will disable the use of the bucket's default KMS key for server-side encryption. BucketKeyEnabled, + + /// The base64 encoded, 256-bit customer encryption key to use for server-side encryption. + /// If set, ServerSideEncryption must be "sse-c". + CustomerEncryptionKey, } impl AsRef for S3EncryptionConfigKey { @@ -1018,6 +1044,7 @@ impl AsRef for S3EncryptionConfigKey { Self::ServerSideEncryption => "aws_server_side_encryption", Self::KmsKeyId => "aws_sse_kms_key_id", Self::BucketKeyEnabled => "aws_sse_bucket_key_enabled", + Self::CustomerEncryptionKey => "aws_sse_customer_key_base64", } } } @@ -1027,6 +1054,7 @@ enum S3EncryptionType { S3, SseKms, DsseKms, + SseC, } impl crate::config::Parse for S3EncryptionType { @@ -1035,6 +1063,7 @@ impl crate::config::Parse for S3EncryptionType { "AES256" => Ok(Self::S3), "aws:kms" => Ok(Self::SseKms), "aws:kms:dsse" => Ok(Self::DsseKms), + "sse-c" => Ok(Self::SseC), _ => Err(Error::InvalidEncryptionType { passed: s.into() }.into()), } } @@ -1046,6 +1075,7 @@ impl From<&S3EncryptionType> for &'static str { S3EncryptionType::S3 => "AES256", S3EncryptionType::SseKms => "aws:kms", S3EncryptionType::DsseKms => "aws:kms:dsse", + S3EncryptionType::SseC => "sse-c", } } } @@ -1062,37 +1092,87 @@ impl std::fmt::Display for S3EncryptionType { /// Whether these headers are sent depends on both the kind of encryption set /// and the kind of request being made. #[derive(Default, Clone, Debug)] -pub struct S3EncryptionHeaders(HeaderMap); +pub(super) struct S3EncryptionHeaders(pub HeaderMap); impl S3EncryptionHeaders { fn try_new( encryption_type: &S3EncryptionType, - key_id: Option, + encryption_kms_key_id: Option, bucket_key_enabled: Option, + encryption_customer_key_base64: Option, ) -> Result { let mut headers = HeaderMap::new(); - // Note: if we later add support for SSE-C, we should be sure to use - // HeaderValue::set_sensitive to prevent the key from being logged. - headers.insert( - "x-amz-server-side-encryption", - HeaderValue::from_static(encryption_type.into()), - ); - if let Some(key_id) = key_id { - headers.insert( - "x-amz-server-side-encryption-aws-kms-key-id", - key_id - .try_into() - .map_err(|err| Error::InvalidEncryptionHeader { - header: "kms-key-id", - source: Box::new(err), - })?, - ); - } - if let Some(bucket_key_enabled) = bucket_key_enabled { - headers.insert( - "x-amz-server-side-encryption-bucket-key-enabled", - HeaderValue::from_static(if bucket_key_enabled { "true" } else { "false" }), - ); + match encryption_type { + S3EncryptionType::S3 | S3EncryptionType::SseKms | S3EncryptionType::DsseKms => { + headers.insert( + "x-amz-server-side-encryption", + HeaderValue::from_static(encryption_type.into()), + ); + if let Some(key_id) = encryption_kms_key_id { + headers.insert( + "x-amz-server-side-encryption-aws-kms-key-id", + key_id + .try_into() + .map_err(|err| Error::InvalidEncryptionHeader { + header: "kms-key-id", + source: Box::new(err), + })?, + ); + } + if let Some(bucket_key_enabled) = bucket_key_enabled { + headers.insert( + "x-amz-server-side-encryption-bucket-key-enabled", + HeaderValue::from_static(if bucket_key_enabled { "true" } else { "false" }), + ); + } + } + S3EncryptionType::SseC => { + headers.insert( + "x-amz-server-side-encryption-customer-algorithm", + HeaderValue::from_static("AES256"), + ); + if let Some(key) = encryption_customer_key_base64 { + let mut header_value: HeaderValue = + key.clone() + .try_into() + .map_err(|err| Error::InvalidEncryptionHeader { + header: "x-amz-server-side-encryption-customer-key", + source: Box::new(err), + })?; + header_value.set_sensitive(true); + headers.insert("x-amz-server-side-encryption-customer-key", header_value); + + let decoded_key = BASE64_STANDARD.decode(key.as_bytes()).map_err(|err| { + Error::InvalidEncryptionHeader { + header: "x-amz-server-side-encryption-customer-key", + source: Box::new(err), + } + })?; + let mut hasher = Md5::new(); + hasher.update(decoded_key); + let md5 = BASE64_STANDARD.encode(hasher.finalize()); + let mut md5_header_value: HeaderValue = + md5.try_into() + .map_err(|err| Error::InvalidEncryptionHeader { + header: "x-amz-server-side-encryption-customer-key-MD5", + source: Box::new(err), + })?; + md5_header_value.set_sensitive(true); + headers.insert( + "x-amz-server-side-encryption-customer-key-MD5", + md5_header_value, + ); + } else { + return Err(Error::InvalidEncryptionHeader { + header: "x-amz-server-side-encryption-customer-key", + source: Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Missing customer key", + )), + } + .into()); + } + } } Ok(Self(headers)) } @@ -1162,7 +1242,11 @@ mod tests { .with_config(AmazonS3ConfigKey::UnsignedPayload, "true") .with_config("aws_server_side_encryption".parse().unwrap(), "AES256") .with_config("aws_sse_kms_key_id".parse().unwrap(), "some_key_id") - .with_config("aws_sse_bucket_key_enabled".parse().unwrap(), "true"); + .with_config("aws_sse_bucket_key_enabled".parse().unwrap(), "true") + .with_config( + "aws_sse_customer_key_base64".parse().unwrap(), + "some_customer_key", + ); assert_eq!( builder @@ -1216,6 +1300,12 @@ mod tests { .unwrap(), "true" ); + assert_eq!( + builder + .get_config_value(&"aws_sse_customer_key_base64".parse().unwrap()) + .unwrap(), + "some_customer_key" + ); } #[test] diff --git a/object_store/src/aws/client.rs b/object_store/src/aws/client.rs index ab4da86f504b..007e271086d5 100644 --- a/object_store/src/aws/client.rs +++ b/object_store/src/aws/client.rs @@ -181,7 +181,7 @@ pub struct S3Config { pub checksum: Option, pub copy_if_not_exists: Option, pub conditional_put: Option, - pub encryption_headers: S3EncryptionHeaders, + pub(super) encryption_headers: S3EncryptionHeaders, } impl S3Config { @@ -522,10 +522,47 @@ impl S3Client { /// Make an S3 Copy request pub fn copy_request<'a>(&'a self, from: &Path, to: &'a Path) -> Request<'a> { let source = format!("{}/{}", self.config.bucket, encode_path(from)); + + let mut copy_source_encryption_headers = HeaderMap::new(); + if let Some(customer_algorithm) = self + .config + .encryption_headers + .0 + .get("x-amz-server-side-encryption-customer-algorithm") + { + copy_source_encryption_headers.insert( + "x-amz-copy-source-server-side-encryption-customer-algorithm", + customer_algorithm.clone(), + ); + } + if let Some(customer_key) = self + .config + .encryption_headers + .0 + .get("x-amz-server-side-encryption-customer-key") + { + copy_source_encryption_headers.insert( + "x-amz-copy-source-server-side-encryption-customer-key", + customer_key.clone(), + ); + } + if let Some(customer_key_md5) = self + .config + .encryption_headers + .0 + .get("x-amz-server-side-encryption-customer-key-MD5") + { + copy_source_encryption_headers.insert( + "x-amz-copy-source-server-side-encryption-customer-key-MD5", + customer_key_md5.clone(), + ); + } + self.request(Method::PUT, to) .idempotent(true) .header(©_SOURCE_HEADER, &source) .headers(self.config.encryption_headers.clone().into()) + .headers(copy_source_encryption_headers) .with_session_creds(false) } @@ -562,13 +599,21 @@ impl S3Client { ) -> Result { let part = (part_idx + 1).to_string(); - let response = self + let mut request = self .request(Method::PUT, path) .with_payload(data) .query(&[("partNumber", &part), ("uploadId", upload_id)]) - .idempotent(true) - .send() - .await?; + .idempotent(true); + if self + .config + .encryption_headers + .0 + .contains_key("x-amz-server-side-encryption-customer-algorithm") + { + // If SSE-C is used, we must include the encryption headers in every upload request. + request = request.with_encryption_headers(); + } + let response = request.send().await?; let content_id = get_etag(response.headers()).context(MetadataSnafu)?; Ok(PartId { content_id }) @@ -660,6 +705,7 @@ impl GetClient for S3Client { }; let mut builder = self.client.request(method, url); + builder = builder.headers(self.config.encryption_headers.clone().into()); if let Some(v) = &options.version { builder = builder.query(&[("versionId", v)]) diff --git a/object_store/src/aws/mod.rs b/object_store/src/aws/mod.rs index f5204a5365ed..4a773e7a1879 100644 --- a/object_store/src/aws/mod.rs +++ b/object_store/src/aws/mod.rs @@ -60,7 +60,7 @@ mod dynamo; mod precondition; mod resolve; -pub use builder::{AmazonS3Builder, AmazonS3ConfigKey, S3EncryptionHeaders}; +pub use builder::{AmazonS3Builder, AmazonS3ConfigKey}; pub use checksum::Checksum; pub use dynamo::DynamoCommit; pub use precondition::{S3ConditionalPut, S3CopyIfNotExists}; @@ -412,6 +412,9 @@ mod tests { use crate::client::get::GetClient; use crate::integration::*; use crate::tests::*; + use crate::ClientOptions; + use base64::prelude::BASE64_STANDARD; + use base64::Engine; use hyper::HeaderMap; const NON_EXISTENT_NAME: &str = "nonexistentname"; @@ -605,4 +608,67 @@ mod tests { store.delete(location).await.unwrap(); } } + + /// See CONTRIBUTING.md for the MinIO setup for this test. + #[tokio::test] + async fn test_s3_ssec_encryption_with_minio() { + if std::env::var("TEST_S3_SSEC_ENCRYPTION").is_err() { + eprintln!("Skipping S3 SSE-C encryption test"); + return; + } + eprintln!("Running S3 SSE-C encryption test"); + + let customer_key = "1234567890abcdef1234567890abcdef"; + let expected_md5 = "JMwgiexXqwuPqIPjYFmIZQ=="; + + let store = AmazonS3Builder::from_env() + .with_ssec_encryption(BASE64_STANDARD.encode(customer_key)) + .with_client_options(ClientOptions::default().with_allow_invalid_certificates(true)) + .build() + .unwrap(); + + let data = PutPayload::from(vec![3u8; 1024]); + + let locations = [ + Path::from("test-encryption-1"), + Path::from("test-encryption-2"), + Path::from("test-encryption-3"), + ]; + + // Test put with sse-c. + store.put(&locations[0], data.clone()).await.unwrap(); + + // Test copy with sse-c. + store.copy(&locations[0], &locations[1]).await.unwrap(); + + // Test multipart upload with sse-c. + let mut upload = store.put_multipart(&locations[2]).await.unwrap(); + upload.put_part(data.clone()).await.unwrap(); + upload.complete().await.unwrap(); + + // Test get with sse-c. + for location in &locations { + let res = store + .client + .get_request(location, GetOptions::default()) + .await + .unwrap(); + let headers = res.headers(); + assert_eq!( + headers + .get("x-amz-server-side-encryption-customer-algorithm") + .expect("object is not encrypted with SSE-C"), + "AES256" + ); + + assert_eq!( + headers + .get("x-amz-server-side-encryption-customer-key-MD5") + .expect("object is not encrypted with SSE-C"), + expected_md5 + ); + + store.delete(location).await.unwrap(); + } + } }