perf(sse): drop the second KMS decrypt from encrypted GET responses (#6597)

perf(sse): classify GET response headers without a second KMS unwrap

An SSE-KMS GET performed two backend Decrypt calls per request: the
object layer's encryption resolver unwraps the envelope to build the
decrypted stream, and the S3 layer then called sse_decryption again
purely to derive response headers, discarding the returned key bytes.

Replace the S3-layer call with classify_sse_read_response, which
reproduces that call's behavior from stored metadata alone: SSE-C
validation errors and precedence, per-key kms:Decrypt authorization
ahead of every other failure mode, and the request's KMS audit summary
fields. The success outcome stays honest because a failed unwrap aborts
the read in the object layer before response classification is reached.

Tests cover header parity against the unwrap-based path, audit-tag
parity for allowed and denied principals, SSE-C validation parity, and
prove classification needs no DEK provider at all.
This commit is contained in:
唐小鸭
2026-08-26 09:35:42 +08:00
committed by GitHub
parent b93e7b2355
commit a65b306fb0
4 changed files with 347 additions and 32 deletions
+16 -18
View File
@@ -96,10 +96,10 @@ use super::storage_api::object_usecase::set_disk::{
get_lock_acquire_timeout, get_object_disk_read_timeout, is_valid_storage_class,
};
use super::storage_api::object_usecase::sse::{
DecryptionRequest, EncryptionRequest, SSEType, SseKmsPrincipal, apply_bucket_default_lock_retention,
authorize_sse_kms_object_read, bucket_default_write_sse, build_ssec_read_headers, encryption_material_to_metadata,
DecryptionRequest, EncryptionRequest, SseKmsPrincipal, apply_bucket_default_lock_retention, authorize_sse_kms_object_read,
bucket_default_write_sse, build_ssec_read_headers, classify_sse_read_response, encryption_material_to_metadata,
extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers,
get_buffer_size_opt_in, load_bucket_object_lock_config_state, map_get_object_reader_error, sse_decryption, sse_encryption,
get_buffer_size_opt_in, load_bucket_object_lock_config_state, map_get_object_reader_error, sse_encryption,
validate_bucket_object_lock_enabled_state,
};
use super::storage_api::object_usecase::storage_class as storageclass;
@@ -5325,21 +5325,19 @@ impl DefaultObjectUsecase {
encryption_applied,
final_stream,
buffered_body,
) = match sse_decryption(decryption_request).await? {
Some(material) => {
let server_side_encryption = Some(material.server_side_encryption.clone());
let sse_customer_algorithm = matches!(material.sse_type, SSEType::SseC).then_some(material.algorithm.clone());
let sse_customer_key_md5 = material.customer_key_md5.clone();
(
server_side_encryption,
sse_customer_algorithm,
sse_customer_key_md5,
material.kms_key_id,
true,
wrap_reader(stream),
None,
)
}
) = match classify_sse_read_response(decryption_request).await? {
// The stream is already decrypted by the object layer's encryption
// resolver; only the response headers, authorization and audit
// summary are derived here, without a second KMS unwrap.
Some(headers) => (
Some(headers.server_side_encryption),
headers.sse_customer_algorithm,
headers.sse_customer_key_md5,
headers.ssekms_key_id,
true,
wrap_reader(stream),
None,
),
None => (None, None, None, None, false, wrap_reader(stream), buffered_body),
};
+3 -3
View File
@@ -1045,12 +1045,12 @@ pub(crate) mod request_context {
pub(crate) mod sse {
pub(crate) use crate::storage::storage_api::sse_consumer::{
DecryptionRequest, EncryptionRequest, PrepareEncryptionRequest, SseKmsPrincipal, apply_bucket_default_lock_retention,
authorize_sse_kms_object_read, extract_server_side_encryption_from_headers, get_buffer_size_opt_in,
load_bucket_object_lock_config_state, sse_decryption, sse_encryption, sse_prepare_encryption,
authorize_sse_kms_object_read, classify_sse_read_response, extract_server_side_encryption_from_headers,
get_buffer_size_opt_in, load_bucket_object_lock_config_state, sse_decryption, sse_encryption, sse_prepare_encryption,
validate_bucket_object_lock_enabled_state,
};
pub(crate) use crate::storage::storage_api::sse_consumer::{
EncryptionKeyKind, SSEType, bucket_default_write_sse, build_ssec_read_headers, encryption_material_to_metadata,
EncryptionKeyKind, bucket_default_write_sse, build_ssec_read_headers, encryption_material_to_metadata,
extract_ssec_params_from_headers, extract_ssekms_context_from_headers, map_get_object_reader_error,
mark_encrypted_multipart_metadata,
};
+322 -6
View File
@@ -792,6 +792,7 @@ pub struct DecryptionMaterial {
pub sse_type: SSEType,
pub server_side_encryption: ServerSideEncryption,
pub kms_key_id: Option<SSEKMSKeyId>,
#[allow(unused)]
pub algorithm: SSECustomerAlgorithm,
pub customer_key_md5: Option<SSECustomerKeyMD5>, // if use SSE-C, check key md5
@@ -2108,6 +2109,129 @@ pub async fn sse_decryption(request: DecryptionRequest<'_>) -> Result<Option<Dec
Ok(None)
}
/// Response headers derived for a decrypted read without unwrapping the data key.
#[derive(Debug)]
pub struct SseReadResponseHeaders {
pub server_side_encryption: ServerSideEncryption,
pub sse_customer_algorithm: Option<SSECustomerAlgorithm>,
pub sse_customer_key_md5: Option<SSECustomerKeyMD5>,
pub ssekms_key_id: Option<SSEKMSKeyId>,
}
/// Read-side response classification for an object whose payload the object
/// layer's encryption resolver already decrypted.
///
/// [`sse_decryption`] both unwraps the data key and derives the response
/// headers. The GET path only needs the latter — its stream comes out of the
/// object layer decrypted (see [`SseObjectEncryptionResolver`]) — so calling
/// [`sse_decryption`] there performed a second KMS `Decrypt` per request whose
/// key bytes were discarded. This function reproduces that call's header,
/// validation, authorization and audit behavior from stored metadata alone.
///
/// Contract, mirrored from [`sse_decryption`] item by item:
/// - SSE-C: missing request key/MD5 and stored/request MD5 mismatch fail with
/// the same errors and precedence, and the provided key is fully validated.
/// - Managed SSE: per-key `kms:Decrypt` authorization runs ahead of every other
/// failure mode, and the request's KMS audit summary records the same
/// scheme/key/version/outcome fields the unwrap-based path recorded. The
/// success outcome is honest because a failed unwrap aborts the read in the
/// object layer before response classification is ever reached.
/// - Objects without SSE metadata — and managed metadata whose scheme cannot
/// be established in this build — classify as `None`, exactly where
/// [`sse_decryption`] returned `None`.
pub async fn classify_sse_read_response(request: DecryptionRequest<'_>) -> Result<Option<SseReadResponseHeaders>, ApiError> {
if request
.metadata
.contains_key("x-amz-server-side-encryption-customer-algorithm")
{
let (key, key_md5) = match (request.sse_customer_key, request.sse_customer_key_md5) {
(Some(k), Some(md5)) => (k, md5),
_ => {
return Err(ssec_invalid_request(
"The object was stored using a form of Server Side Encryption. \
The correct parameters must be provided to retrieve the object.",
));
}
};
let stored_md5 = request.metadata.get("x-amz-server-side-encryption-customer-key-md5");
verify_ssec_key_match(key_md5, stored_md5)?;
let algorithm = request
.metadata
.get("x-amz-server-side-encryption-customer-algorithm")
.map(|s| s.as_str())
.unwrap_or("AES256");
validate_ssec_params(SsecParams {
algorithm: algorithm.to_string(),
key: key.to_string(),
key_md5: key_md5.to_string(),
})?;
return Ok(Some(SseReadResponseHeaders {
server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AES256),
sse_customer_algorithm: Some(SSECustomerAlgorithm::from(algorithm)),
sse_customer_key_md5: Some(key_md5.clone()),
ssekms_key_id: None,
}));
}
if !contains_managed_encryption_metadata(request.metadata) {
return Ok(None);
}
let sse_type = match request.metadata.get("x-amz-server-side-encryption").map(String::as_str) {
Some(ServerSideEncryption::AWS_KMS) => SSEType::SseKms,
Some(_) => SSEType::SseS3,
#[cfg(feature = "rio-v2")]
None => match infer_minio_managed_sse_type(request.metadata) {
Some(sse_type) => sse_type,
None => return Ok(None),
},
#[cfg(not(feature = "rio-v2"))]
None => return Ok(None),
};
// Same key-id resolution chain as the unwrap path, so authorization and the
// response header name the same key.
let normalized_metadata = normalize_managed_metadata(request.metadata, Some(recode_minio_kms_context));
let kms_key_id = normalized_metadata
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
.or_else(|| request.metadata.get("x-amz-server-side-encryption-aws-kms-key-id"))
.cloned()
.unwrap_or_else(|| "default".to_string());
// Ahead of every other failure mode, so a denied caller learns nothing
// about the key beyond "not yours".
if let Err(error) = authorize_sse_kms_key(request.principal, sse_type, KmsAction::DecryptAction, &kms_key_id).await {
if let Some((sse_type, key_id)) = stored_managed_encryption_key(request.metadata) {
record_managed_kms_outcome(
request.principal,
sse_type,
Some(&key_id),
|| stored_envelope_master_key_version(request.metadata),
Err(&error),
);
}
return Err(error);
}
record_managed_kms_outcome(
request.principal,
sse_type,
Some(&kms_key_id),
|| stored_envelope_master_key_version(request.metadata),
Ok(()),
);
Ok(Some(SseReadResponseHeaders {
server_side_encryption: ServerSideEncryption::from(managed_sse_public_header(sse_type).to_string()),
sse_customer_algorithm: None,
sse_customer_key_md5: None,
ssekms_key_id: Some(SSEKMSKeyId::from(kms_key_id)),
}))
}
// ============================================================================
// Internal Implementation - SSE-C
// ============================================================================
@@ -3663,12 +3787,12 @@ mod tests {
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,
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,
recode_minio_kms_context, reset_sse_dek_provider, resolve_effective_kms_key_id, 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,
classify_sse_read_response, 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, recode_minio_kms_context, reset_sse_dek_provider, resolve_effective_kms_key_id,
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::{
@@ -6711,6 +6835,198 @@ mod tests {
assert_eq!(audit_tag(&tags, "kmsErrorClass").as_deref(), Some("access_denied"));
}
// ========================================================================
// Read-side response classification (single-decrypt GET path)
// ========================================================================
#[tokio::test]
async fn classification_reproduces_managed_read_headers_and_audit_without_a_kms_unwrap() {
use rustfs_kms::types::{CreateKeyRequest, KeyUsage};
let _guard = lock_sse_test_state().await;
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("classify-key".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.clone())
.await
.expect("kms provider should initialize from the configured test manager");
super::set_sse_dek_provider_for_test(Arc::new(provider));
let material = sse_encryption(EncryptionRequest {
bucket: "finance",
key: "ledger.csv",
server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS)),
ssekms_key_id: Some("classify-key".to_string()),
ssekms_context: None,
sse_customer_algorithm: None,
sse_customer_key: None,
sse_customer_key_md5: None,
content_size: 128,
principal: None,
})
.await
.expect("sse-kms encryption should succeed")
.expect("managed sse-kms material");
let metadata = encryption_material_to_metadata(&material).expect("kms metadata should serialize");
// Header parity against the unwrap-based path, on the same metadata.
let unwrapped = sse_decryption(DecryptionRequest {
bucket: "finance",
key: "ledger.csv",
metadata: &metadata,
sse_customer_key: None,
sse_customer_key_md5: None,
principal: None,
})
.await
.expect("sse-kms decryption should succeed")
.expect("managed sse-kms material");
// Classification must not need a data-key unwrap at all: drop the
// provider before classifying, so any KMS round trip would fail loudly.
reset_sse_dek_provider();
let (read_principal, read_audit) = audited_principal(false, true);
let headers = classify_sse_read_response(DecryptionRequest {
bucket: "finance",
key: "ledger.csv",
metadata: &metadata,
sse_customer_key: None,
sse_customer_key_md5: None,
principal: Some(&read_principal),
})
.await
.expect("classification should succeed without a dek provider")
.expect("managed metadata must classify as encrypted");
assert_eq!(headers.server_side_encryption, unwrapped.server_side_encryption);
assert_eq!(headers.ssekms_key_id, unwrapped.kms_key_id);
assert_eq!(headers.sse_customer_algorithm, None);
assert_eq!(headers.sse_customer_key_md5, None);
// Audit parity: the same summary fields the unwrap-based read recorded.
let read_tags = read_audit.audit_tags();
assert_eq!(audit_tag(&read_tags, "sseType").as_deref(), Some("SSE-KMS"));
assert_eq!(audit_tag(&read_tags, "kmsKeyId").as_deref(), Some("classify-key"));
assert_eq!(audit_tag(&read_tags, "kmsOutcome").as_deref(), Some("success"));
assert_eq!(audit_tag(&read_tags, "kmsErrorClass"), None);
}
#[tokio::test]
async fn classification_denies_an_unauthorized_principal_with_the_same_audit_summary() {
let (principal, audit) = audited_principal(true, false);
let error = classify_sse_read_response(DecryptionRequest {
bucket: "finance",
key: "ledger.csv",
metadata: &sse_kms_object_metadata(),
sse_customer_key: None,
sse_customer_key_md5: None,
principal: Some(&principal),
})
.await
.expect_err("an unauthorized principal must not classify against the key");
assert_eq!(error.code, S3ErrorCode::AccessDenied);
let tags = audit.audit_tags();
assert_eq!(audit_tag(&tags, "sseType").as_deref(), Some("SSE-KMS"));
assert_eq!(audit_tag(&tags, "kmsKeyId").as_deref(), Some("finance-key"));
assert_eq!(audit_tag(&tags, "kmsOutcome").as_deref(), Some("failure"));
assert_eq!(audit_tag(&tags, "kmsErrorClass").as_deref(), Some("access_denied"));
}
#[tokio::test]
async fn classification_matches_ssec_validation_and_headers() {
let key = [0x42u8; 32];
let key_b64 = BASE64_STANDARD.encode(key);
let key_md5 = md5_base64(key);
let metadata = HashMap::from([
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
("x-amz-server-side-encryption-customer-key-md5".to_string(), key_md5.clone()),
]);
let headers = classify_sse_read_response(DecryptionRequest {
bucket: "finance",
key: "ledger.csv",
metadata: &metadata,
sse_customer_key: Some(&SSECustomerKey::from(key_b64.clone())),
sse_customer_key_md5: Some(&SSECustomerKeyMD5::from(key_md5.clone())),
principal: None,
})
.await
.expect("a matching customer key should classify")
.expect("SSE-C metadata must classify as encrypted");
assert_eq!(headers.server_side_encryption.as_str(), ServerSideEncryption::AES256);
assert_eq!(headers.sse_customer_algorithm.as_deref(), Some("AES256"));
assert_eq!(headers.sse_customer_key_md5.as_deref(), Some(key_md5.as_str()));
assert_eq!(headers.ssekms_key_id, None);
// Same error and precedence as `sse_decryption` when the key is absent…
let missing = classify_sse_read_response(DecryptionRequest {
bucket: "finance",
key: "ledger.csv",
metadata: &metadata,
sse_customer_key: None,
sse_customer_key_md5: None,
principal: None,
})
.await
.expect_err("an SSE-C object must not classify without the customer key");
assert_eq!(missing.code, S3ErrorCode::InvalidRequest);
// …and when the provided key does not match the stored digest.
let other_key = [0x43u8; 32];
let mismatch = classify_sse_read_response(DecryptionRequest {
bucket: "finance",
key: "ledger.csv",
metadata: &metadata,
sse_customer_key: Some(&SSECustomerKey::from(BASE64_STANDARD.encode(other_key))),
sse_customer_key_md5: Some(&SSECustomerKeyMD5::from(md5_base64(other_key))),
principal: None,
})
.await
.expect_err("a mismatched customer key must not classify");
let parity = sse_decryption(DecryptionRequest {
bucket: "finance",
key: "ledger.csv",
metadata: &metadata,
sse_customer_key: Some(&SSECustomerKey::from(BASE64_STANDARD.encode(other_key))),
sse_customer_key_md5: Some(&SSECustomerKeyMD5::from(md5_base64(other_key))),
principal: None,
})
.await
.expect_err("the unwrap-based path rejects the same mismatch");
assert_eq!(mismatch.code, parity.code);
}
#[tokio::test]
async fn classification_reports_plaintext_objects_as_unencrypted() {
let result = classify_sse_read_response(DecryptionRequest {
bucket: "finance",
key: "ledger.csv",
metadata: &HashMap::new(),
sse_customer_key: None,
sse_customer_key_md5: None,
principal: None,
})
.await
.expect("plaintext metadata should classify cleanly");
assert!(result.is_none(), "objects without SSE metadata carry no SSE response headers");
}
#[test]
fn kms_backend_failures_keep_the_kms_audit_error_class() {
assert_eq!(
+6 -5
View File
@@ -110,8 +110,9 @@ pub(crate) use super::ecfs_extend::{
};
pub(crate) use super::sse::{
DecryptionRequest, EncryptionRequest, PrepareEncryptionRequest, SseKmsPrincipal, authorize_sse_kms_object_read,
extract_server_side_encryption_from_headers, 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,
classify_sse_read_response, extract_server_side_encryption_from_headers, 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,
};
pub(crate) mod access_consumer {
@@ -355,14 +356,14 @@ pub(crate) mod s3_api_consumer {
pub(crate) mod sse_consumer {
pub(crate) use super::super::sse::{
EncryptionKeyKind, SSEType, bucket_default_write_sse, build_ssec_read_headers, encryption_material_to_metadata,
EncryptionKeyKind, bucket_default_write_sse, build_ssec_read_headers, encryption_material_to_metadata,
extract_ssec_params_from_headers, extract_ssekms_context_from_headers, log_sse_kms_key_policy_mode,
map_get_object_reader_error, mark_encrypted_multipart_metadata,
};
pub(crate) use super::{
DecryptionRequest, EncryptionRequest, PrepareEncryptionRequest, SseKmsPrincipal, apply_bucket_default_lock_retention,
authorize_sse_kms_object_read, extract_server_side_encryption_from_headers, get_buffer_size_opt_in,
load_bucket_object_lock_config_state, sse_decryption, sse_encryption, sse_prepare_encryption,
authorize_sse_kms_object_read, classify_sse_read_response, extract_server_side_encryption_from_headers,
get_buffer_size_opt_in, load_bucket_object_lock_config_state, sse_decryption, sse_encryption, sse_prepare_encryption,
validate_bucket_object_lock_enabled_state,
};
}