From 9b66040a02acf5ede81f22846cc89c419207b393 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Fri, 14 Aug 2026 00:08:18 +0800 Subject: [PATCH] refactor(sse): sink managed-SSE attribution into the shared encryption-keys module (#6017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(sse): sink managed-SSE attribution into the shared encryption-keys module Moves the managed-SSE classifier — stored_managed_encryption_key, contains_managed_encryption_metadata, normalize_managed_metadata — and the SSEType enum from rustfs/src/storage/sse.rs into crates/utils/src/http/object_encryption_keys.rs, the module that already owns every constant they read. This is PR-B0 of rustfs/backlog#1643: crates/scanner must never depend on the rustfs binary crate, so encryption attribution has to live in a shared lower layer before the scanner can report per-scheme coverage without growing a second classifier. SSEType moves wholesale (option a): its only impl is the dependency-free audit_label(), so the enum relocates verbatim (audit_label becomes pub) and rustfs::storage::sse re-exports it, keeping every existing path compiling. The one piece that cannot move verbatim is normalize_managed_metadata's KMS-context branch, which needs base64 and serde_json — dependencies rustfs-utils does not have and does not gain here. The shared normalizer instead takes an injected Option Option> context recoder; sse.rs passes recode_minio_kms_context, the old inline chain verbatim including the silent skip on decode failure. stored_managed_encryption_key passes no recoder because the context mapping only ever inserts the context key, which the key-id lookup never reads, so its output is identical. Every metadata lookup stays a case-sensitive exact match (lowercase x-amz-* stored forms, TitleCase MinIO-internal names) per the backlog#1775 trap; new shared-module tests pin that, and a source-scan test in sse.rs asserts the classifier has exactly one definition so a second copy cannot silently return. * fix(utils): satisfy encryption key test clippy --------- Co-authored-by: cxymds --- .../utils/src/http/object_encryption_keys.rs | 268 +++++++++++++++++- rustfs/src/storage/sse.rs | 157 ++++------ 2 files changed, 324 insertions(+), 101 deletions(-) diff --git a/crates/utils/src/http/object_encryption_keys.rs b/crates/utils/src/http/object_encryption_keys.rs index 7b71faeae..661d5bd98 100644 --- a/crates/utils/src/http/object_encryption_keys.rs +++ b/crates/utils/src/http/object_encryption_keys.rs @@ -25,7 +25,8 @@ // The lowercase stored forms, matching exactly what encryption_material_to_metadata // persists. The read-path SSE-C check is case-sensitive, so restoring under any // other casing would classify the replica as managed-SSE and reject SSE-C GETs. -use super::headers::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER}; +use super::headers::{AMZ_ENCRYPTION_KMS, SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER}; +use std::collections::HashMap; pub const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id"; pub const INTERNAL_ENCRYPTION_KEY_HEADER: &str = "x-rustfs-encryption-key"; @@ -165,6 +166,143 @@ pub fn is_replication_stripped_encryption_key(key: &str) -> bool { || super::starts_with_ignore_ascii_case(key, RUSTFS_INTERNAL_ENCRYPTION_PREFIX) } +// ============================================================================ +// Managed-SSE attribution (shared classifier) +// ============================================================================ +// +// Single source of truth for classifying stored managed-SSE (SSE-S3 / SSE-KMS) +// object metadata. These live here — rather than in the `rustfs` binary +// crate's SSE module — so lower-layer consumers such as the scanner can +// attribute encrypted objects without growing a second copy of the +// normalization/classification logic (backlog#1643 PR-B0). The binary crate +// re-exports them from `rustfs::storage::sse`, and a source-scan test there +// pins that no second definition reappears. +// +// Every metadata lookup below is a case-SENSITIVE exact match on the stored +// `HashMap` keys, mirroring the SSE read path. Do not +// "harmonize" these with the lowercase-normalizing helpers in +// `header_compat.rs`: the lowercase `x-amz-*` stored forms and the TitleCase +// MinIO-internal names are load-bearing exactly as written. + +/// Type of encryption used +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SSEType { + /// SSE-S3 (AES256) + SseS3, + /// SSE-KMS (aws:kms) + SseKms, + /// SSE-C (customer-provided key) + SseC, +} + +impl SSEType { + /// Stable scheme name for audit consumers. + pub fn audit_label(self) -> &'static str { + match self { + SSEType::SseS3 => "SSE-S3", + SSEType::SseKms => "SSE-KMS", + SSEType::SseC => "SSE-C", + } + } +} + +/// Recodes a stored MinIO KMS context value — base64-wrapped JSON under +/// [`MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER`] — into the plain-JSON form +/// RustFS stores under [`INTERNAL_ENCRYPTION_CONTEXT_HEADER`]. +/// +/// Injected by callers because this crate deliberately carries no JSON codec. +/// Returning `None` skips the context mapping, matching the historical +/// silent-skip on a value that fails to decode. +pub type KmsContextRecoder = fn(&str) -> Option; + +/// True when the stored metadata carries a managed-SSE (SSE-S3 / SSE-KMS) +/// encryption envelope, under either the RustFS-branded or the MinIO-branded +/// internal keys. +pub fn contains_managed_encryption_metadata(metadata: &HashMap) -> bool { + metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER) + || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER) + || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER) + || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER) + || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER) +} + +/// Maps the MinIO-branded internal SSE keys onto the RustFS-branded stored +/// keys (the dual internal metadata keys invariant). RustFS-branded keys +/// already present always win; every source lookup is a case-sensitive exact +/// match on the specific TitleCase MinIO names. +pub fn normalize_managed_metadata( + metadata: &HashMap, + recode_kms_context: Option, +) -> HashMap { + let mut normalized = metadata.clone(); + + if !normalized.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER) + && let Some(value) = metadata + .get(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER) + .or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)) + .or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)) + .or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)) + { + normalized.insert(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), value.clone()); + } + + if !normalized.contains_key(INTERNAL_ENCRYPTION_IV_HEADER) + && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER) + { + normalized.insert(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), value.clone()); + } + + if !normalized.contains_key(INTERNAL_ENCRYPTION_ALGORITHM_HEADER) + && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER) + { + normalized.insert(INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), value.clone()); + } + + if !normalized.contains_key(INTERNAL_ENCRYPTION_KEY_ID_HEADER) + && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER) + { + normalized.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), value.clone()); + } + + if !normalized.contains_key(INTERNAL_ENCRYPTION_CONTEXT_HEADER) + && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER) + && let Some(recode) = recode_kms_context + && let Some(encoded) = recode(value) + { + normalized.insert(INTERNAL_ENCRYPTION_CONTEXT_HEADER.to_string(), encoded); + } + + normalized +} + +/// Resolve the scheme and KMS key a stored managed-SSE object was wrapped with. +/// +/// Mirrors the lookup `apply_managed_decryption_material` performs, so both agree on +/// which key a read is authorized against. +/// +/// No [`KmsContextRecoder`] is taken: the context mapping only ever inserts +/// [`INTERNAL_ENCRYPTION_CONTEXT_HEADER`], which this lookup never reads, so +/// the result is identical with or without it. +pub fn stored_managed_encryption_key(metadata: &HashMap) -> Option<(SSEType, String)> { + if !contains_managed_encryption_metadata(metadata) { + return None; + } + + // Case-sensitive: the SSE writer stores the scheme under the lowercase + // `x-amz-server-side-encryption` key; other casings are not stored forms. + let sse_type = match metadata.get("x-amz-server-side-encryption")?.as_str() { + AMZ_ENCRYPTION_KMS => SSEType::SseKms, + _ => SSEType::SseS3, + }; + let key_id = normalize_managed_metadata(metadata, None) + .get(INTERNAL_ENCRYPTION_KEY_ID_HEADER) + .or_else(|| metadata.get("x-amz-server-side-encryption-aws-kms-key-id")) + .cloned() + .unwrap_or_else(|| "default".to_string()); + + Some((sse_type, key_id)) +} + #[cfg(test)] mod tests { use super::*; @@ -273,6 +411,134 @@ mod tests { assert!(!is_replication_stripped_encryption_key("content-type")); } + #[test] + fn managed_envelope_predicate_matches_both_key_families() { + assert!(!contains_managed_encryption_metadata(&HashMap::new())); + + for key in [ + INTERNAL_ENCRYPTION_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, + ] { + let single = HashMap::from([(key.to_string(), "value".to_string())]); + assert!(contains_managed_encryption_metadata(&single), "{key} must classify as managed SSE"); + } + + // SSE-C material alone is not a managed envelope. + let ssec_only = HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]); + assert!(!contains_managed_encryption_metadata(&ssec_only)); + } + + #[test] + fn normalize_maps_minio_keys_onto_missing_rustfs_keys_only() { + let metadata = HashMap::from([ + (MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), "minio-dek".to_string()), + (MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "minio-iv".to_string()), + (MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), "DAREv2-HMAC-SHA256".to_string()), + (MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "minio-key".to_string()), + ]); + + let normalized = normalize_managed_metadata(&metadata, None); + assert_eq!(normalized.get(INTERNAL_ENCRYPTION_KEY_HEADER).map(String::as_str), Some("minio-dek")); + assert_eq!(normalized.get(INTERNAL_ENCRYPTION_IV_HEADER).map(String::as_str), Some("minio-iv")); + assert_eq!( + normalized.get(INTERNAL_ENCRYPTION_ALGORITHM_HEADER).map(String::as_str), + Some("DAREv2-HMAC-SHA256") + ); + assert_eq!(normalized.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER).map(String::as_str), Some("minio-key")); + + // Existing RustFS-branded keys always win over the MinIO twins. + let mut both = metadata; + both.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), "rustfs-key".to_string()); + assert_eq!( + normalize_managed_metadata(&both, None) + .get(INTERNAL_ENCRYPTION_KEY_ID_HEADER) + .map(String::as_str), + Some("rustfs-key") + ); + + // The mapping is a case-sensitive exact match on the TitleCase MinIO + // names; a lowercased twin must not normalize. + let lowercased = HashMap::from([(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_lowercase(), "minio-key".to_string())]); + assert!(!normalize_managed_metadata(&lowercased, None).contains_key(INTERNAL_ENCRYPTION_KEY_ID_HEADER)); + } + + #[test] + fn normalize_recodes_kms_context_only_through_the_injected_codec() { + let metadata = HashMap::from([(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER.to_string(), "encoded-context".to_string())]); + + // Without a codec the context stays unnormalized. + assert!(!normalize_managed_metadata(&metadata, None).contains_key(INTERNAL_ENCRYPTION_CONTEXT_HEADER)); + + // A codec that fails to decode also leaves it unnormalized. + fn reject(_value: &str) -> Option { + None + } + assert!(!normalize_managed_metadata(&metadata, Some(reject)).contains_key(INTERNAL_ENCRYPTION_CONTEXT_HEADER)); + + fn recode(value: &str) -> Option { + Some(format!("recoded:{value}")) + } + assert_eq!( + normalize_managed_metadata(&metadata, Some(recode)) + .get(INTERNAL_ENCRYPTION_CONTEXT_HEADER) + .map(String::as_str), + Some("recoded:encoded-context") + ); + + // A stored RustFS context wins without invoking the codec. + let mut both = metadata; + both.insert(INTERNAL_ENCRYPTION_CONTEXT_HEADER.to_string(), "stored-context".to_string()); + assert_eq!( + normalize_managed_metadata(&both, Some(recode)) + .get(INTERNAL_ENCRYPTION_CONTEXT_HEADER) + .map(String::as_str), + Some("stored-context") + ); + } + + #[test] + fn stored_managed_encryption_key_attributes_scheme_and_key() { + // Plaintext metadata carries no managed envelope. + assert!(stored_managed_encryption_key(&HashMap::new()).is_none()); + + // A managed envelope without the stored SSE marker cannot be attributed. + let envelope_only = HashMap::from([(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), "dek".to_string())]); + assert!(stored_managed_encryption_key(&envelope_only).is_none()); + + // The stored SSE marker is the lowercase form; a TitleCase key is not + // a stored form and must not be recognized. + let mut titlecase = envelope_only.clone(); + titlecase.insert("X-Amz-Server-Side-Encryption".to_string(), "aws:kms".to_string()); + assert!(stored_managed_encryption_key(&titlecase).is_none()); + + let mut sse_s3 = envelope_only.clone(); + sse_s3.insert("x-amz-server-side-encryption".to_string(), "AES256".to_string()); + assert_eq!(stored_managed_encryption_key(&sse_s3), Some((SSEType::SseS3, "default".to_string()))); + + let mut sse_kms = envelope_only; + sse_kms.insert("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()); + assert_eq!(stored_managed_encryption_key(&sse_kms), Some((SSEType::SseKms, "default".to_string()))); + + // Key-id precedence: RustFS stored key id, then the MinIO twin, then + // the lowercase amz key id, then "default". + sse_kms.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), "amz-key".to_string()); + assert_eq!(stored_managed_encryption_key(&sse_kms), Some((SSEType::SseKms, "amz-key".to_string()))); + sse_kms.insert(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "minio-key".to_string()); + assert_eq!(stored_managed_encryption_key(&sse_kms), Some((SSEType::SseKms, "minio-key".to_string()))); + sse_kms.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), "rustfs-key".to_string()); + assert_eq!(stored_managed_encryption_key(&sse_kms), Some((SSEType::SseKms, "rustfs-key".to_string()))); + } + + #[test] + fn sse_type_audit_labels_are_stable() { + assert_eq!(SSEType::SseS3.audit_label(), "SSE-S3"); + assert_eq!(SSEType::SseKms.audit_label(), "SSE-KMS"); + assert_eq!(SSEType::SseC.audit_label(), "SSE-C"); + } + #[test] fn transport_prefixes_cover_every_transport_value_key() { // Every transport key that carries material must match a redaction diff --git a/rustfs/src/storage/sse.rs b/rustfs/src/storage/sse.rs index 66dd2095a..920d6b6ca 100644 --- a/rustfs/src/storage/sse.rs +++ b/rustfs/src/storage/sse.rs @@ -119,8 +119,14 @@ use rustfs_utils::http::object_encryption_keys::{ MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, - MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, SSEC_ORIGINAL_SIZE_HEADER, + MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, SSEC_ORIGINAL_SIZE_HEADER, normalize_managed_metadata, + stored_managed_encryption_key, }; +// The managed-SSE classifier lives in the shared encryption-keys module so the +// scanner can reuse it (backlog#1643 PR-B0); these re-exports keep the +// historical `crate::storage::sse` paths compiling. +pub use rustfs_utils::http::object_encryption_keys::SSEType; +pub(crate) use rustfs_utils::http::object_encryption_keys::contains_managed_encryption_metadata; #[cfg(feature = "rio-v2")] const MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM: &str = "DAREv2-HMAC-SHA256"; #[cfg(feature = "rio-v2")] @@ -783,28 +789,6 @@ pub struct DecryptionMaterial { pub key_kind: EncryptionKeyKind, } -/// Type of encryption used -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SSEType { - /// SSE-S3 (AES256) - SseS3, - /// SSE-KMS (aws:kms) - SseKms, - /// SSE-C (customer-provided key) - SseC, -} - -impl SSEType { - /// Stable scheme name for audit consumers. - fn audit_label(self) -> &'static str { - match self { - SSEType::SseS3 => "SSE-S3", - SSEType::SseKms => "SSE-KMS", - SSEType::SseC => "SSE-C", - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EncryptionKeyKind { Direct, @@ -1064,28 +1048,6 @@ pub async fn authorize_sse_kms_object_read( result } -/// Resolve the scheme and KMS key a stored managed-SSE object was wrapped with. -/// -/// Mirrors the lookup `apply_managed_decryption_material` performs, so both agree on -/// which key a read is authorized against. -fn stored_managed_encryption_key(metadata: &HashMap) -> Option<(SSEType, String)> { - if !contains_managed_encryption_metadata(metadata) { - return None; - } - - let sse_type = match metadata.get("x-amz-server-side-encryption")?.as_str() { - ServerSideEncryption::AWS_KMS => SSEType::SseKms, - _ => SSEType::SseS3, - }; - let key_id = normalize_managed_metadata(metadata) - .get(INTERNAL_ENCRYPTION_KEY_ID_HEADER) - .or_else(|| metadata.get("x-amz-server-side-encryption-aws-kms-key-id")) - .cloned() - .unwrap_or_else(|| "default".to_string()); - - Some((sse_type, key_id)) -} - // ============================================================================ // Data-plane KMS audit attachment (SSE-S3 / SSE-KMS) // ============================================================================ @@ -1339,7 +1301,9 @@ fn envelope_master_key_version(envelope_bytes: &[u8]) -> Option { /// Master-key version of the envelope stored on an object, for the audit /// summary of a read against that object. fn stored_envelope_master_key_version(metadata: &HashMap) -> Option { - let encoded = normalize_managed_metadata(metadata); + // No context recoder: the recode only ever inserts the context key, which + // this lookup never reads, so the normalized result is identical without it. + let encoded = normalize_managed_metadata(metadata, None); let encoded = encoded.get(INTERNAL_ENCRYPTION_KEY_HEADER)?; let envelope = BASE64_STANDARD.decode(encoded).ok()?; envelope_master_key_version(&envelope) @@ -2487,7 +2451,7 @@ async fn apply_managed_decryption_material_inner( // Safe: presence is guaranteed by the contains_key check above. let server_side_encryption = metadata.get("x-amz-server-side-encryption").cloned().unwrap_or_default(); - let normalized_metadata = normalize_managed_metadata(metadata); + let normalized_metadata = normalize_managed_metadata(metadata, Some(recode_minio_kms_context)); let encryption_type = match server_side_encryption.as_str() { ServerSideEncryption::AES256 => SSEType::SseS3, @@ -3229,14 +3193,6 @@ pub fn mark_encrypted_multipart_metadata(metadata: &mut HashMap) metadata.insert(MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER.to_string(), String::new()); } -pub(crate) fn contains_managed_encryption_metadata(metadata: &HashMap) -> bool { - metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER) - || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER) - || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER) - || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER) - || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER) -} - #[cfg(feature = "rio-v2")] fn is_legacy_rustfs_managed_metadata(metadata: &HashMap) -> bool { metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER) @@ -3282,47 +3238,16 @@ fn parse_minio_managed_sealed_key( Ok(Some(ManagedSealedKey { iv, sealed_key })) } -fn normalize_managed_metadata(metadata: &HashMap) -> HashMap { - let mut normalized = metadata.clone(); - - if !normalized.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER) - && let Some(value) = metadata - .get(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER) - .or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)) - .or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)) - .or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)) - { - normalized.insert(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), value.clone()); - } - - if !normalized.contains_key(INTERNAL_ENCRYPTION_IV_HEADER) - && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER) - { - normalized.insert(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), value.clone()); - } - - if !normalized.contains_key(INTERNAL_ENCRYPTION_ALGORITHM_HEADER) - && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER) - { - normalized.insert(INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), value.clone()); - } - - if !normalized.contains_key(INTERNAL_ENCRYPTION_KEY_ID_HEADER) - && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER) - { - normalized.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), value.clone()); - } - - if !normalized.contains_key(INTERNAL_ENCRYPTION_CONTEXT_HEADER) - && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER) - && let Ok(decoded) = BASE64_STANDARD.decode(value) - && let Ok(context) = serde_json::from_slice::>(&decoded) - && let Ok(encoded) = serde_json::to_string(&context) - { - normalized.insert(INTERNAL_ENCRYPTION_CONTEXT_HEADER.to_string(), encoded); - } - - normalized +/// Recodes a stored MinIO KMS context value (base64-wrapped JSON) into the +/// plain-JSON form RustFS stores under [`INTERNAL_ENCRYPTION_CONTEXT_HEADER`]. +/// +/// Injected into the shared [`normalize_managed_metadata`] because the shared +/// crate carries no JSON codec; any decode failure returns `None`, which skips +/// the context mapping exactly like the historical inline `if let Ok` chain. +fn recode_minio_kms_context(value: &str) -> Option { + let decoded = BASE64_STANDARD.decode(value).ok()?; + let context = serde_json::from_slice::>(&decoded).ok()?; + serde_json::to_string(&context).ok() } // ============================================================================ @@ -3473,9 +3398,9 @@ mod tests { 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, - 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, + 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::{ @@ -3484,6 +3409,38 @@ mod tests { }; use rustfs_utils::http::headers::SSEC_ALGORITHM_HEADER; + /// backlog#1643 PR-B0 acceptance guard: the managed-SSE classifier must + /// have exactly one definition — in the shared encryption-keys module — + /// so the scanner and the S3 layer can never disagree on attribution. + /// This module may only re-export or call it. + #[test] + fn managed_sse_classifier_has_exactly_one_definition() { + let classifier_fns = [ + "contains_managed_encryption_metadata", + "normalize_managed_metadata", + "stored_managed_encryption_key", + ]; + + let sse_src = include_str!("sse.rs"); + let shared_src = + std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../crates/utils/src/http/object_encryption_keys.rs")) + .expect("shared encryption-keys module should be readable"); + + for name in classifier_fns { + // Built at runtime so this test's own source cannot satisfy the scan. + let definition = format!("fn {name}("); + assert!( + !sse_src.contains(&definition), + "{name} must not be redefined in storage/sse.rs; call the shared rustfs_utils::http::object_encryption_keys implementation instead" + ); + assert_eq!( + shared_src.matches(&definition).count(), + 1, + "{name} must be defined exactly once, in the shared encryption-keys module" + ); + } + } + #[test] fn ssec_read_headers_are_sensitive() { let headers = super::build_ssec_read_headers( @@ -4869,7 +4826,7 @@ mod tests { (MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "default".to_string()), ]); - let normalized = normalize_managed_metadata(&metadata); + let normalized = normalize_managed_metadata(&metadata, Some(recode_minio_kms_context)); assert_eq!( normalized.get(INTERNAL_ENCRYPTION_KEY_HEADER),