From a65b306fb04e43db94fcc176f9e31ac6397e05a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=B0=8F=E9=B8=AD?= Date: Wed, 26 Aug 2026 09:35:42 +0800 Subject: [PATCH] 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. --- rustfs/src/app/object_usecase.rs | 34 ++-- rustfs/src/app/storage_api.rs | 6 +- rustfs/src/storage/sse.rs | 328 +++++++++++++++++++++++++++++- rustfs/src/storage/storage_api.rs | 11 +- 4 files changed, 347 insertions(+), 32 deletions(-) diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index 9f0360915..10665335b 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -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), }; diff --git a/rustfs/src/app/storage_api.rs b/rustfs/src/app/storage_api.rs index 3c085ad9b..f11b10c38 100644 --- a/rustfs/src/app/storage_api.rs +++ b/rustfs/src/app/storage_api.rs @@ -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, }; diff --git a/rustfs/src/storage/sse.rs b/rustfs/src/storage/sse.rs index b3e4f839f..772238706 100644 --- a/rustfs/src/storage/sse.rs +++ b/rustfs/src/storage/sse.rs @@ -792,6 +792,7 @@ pub struct DecryptionMaterial { pub sse_type: SSEType, pub server_side_encryption: ServerSideEncryption, pub kms_key_id: Option, + #[allow(unused)] pub algorithm: SSECustomerAlgorithm, pub customer_key_md5: Option, // if use SSE-C, check key md5 @@ -2108,6 +2109,129 @@ pub async fn sse_decryption(request: DecryptionRequest<'_>) -> Result, + pub sse_customer_key_md5: Option, + pub ssekms_key_id: Option, +} + +/// 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, 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!( diff --git a/rustfs/src/storage/storage_api.rs b/rustfs/src/storage/storage_api.rs index 80dd5bed9..c33a18f24 100644 --- a/rustfs/src/storage/storage_api.rs +++ b/rustfs/src/storage/storage_api.rs @@ -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, }; }