Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(object_store): add support for server-side encryption with customer-provided keys (SSE-C) #6230

Merged
merged 8 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 125 additions & 28 deletions object_store/src/aws/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ 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};
use std::error;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
Expand Down Expand Up @@ -73,7 +77,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(
Expand Down Expand Up @@ -166,6 +170,8 @@ pub struct AmazonS3Builder {
encryption_type: Option<ConfigValue<S3EncryptionType>>,
encryption_kms_key_id: Option<String>,
encryption_bucket_key_enabled: Option<ConfigValue<bool>>,
/// base64-encoded 256-bit customer encryption key for SSE-C.
encryption_customer_key_base64: Option<String>,
}

/// Configuration keys for [`AmazonS3Builder`]
Expand Down Expand Up @@ -394,6 +400,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()),
Expand Down Expand Up @@ -511,6 +520,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
Expand Down Expand Up @@ -566,6 +578,9 @@ impl AmazonS3Builder {
.encryption_bucket_key_enabled
.as_ref()
.map(ToString::to_string),
S3EncryptionConfigKey::CustomerEncryptionKey => {
self.encryption_customer_key_base64.clone()
}
},
}
}
Expand Down Expand Up @@ -813,6 +828,16 @@ impl AmazonS3Builder {
self
}

/// Use SSE-C for server side encryption.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably say whether it should already be base64 encoded.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment

pub fn with_ssec_encryption(mut self, customer_key_base64: impl Into<String>) -> Self {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused, should this parameter already be base64 encoded? I ask because internally it looks like you are encoding it?

Suggested change
pub fn with_ssec_encryption(mut self, customer_key_base64: impl Into<String>) -> Self {
pub fn with_ssec_encryption(mut self, customer_key: impl Into<String>) -> Self {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch.

My intention is to make this API takes an already base64 encoded key.

The reason is because I checked the AWS SDK examples in
https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html, that's what they do for the .NET API.

image

So now I removed the base64 encoding inside this method, and changed the test to encode first.

self.encryption_type = Some(ConfigValue::Parsed(S3EncryptionType::SseC));
if let Some(customer_key_64) = customer_key_base64.into().into() {
self.encryption_customer_key_base64 =
Some(BASE64_STANDARD.encode(customer_key_64.clone()));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this conditional? Seems like you could just do this:

Suggested change
if let Some(customer_key_64) = customer_key_base64.into().into() {
self.encryption_customer_key_base64 =
Some(BASE64_STANDARD.encode(customer_key_64.clone()));
}
self.encryption_customer_key_base64 =
Some(BASE64_STANDARD.encode(customer_key.into()));

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the if mainly following other examples in this file like with_dsse_kms_encryption.

I've now removed the if. Still not sure why the other places need the if though.

self
}

/// Set whether to enable bucket key for server side encryption. This overrides
/// the bucket default setting for bucket keys.
///
Expand Down Expand Up @@ -953,6 +978,7 @@ impl AmazonS3Builder {
self.encryption_bucket_key_enabled
.map(|val| val.get())
.transpose()?,
self.encryption_customer_key_base64,
)?
} else {
S3EncryptionHeaders::default()
Expand Down Expand Up @@ -994,22 +1020,25 @@ 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".
KmsKeyId,
/// 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<str> for S3EncryptionConfigKey {
Expand All @@ -1018,6 +1047,7 @@ impl AsRef<str> 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",
}
}
}
Expand All @@ -1027,6 +1057,7 @@ enum S3EncryptionType {
S3,
SseKms,
DsseKms,
SseC,
}

impl crate::config::Parse for S3EncryptionType {
Expand All @@ -1035,6 +1066,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()),
}
}
Expand All @@ -1046,6 +1078,7 @@ impl From<&S3EncryptionType> for &'static str {
S3EncryptionType::S3 => "AES256",
S3EncryptionType::SseKms => "aws:kms",
S3EncryptionType::DsseKms => "aws:kms:dsse",
S3EncryptionType::SseC => "sse-c",
}
}
}
Expand All @@ -1067,35 +1100,89 @@ pub struct S3EncryptionHeaders(HeaderMap);
impl S3EncryptionHeaders {
fn try_new(
encryption_type: &S3EncryptionType,
key_id: Option<String>,
encryption_kms_key_id: Option<String>,
bucket_key_enabled: Option<bool>,
encryption_customer_key_base64: Option<String>,
) -> Result<Self> {
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))
}

pub fn header_map(&self) -> &HeaderMap {
jiachengdb marked this conversation as resolved.
Show resolved Hide resolved
&self.0
}
}

impl From<S3EncryptionHeaders> for HeaderMap {
Expand Down Expand Up @@ -1162,7 +1249,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
Expand Down Expand Up @@ -1216,6 +1307,12 @@ mod tests {
.unwrap(),
"true"
);
assert_eq!(
builder
.get_config_value(&"aws_sse_customer_key_base64".parse().unwrap())
.unwrap(),
"some_customer_key"
);
}

#[test]
Expand Down
54 changes: 50 additions & 4 deletions object_store/src/aws/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,10 +522,47 @@ impl S3Client {
/// Make an S3 Copy request <https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html>
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
.header_map()
.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
.header_map()
.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
.header_map()
.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(&COPY_SOURCE_HEADER, &source)
.headers(self.config.encryption_headers.clone().into())
.headers(copy_source_encryption_headers)
.with_session_creds(false)
}

Expand Down Expand Up @@ -562,13 +599,21 @@ impl S3Client {
) -> Result<PartId> {
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
.header_map()
.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 })
Expand Down Expand Up @@ -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)])
Expand Down
Loading