mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-12 23:52:45 +00:00
fix(sse): guard copy-source SSE-C keys over TLS and reject bucket-keyed KMS context (#8296)
* fix(sse): count copy-source SSE-C headers in the TLS transport guard
The SSE-C transport guard only looked at the object's own customer-key
headers. A CopyObject or UploadPartCopy that read an SSE-C source into a
non-SSE-C destination carried the source key only in the
x-amz-copy-source-server-side-encryption-customer-* headers, so it was
accepted on a plaintext transport with RUSTFS_SSE_C_REQUIRE_TLS=true and
was missing from rustfs_ssec_plaintext_requests_total.
Treat the copy-source triple as SSE-C headers too.
* fix(sse): reject a KMS context key that replaces the location entry
Managed SSE wraps each data key under an encryption context that carries
{bucket: bucket/key}, added with or_insert, while only the client context
is persisted and the entry is rebuilt on read. A client context entry
keyed by the bucket name therefore replaced the location entry on write
and on every read, so the data key was no longer tied to the object's
location.
Reject such a context with 400 InvalidArgument at the single managed-SSE
write entry, before the KMS is called. The read side is unchanged, so
objects stored with such an entry stay readable, and other keys,
including other bucket names, are still accepted.
---------
Co-authored-by: Hauser <housemecn@gmail.com>
This commit is contained in:
@@ -40,8 +40,9 @@ use http_body_util::{BodyExt, Full};
|
||||
use metrics::counter;
|
||||
use rustfs_trusted_proxies::ClientInfo;
|
||||
use rustfs_utils::http::headers::{
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_COPY_CUSTOMER_ALGORITHM, AMZ_SERVER_SIDE_ENCRYPTION_COPY_CUSTOMER_KEY,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_COPY_CUSTOMER_KEY_MD5, AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY, AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5,
|
||||
};
|
||||
use std::sync::Once;
|
||||
use std::task::{Context, Poll};
|
||||
@@ -62,15 +63,26 @@ pub(crate) const METRIC_SSEC_PLAINTEXT_REQUESTS_TOTAL: &str = "rustfs_ssec_plain
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
type BoxBody = http_body_util::combinators::UnsyncBoxBody<Bytes, BoxError>;
|
||||
|
||||
/// Every header that carries, or announces, a customer-provided key: the
|
||||
/// object's own triple and the copy-source triple that CopyObject and
|
||||
/// UploadPartCopy use to read an SSE-C source.
|
||||
const SSEC_HEADERS: [&str; 6] = [
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_COPY_CUSTOMER_ALGORITHM,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_COPY_CUSTOMER_KEY,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_COPY_CUSTOMER_KEY_MD5,
|
||||
];
|
||||
|
||||
/// Whether the request carries any SSE-C header.
|
||||
///
|
||||
/// Any one of the three is enough: an incomplete triple is still an attempt to
|
||||
/// use SSE-C, and it is rejected later for being incomplete — but the key may
|
||||
/// already have crossed the wire.
|
||||
/// Any one is enough: an incomplete triple is still an attempt to use SSE-C,
|
||||
/// and it is rejected later for being incomplete — but the key may already
|
||||
/// have crossed the wire. The copy-source triple counts the same as the
|
||||
/// object's own: it is the source object's key, sent in the clear just the same.
|
||||
fn carries_ssec_headers(headers: &HeaderMap) -> bool {
|
||||
headers.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM)
|
||||
|| headers.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY)
|
||||
|| headers.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5)
|
||||
SSEC_HEADERS.iter().any(|name| headers.contains_key(*name))
|
||||
}
|
||||
|
||||
/// Whether this request reached the server over TLS.
|
||||
@@ -204,6 +216,7 @@ where
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustfs_utils::http::headers::AMZ_COPY_SOURCE;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
fn ssec_headers() -> HeaderMap {
|
||||
@@ -244,6 +257,26 @@ mod tests {
|
||||
assert!(carries_ssec_headers(&only_md5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_source_ssec_headers_count_as_an_ssec_request() {
|
||||
// CopyObject / UploadPartCopy of an SSE-C source send the source key in
|
||||
// the copy-source triple; each one alone must trip the guard.
|
||||
for name in [
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_COPY_CUSTOMER_ALGORITHM,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_COPY_CUSTOMER_KEY,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_COPY_CUSTOMER_KEY_MD5,
|
||||
] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(name, HeaderValue::from_static("dmFsdWU="));
|
||||
assert!(carries_ssec_headers(&headers), "{name} must count as an SSE-C header");
|
||||
}
|
||||
|
||||
// Other copy-source headers are not key material.
|
||||
let mut copy_only = HeaderMap::new();
|
||||
copy_only.insert(AMZ_COPY_SOURCE, HeaderValue::from_static("bucket/object"));
|
||||
assert!(!carries_ssec_headers(©_only));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_is_secure_only_on_tls_or_a_resolved_https_proxy() {
|
||||
assert!(is_secure_transport(true, None), "a TLS listener needs no header to prove it");
|
||||
|
||||
+185
-8
@@ -1610,6 +1610,24 @@ fn build_kms_request_context(
|
||||
context
|
||||
}
|
||||
|
||||
/// Rejects a client encryption context that would replace the location binding.
|
||||
///
|
||||
/// [`build_kms_request_context`] binds the data key to `{bucket: bucket/key}`
|
||||
/// with `or_insert`, and only the client context is persisted. A client entry
|
||||
/// keyed by the bucket name therefore travels with the object and replaces the
|
||||
/// binding on every read, so the envelope would no longer be tied to the
|
||||
/// object's location. Only the write side rejects it: objects stored with such
|
||||
/// an entry stay readable because the read side rebuilds the context unchanged.
|
||||
fn reject_kms_context_location_override(bucket: &str, ssekms_context: Option<&HashMap<String, String>>) -> Result<(), ApiError> {
|
||||
if ssekms_context.is_some_and(|context| context.contains_key(bucket)) {
|
||||
return Err(sse_invalid_argument(&format!(
|
||||
"The x-amz-server-side-encryption-context header contains the key \"{bucket}\", which equals the bucket name; \
|
||||
that key is reserved for binding the data key to the object's location."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_object_encryption_context(
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
@@ -2697,6 +2715,10 @@ async fn apply_managed_encryption_material(
|
||||
content_size: i64,
|
||||
principal: Option<&SseKmsPrincipal>,
|
||||
) -> Result<EncryptionMaterial, ApiError> {
|
||||
// Request validation, not a KMS operation: reject before the KMS is called
|
||||
// and before an audit outcome is recorded.
|
||||
reject_kms_context_location_override(bucket, ssekms_context.as_ref())?;
|
||||
|
||||
let requested_sse_type = managed_sse_type(server_side_encryption.as_str());
|
||||
let requested_key_id = kms_key_id.clone();
|
||||
let result = apply_managed_encryption_material_inner(
|
||||
@@ -4358,14 +4380,15 @@ mod tests {
|
||||
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, ObjectDekRewrapOutcome,
|
||||
ObjectEncryptionResolver, PrepareEncryptionRequest, ReadEncryptionMode, ReadEncryptionRequest, SSEC_ORIGINAL_SIZE_HEADER,
|
||||
SSEType, SseDekProvider, SseKmsPrincipal, SseObjectEncryptionResolver, SsecParams, StorageError, TestSseDekProvider,
|
||||
apply_managed_decryption_material, apply_managed_encryption_material, authorize_sse_kms_object_read,
|
||||
build_kms_request_context, classify_sse_read_response, encode_minio_kms_context, encryption_material_to_metadata,
|
||||
extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers,
|
||||
generate_ssec_nonce, is_managed_sse, kms_operation_error, map_get_object_reader_error, mark_encrypted_multipart_metadata,
|
||||
md5_base64, normalize_managed_metadata, project_sse_read_response_headers, recode_minio_kms_context,
|
||||
reset_sse_dek_provider, resolve_effective_kms_key_id, resolve_stored_kms_key_id, rewrap_object_encryption_metadata,
|
||||
sse_decryption, sse_encryption, sse_prepare_encryption, strip_managed_encryption_metadata, validate_sse_headers_for_read,
|
||||
validate_sse_headers_for_write, validate_ssec_for_read, validate_ssec_params, verify_ssec_key_match,
|
||||
apply_managed_decryption_material, apply_managed_encryption_material, apply_managed_encryption_material_inner,
|
||||
authorize_sse_kms_object_read, build_kms_request_context, classify_sse_read_response, encode_minio_kms_context,
|
||||
encryption_material_to_metadata, extract_server_side_encryption_from_headers, extract_ssec_params_from_headers,
|
||||
extract_ssekms_context_from_headers, generate_ssec_nonce, is_managed_sse, kms_operation_error,
|
||||
map_get_object_reader_error, mark_encrypted_multipart_metadata, md5_base64, normalize_managed_metadata,
|
||||
project_sse_read_response_headers, recode_minio_kms_context, reset_sse_dek_provider, resolve_effective_kms_key_id,
|
||||
resolve_stored_kms_key_id, rewrap_object_encryption_metadata, sse_decryption, sse_encryption, sse_prepare_encryption,
|
||||
strip_managed_encryption_metadata, validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read,
|
||||
validate_ssec_params, verify_ssec_key_match,
|
||||
};
|
||||
#[cfg(feature = "rio-v2")]
|
||||
use super::{
|
||||
@@ -8229,6 +8252,160 @@ mod tests {
|
||||
assert_eq!(audit_tag(&read_tags, "kmsOutcome").as_deref(), Some("success"));
|
||||
}
|
||||
|
||||
async fn install_test_kms_provider(key_name: &str) {
|
||||
use rustfs_kms::types::{CreateKeyRequest, KeyUsage};
|
||||
|
||||
reset_sse_dek_provider();
|
||||
let manager = configure_test_global_local_kms().await;
|
||||
manager
|
||||
.get_encryption_service()
|
||||
.await
|
||||
.expect("encryption service should exist")
|
||||
.create_key(CreateKeyRequest {
|
||||
key_name: Some(key_name.to_string()),
|
||||
key_usage: KeyUsage::EncryptDecrypt,
|
||||
description: None,
|
||||
policy: None,
|
||||
tags: HashMap::new(),
|
||||
origin: None,
|
||||
})
|
||||
.await
|
||||
.expect("kms test key should be created");
|
||||
let provider = KmsSseDekProvider::new_with_service_manager(manager)
|
||||
.await
|
||||
.expect("kms provider should initialize from the configured test manager");
|
||||
super::set_sse_dek_provider_for_test(Arc::new(provider));
|
||||
}
|
||||
|
||||
fn sse_kms_request<'a>(bucket: &'a str, key: &'a str, context: HashMap<String, String>) -> EncryptionRequest<'a> {
|
||||
EncryptionRequest {
|
||||
bucket,
|
||||
key,
|
||||
server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS)),
|
||||
ssekms_key_id: Some("binding-key".to_string()),
|
||||
ssekms_context: Some(context),
|
||||
sse_customer_algorithm: None,
|
||||
sse_customer_key: None,
|
||||
sse_customer_key_md5: None,
|
||||
content_size: 64,
|
||||
principal: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_kms_write_rejects_a_context_key_equal_to_the_bucket_name() {
|
||||
let _guard = lock_sse_test_state().await;
|
||||
install_test_kms_provider("binding-key").await;
|
||||
let context = HashMap::from([("finance".to_string(), "finance/elsewhere".to_string())]);
|
||||
|
||||
let err = sse_encryption(sse_kms_request("finance", "ledger.csv", context.clone()))
|
||||
.await
|
||||
.expect_err("a context entry under the bucket name must be rejected");
|
||||
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
||||
assert!(
|
||||
err.message.contains("\"finance\""),
|
||||
"the message must name the offending key: {}",
|
||||
err.message
|
||||
);
|
||||
|
||||
// CreateMultipartUpload generates the session data key through the
|
||||
// prepare path; it must refuse the same context.
|
||||
let err = sse_prepare_encryption(PrepareEncryptionRequest {
|
||||
bucket: "finance",
|
||||
key: "ledger.csv",
|
||||
server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS)),
|
||||
ssekms_key_id: Some("binding-key".to_string()),
|
||||
ssekms_context: Some(context),
|
||||
sse_customer_algorithm: None,
|
||||
sse_customer_key: None,
|
||||
sse_customer_key_md5: None,
|
||||
principal: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("the multipart prepare path must reject the same context");
|
||||
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
||||
|
||||
reset_sse_dek_provider();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_kms_write_keeps_the_location_binding_when_the_context_names_another_bucket() {
|
||||
let _guard = lock_sse_test_state().await;
|
||||
install_test_kms_provider("binding-key").await;
|
||||
let context = HashMap::from([
|
||||
("archive".to_string(), "archive/ledger.csv".to_string()),
|
||||
("tenant".to_string(), "acct-4711".to_string()),
|
||||
]);
|
||||
|
||||
let material = sse_encryption(sse_kms_request("finance", "ledger.csv", context))
|
||||
.await
|
||||
.expect("a key naming a different bucket is an ordinary context entry")
|
||||
.expect("managed sse-kms material");
|
||||
let metadata = encryption_material_to_metadata(&material).expect("kms metadata should serialize");
|
||||
|
||||
let read = |key: &'static str| {
|
||||
let metadata = metadata.clone();
|
||||
async move {
|
||||
sse_decryption(DecryptionRequest {
|
||||
bucket: "finance",
|
||||
key,
|
||||
metadata: &metadata,
|
||||
sse_customer_key: None,
|
||||
sse_customer_key_md5: None,
|
||||
principal: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
};
|
||||
let decrypted = read("ledger.csv")
|
||||
.await
|
||||
.expect("the object decrypts at its own location")
|
||||
.expect("managed sse-kms material");
|
||||
assert_eq!(decrypted.key_bytes, material.key_bytes);
|
||||
read("relocated.csv")
|
||||
.await
|
||||
.expect_err("the data key stays bound to the object's location");
|
||||
|
||||
reset_sse_dek_provider();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_kms_objects_stored_with_a_bucket_keyed_context_stay_readable() {
|
||||
let _guard = lock_sse_test_state().await;
|
||||
install_test_kms_provider("binding-key").await;
|
||||
let context = HashMap::from([("finance".to_string(), "finance/elsewhere".to_string())]);
|
||||
|
||||
// Objects written before the write-side rejection reached the inner
|
||||
// routine without validation; reproduce that stored shape directly.
|
||||
let material = apply_managed_encryption_material_inner(
|
||||
"finance",
|
||||
"ledger.csv",
|
||||
ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS),
|
||||
Some("binding-key".to_string()),
|
||||
Some(context),
|
||||
64,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("the inner routine does not validate the client context");
|
||||
let metadata = encryption_material_to_metadata(&material).expect("kms metadata should serialize");
|
||||
|
||||
let decrypted = sse_decryption(DecryptionRequest {
|
||||
bucket: "finance",
|
||||
key: "ledger.csv",
|
||||
metadata: &metadata,
|
||||
sse_customer_key: None,
|
||||
sse_customer_key_md5: None,
|
||||
principal: None,
|
||||
})
|
||||
.await
|
||||
.expect("an existing object must stay readable after the write-side rejection")
|
||||
.expect("managed sse-kms material");
|
||||
assert_eq!(decrypted.key_bytes, material.key_bytes);
|
||||
|
||||
reset_sse_dek_provider();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_c_requests_produce_no_kms_audit_summary() {
|
||||
let key = [0x21u8; 32];
|
||||
|
||||
Reference in New Issue
Block a user