diff --git a/crates/ecstore/src/bucket/replication/replication_target_boundary.rs b/crates/ecstore/src/bucket/replication/replication_target_boundary.rs index efdecdeab..993e97d7a 100644 --- a/crates/ecstore/src/bucket/replication/replication_target_boundary.rs +++ b/crates/ecstore/src/bucket/replication/replication_target_boundary.rs @@ -28,7 +28,7 @@ use rustfs_utils::http::{ AMZ_STORAGE_CLASS, AMZ_TAG_COUNT, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, HeaderExt as _, SUFFIX_OBJECTLOCK_LEGALHOLD_TIMESTAMP, SUFFIX_OBJECTLOCK_RETENTION_TIMESTAMP, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, SUFFIX_REPLICATION_SSEC_CRC, SUFFIX_TAGGING_TIMESTAMP, get_str, insert_header_map, - is_internal_key, + is_internal_key, is_object_encryption_marker, is_replication_stripped_encryption_key, ssec_replication_transport_header, }; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; @@ -62,23 +62,6 @@ static STANDARD_HEADERS: &[&str] = &[ AMZ_SERVER_SIDE_ENCRYPTION, ]; -static VALID_SSE_REPLICATION_HEADERS: &[(&str, &str)] = &[ - ( - "X-Rustfs-Internal-Server-Side-Encryption-Sealed-Key", - "X-Rustfs-Replication-Server-Side-Encryption-Sealed-Key", - ), - ( - "X-Rustfs-Internal-Server-Side-Encryption-Seal-Algorithm", - "X-Rustfs-Replication-Server-Side-Encryption-Seal-Algorithm", - ), - ( - "X-Rustfs-Internal-Server-Side-Encryption-Iv", - "X-Rustfs-Replication-Server-Side-Encryption-Iv", - ), - ("X-Rustfs-Internal-Encrypted-Multipart", "X-Rustfs-Replication-Encrypted-Multipart"), - ("X-Rustfs-Internal-Actual-Object-Size", "X-Rustfs-Replication-Actual-Object-Size"), -]; - const ERR_REPLICATION_MANAGED_SSE_UNSUPPORTED: &str = "managed SSE replication requires target encryption support"; const ERR_REPLICATION_ENCRYPTION_METADATA_UNSUPPORTED: &str = "replication source contains unsupported encryption metadata"; @@ -105,15 +88,29 @@ fn classify_replication_source_encryption(metadata: &HashMap) -> let kms_context = metadata_value(metadata, AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT); if is_ssec { - return if sse.is_some() || kms_key_id.is_some() || kms_context.is_some() { - ReplicationSourceEncryption::Unsupported - } else { + // Stored SSE-C objects always carry x-amz-server-side-encryption=AES256 + // alongside the customer-algorithm key; only KMS evidence marks a + // mixed, unsupported state. + let sse_compatible = sse.map(str::trim).is_none_or(|value| value.eq_ignore_ascii_case("AES256")); + return if sse_compatible && kms_key_id.is_none() && kms_context.is_none() { ReplicationSourceEncryption::SseC + } else { + ReplicationSourceEncryption::Unsupported }; } match sse.map(str::trim) { - None if kms_key_id.is_none() && kms_context.is_none() => ReplicationSourceEncryption::Plaintext, + None if kms_key_id.is_none() && kms_context.is_none() => { + // Sealed material without any recognizable SSE marker (e.g. an + // object written by MinIO, which does not persist the x-amz SSE + // intent header) must fail closed: replicating it as plaintext + // ships ciphertext the target can never decrypt. + if metadata.keys().any(|key| is_object_encryption_marker(key)) { + ReplicationSourceEncryption::Unsupported + } else { + ReplicationSourceEncryption::Plaintext + } + } Some(value) if value.eq_ignore_ascii_case("AES256") && kms_key_id.is_none() && kms_context.is_none() => { ReplicationSourceEncryption::SseS3 } @@ -174,17 +171,23 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo) } for (key, value) in object_info.user_defined.iter() { - let has_valid_sse_header = valid_sse_replication_header(key).is_some(); - - if (!is_ssec || !has_valid_sse_header) && (is_internal_key(key) || is_standard_header(key)) { + if is_ssec && let Some(transport_header) = ssec_replication_transport_header(key) { + meta.insert(transport_header.to_string(), value.to_string()); continue; } - if let Some(replication_header) = valid_sse_replication_header(key) { - meta.insert(replication_header.to_string(), value.to_string()); - } else { - meta.insert(key.to_string(), value.to_string()); + // Encryption metadata that is not remapped for SSE-C passthrough must + // never leave the source site: envelopes and intent headers are only + // meaningful to the source KMS. + if is_replication_stripped_encryption_key(key) { + continue; } + + if is_internal_key(key) || is_standard_header(key) { + continue; + } + + meta.insert(key.to_string(), value.to_string()); } let mut is_multipart = object_info.is_multipart(); @@ -195,6 +198,11 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo) if is_ssec { let encoded = BASE64_STANDARD.encode(checksum_data); insert_header_map(&mut meta, SUFFIX_REPLICATION_SSEC_CRC, encoded); + } else if object_info.is_encrypted() { + // Encrypted checksums cannot be exposed as plaintext headers, and + // decrypt_checksums reports is_multipart=false for them (a value + // the response path relies on). Keep the object's own multipart + // flag so encrypted objects stay on the multipart route. } else { let (checksum_meta, is_mp) = object_info.decrypt_checksums(0, &HeaderMap::new())?; is_multipart = is_mp; @@ -413,20 +421,14 @@ fn is_standard_header(key: &str) -> bool { STANDARD_HEADERS.iter().any(|header| header.eq_ignore_ascii_case(key)) } -fn valid_sse_replication_header(key: &str) -> Option<&str> { - VALID_SSE_REPLICATION_HEADERS - .iter() - .find(|(internal, _)| key.eq_ignore_ascii_case(internal)) - .map(|(_, replication)| *replication) -} - #[cfg(test)] mod tests { use super::*; use aws_smithy_types::DateTime; use rustfs_replication::content_matches_by_etag; use rustfs_utils::http::{ - SSEC_ALGORITHM_HEADER, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, SUFFIX_REPLICATION_SSEC_CRC, get_header_map, + SSEC_ALGORITHM_HEADER, SSEC_KEY_MD5_HEADER, SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE, SUFFIX_REPLICATION_SSEC_CRC, + get_header_map, }; use std::sync::Arc; use time::Duration; @@ -583,11 +585,29 @@ mod tests { #[test] fn replication_put_options_filter_and_map_metadata() { + use rustfs_utils::http::object_encryption_keys::{ + INTERNAL_ENCRYPTION_IV_HEADER, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER, MINIO_INTERNAL_ENCRYPTION_IV_HEADER, + MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, + REPLICATION_ENCRYPTED_MULTIPART_HEADER, REPLICATION_ENCRYPTION_IV_HEADER, REPLICATION_SSE_IV_HEADER, + REPLICATION_SSE_SEAL_ALGORITHM_HEADER, REPLICATION_SSE_SEALED_KEY_HEADER, REPLICATION_SSEC_ALGORITHM_HEADER, + REPLICATION_SSEC_KEY_MD5_HEADER, REPLICATION_SSEC_ORIGINAL_SIZE_HEADER, SSEC_ORIGINAL_SIZE_HEADER, + }; + + // The stored shape of a real SSE-C object: SSE marker plus customer + // material, per encryption_material_to_metadata. Every transport-table + // source key is present so each mapping is pinned individually. let mut metadata = HashMap::new(); metadata.insert(CONTENT_TYPE.to_string(), "text/plain".to_string()); metadata.insert("x-user-meta".to_string(), "value".to_string()); + metadata.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "AES256".to_string()); metadata.insert(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string()); - metadata.insert("X-Rustfs-Internal-Server-Side-Encryption-Sealed-Key".to_string(), "sealed".to_string()); + metadata.insert(SSEC_KEY_MD5_HEADER.to_string(), "md5-value".to_string()); + metadata.insert(SSEC_ORIGINAL_SIZE_HEADER.to_string(), "1024".to_string()); + metadata.insert(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "iv-direct".to_string()); + metadata.insert(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "iv-minio".to_string()); + metadata.insert(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), "DAREv2-HMAC-SHA256".to_string()); + metadata.insert(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER.to_string(), "sealed".to_string()); + metadata.insert(MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER.to_string(), "true".to_string()); let object_info = ObjectInfo { user_defined: Arc::new(metadata), @@ -605,12 +625,40 @@ mod tests { assert!(!is_multipart); assert_eq!(options.user_metadata.get("x-user-meta"), Some(&"value".to_string())); assert!(!options.user_metadata.contains_key(CONTENT_TYPE)); + + // Every stored SSE-C material key is remapped onto its transport name. + assert_eq!(options.user_metadata.get(REPLICATION_SSEC_ALGORITHM_HEADER), Some(&"AES256".to_string())); + assert_eq!(options.user_metadata.get(REPLICATION_SSEC_KEY_MD5_HEADER), Some(&"md5-value".to_string())); assert_eq!( - options - .user_metadata - .get("X-Rustfs-Replication-Server-Side-Encryption-Sealed-Key"), - Some(&"sealed".to_string()) + options.user_metadata.get(REPLICATION_SSEC_ORIGINAL_SIZE_HEADER), + Some(&"1024".to_string()) ); + assert_eq!( + options.user_metadata.get(REPLICATION_ENCRYPTION_IV_HEADER), + Some(&"iv-direct".to_string()) + ); + assert_eq!(options.user_metadata.get(REPLICATION_SSE_IV_HEADER), Some(&"iv-minio".to_string())); + assert_eq!( + options.user_metadata.get(REPLICATION_SSE_SEAL_ALGORITHM_HEADER), + Some(&"DAREv2-HMAC-SHA256".to_string()) + ); + assert_eq!(options.user_metadata.get(REPLICATION_SSE_SEALED_KEY_HEADER), Some(&"sealed".to_string())); + assert_eq!( + options.user_metadata.get(REPLICATION_ENCRYPTED_MULTIPART_HEADER), + Some(&"true".to_string()) + ); + + // The stored keys themselves and the SSE intent header must not leave + // the source verbatim. + assert!(!options.user_metadata.contains_key(AMZ_SERVER_SIDE_ENCRYPTION)); + assert!(!options.user_metadata.contains_key(SSEC_ALGORITHM_HEADER)); + assert!(!options.user_metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER)); + assert!( + !options + .user_metadata + .contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER) + ); + assert_eq!(options.content_type, "text/plain"); assert_eq!(options.content_encoding, "gzip"); assert_eq!(options.user_tags.get("env"), Some(&"prod".to_string())); @@ -620,6 +668,68 @@ mod tests { assert!(options.internal.replication_request); } + #[test] + fn replication_put_options_strip_encryption_metadata_from_plaintext_objects() { + use rustfs_utils::http::object_encryption_keys::{INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER, SSEC_ORIGINAL_SIZE_HEADER}; + + // Migration leftovers: original-size metadata is not an encryption + // marker (older plaintext objects can retain it), so the object still + // classifies as plaintext — but the keys must be stripped, never + // forwarded as plain user metadata (backlog#1783 D2). The SSE-C + // original-size key is also a transport-table source key, so this + // doubles as the guard for the is_ssec gate: without SSE-C + // classification it must be stripped, not remapped. + let metadata = HashMap::from([ + ("x-user-meta".to_string(), "value".to_string()), + (INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER.to_string(), "1024".to_string()), + (SSEC_ORIGINAL_SIZE_HEADER.to_string(), "1024".to_string()), + ]); + let object_info = ObjectInfo { + user_defined: Arc::new(metadata), + ..Default::default() + }; + + let (options, _) = replication_put_object_options("", &object_info).expect("build put options"); + + assert_eq!(options.user_metadata.get("x-user-meta"), Some(&"value".to_string())); + assert!(!options.user_metadata.contains_key(INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER)); + assert!(!options.user_metadata.contains_key(SSEC_ORIGINAL_SIZE_HEADER)); + assert!( + !options + .user_metadata + .keys() + .any(|key| key.to_ascii_lowercase().starts_with("x-rustfs-replication-")), + "non-SSE-C objects must never emit SSE replication transport keys" + ); + } + + #[test] + fn replication_put_options_fail_closed_on_sealed_material_without_sse_marker() { + use rustfs_utils::http::object_encryption_keys::{ + INTERNAL_ENCRYPTION_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, + }; + + // Sealed material without a recognizable SSE marker (MinIO-written + // objects, or corrupted metadata) must fail closed instead of + // replicating ciphertext as a plaintext object. + for sealed_key in [ + INTERNAL_ENCRYPTION_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, + ] { + let object_info = ObjectInfo { + user_defined: Arc::new(HashMap::from([(sealed_key.to_string(), "sealed-envelope".to_string())])), + ..Default::default() + }; + + let err = match replication_put_object_options("", &object_info) { + Ok(_) => panic!("sealed material without an SSE marker must fail closed ({sealed_key})"), + Err(err) => err, + }; + assert!(err.to_string().contains(ERR_REPLICATION_ENCRYPTION_METADATA_UNSUPPORTED)); + assert!(!err.to_string().contains("sealed-envelope")); + } + } + #[test] fn replication_put_options_adds_ssec_checksum_metadata() { let metadata = HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]); @@ -658,6 +768,30 @@ mod tests { classify_replication_source_encryption(&HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())])), ReplicationSourceEncryption::SseC ); + // Real stored SSE-C objects carry the AES256 SSE marker alongside the + // customer algorithm (encryption_material_to_metadata writes both). + assert_eq!( + classify_replication_source_encryption(&HashMap::from([ + (SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string()), + ("x-amz-server-side-encryption".to_string(), "AES256".to_string()), + ])), + ReplicationSourceEncryption::SseC + ); + // SSE-C material mixed with KMS evidence stays unsupported. + assert_eq!( + classify_replication_source_encryption(&HashMap::from([ + (SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string()), + (AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID.to_string(), "key-1".to_string()), + ])), + ReplicationSourceEncryption::Unsupported + ); + assert_eq!( + classify_replication_source_encryption(&HashMap::from([ + (SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string()), + ("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()), + ])), + ReplicationSourceEncryption::Unsupported + ); assert_eq!( classify_replication_source_encryption(&HashMap::from([( "x-amz-server-side-encryption".to_string(), diff --git a/crates/ecstore/src/object_api/types.rs b/crates/ecstore/src/object_api/types.rs index 553fc0c29..108888e5d 100644 --- a/crates/ecstore/src/object_api/types.rs +++ b/crates/ecstore/src/object_api/types.rs @@ -1041,7 +1041,10 @@ impl ObjectInfo { if let Some(data) = &self.checksum { if self.is_encrypted() { // Object-level encrypted checksum bytes require SSE decrypt material, - // so do not expose them as plaintext checksum headers here. + // so do not expose them as plaintext checksum headers here. The + // `false` multipart flag feeds the response-path COMPOSITE + // fallback; callers that need accurate multipart routing must + // consult `is_multipart()` instead of this value. return Ok((HashMap::new(), false)); } @@ -1712,6 +1715,34 @@ mod tests { assert!(checksums.is_empty()); } + #[test] + fn decrypt_checksums_keeps_encrypted_multipart_flag_false_for_response_paths() { + let checksum = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::CRC32, b"encrypted-object") + .expect("test checksum should be valid"); + let info = ObjectInfo { + checksum: Some(checksum.to_bytes(&[])), + // Multipart ETag shape: md5-of-md5s with a part-count suffix. + etag: Some("0123456789abcdef0123456789abcdef-3".to_string()), + user_defined: Arc::new(HashMap::from([( + rustfs_utils::http::headers::AMZ_SERVER_SIDE_ENCRYPTION.to_string(), + "AES256".to_string(), + )])), + ..Default::default() + }; + + let (checksums, is_multipart) = info + .decrypt_checksums(0, &HeaderMap::new()) + .expect("encrypted checksum should fail closed"); + + // The response path infers COMPOSITE from is_multipart=true when the + // checksum type is unreadable, so encrypted objects must keep the + // flag false here even when the object itself is multipart. Callers + // that need routing (replication) consult is_multipart() directly. + assert!(checksums.is_empty()); + assert!(!is_multipart); + assert!(info.is_multipart()); + } + #[test] fn decrypt_checksums_keeps_encrypted_part_checksum_metadata() { let checksum = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::CRC32, b"encrypted-object") diff --git a/crates/filemeta/src/fileinfo.rs b/crates/filemeta/src/fileinfo.rs index 07f9aebc8..10be680f7 100644 --- a/crates/filemeta/src/fileinfo.rs +++ b/crates/filemeta/src/fileinfo.rs @@ -278,7 +278,11 @@ pub struct FileInfo { fn is_sensitive_metadata_key(key: &str) -> bool { // `is_encryption_metadata_key` covers the x-minio-internal- SSE prefix but not // its x-rustfs-internal- twin, which the dual-key invariant writes alongside it. - is_encryption_metadata_key(key) || starts_with_ignore_ascii_case(key, "x-rustfs-internal-server-side-encryption-") + is_encryption_metadata_key(key) + || starts_with_ignore_ascii_case(key, "x-rustfs-internal-server-side-encryption-") + || rustfs_utils::http::REPLICATION_SSE_TRANSPORT_PREFIXES + .iter() + .any(|prefix| starts_with_ignore_ascii_case(key, prefix)) } struct RedactedMetadata<'a>(&'a HashMap); @@ -2559,6 +2563,30 @@ mod tests { assert!(dump.contains("text/plain")); } + #[test] + fn debug_redacts_replication_sse_transport_metadata_values() { + let sealed_key = "IAAfANqt7wIJfVSgFAG3f5S6HuC2eyM5DdJlx7RSJKw2ZakSb3d5"; + let mut fi = FileInfo::default(); + for key in [ + "X-Rustfs-Replication-Server-Side-Encryption-Sealed-Key", + "X-Rustfs-Replication-Server-Side-Encryption-Iv", + "X-Rustfs-Replication-Encryption-Iv", + "X-Rustfs-Replication-Ssec-Key-Md5", + ] { + fi.metadata.insert(key.to_string(), sealed_key.to_string()); + } + fi.metadata.insert("content-type".to_string(), "text/plain".to_string()); + + let dump = format!("{fi:?}"); + assert!( + !dump.contains(sealed_key), + "replication SSE transport value leaked into Debug output: {dump}" + ); + assert!(dump.contains("X-Rustfs-Replication-Server-Side-Encryption-Sealed-Key")); + assert!(dump.contains(&format!("", sealed_key.len()))); + assert!(dump.contains("text/plain")); + } + #[test] fn debug_elides_inline_data_bytes() { let fi = FileInfo { diff --git a/crates/utils/src/http/header_compat.rs b/crates/utils/src/http/header_compat.rs index 822450e37..3c1d294d1 100644 --- a/crates/utils/src/http/header_compat.rs +++ b/crates/utils/src/http/header_compat.rs @@ -27,9 +27,9 @@ const MINIO_ENCRYPTION_PREFIX: &str = "x-minio-encryption-"; const RUSTFS_ENCRYPTION_PREFIX: &str = "x-rustfs-encryption-"; const MINIO_INTERNAL_ENCRYPTION_PREFIX: &str = "x-minio-internal-server-side-encryption-"; const MINIO_INTERNAL_ENCRYPTED_MULTIPART: &str = "x-minio-internal-encrypted-multipart"; -const RUSTFS_ENCRYPTION_ORIGINAL_SIZE: &str = "x-rustfs-encryption-original-size"; +const RUSTFS_ENCRYPTION_ORIGINAL_SIZE: &str = super::object_encryption_keys::INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER; const MINIO_ENCRYPTION_ORIGINAL_SIZE: &str = "x-minio-encryption-original-size"; -const SSEC_ORIGINAL_SIZE: &str = "x-amz-server-side-encryption-customer-original-size"; +const SSEC_ORIGINAL_SIZE: &str = super::object_encryption_keys::SSEC_ORIGINAL_SIZE_HEADER; // Suffix constants (part after x-rustfs- or x-minio-). Use with get_header/insert_header. pub const SUFFIX_FORCE_DELETE: &str = "force-delete"; diff --git a/crates/utils/src/http/mod.rs b/crates/utils/src/http/mod.rs index ab4e966bc..bf77e8358 100644 --- a/crates/utils/src/http/mod.rs +++ b/crates/utils/src/http/mod.rs @@ -16,7 +16,9 @@ pub mod header_compat; pub mod headers; pub mod ip; pub mod metadata_compat; +pub mod object_encryption_keys; pub use header_compat::*; pub use headers::*; pub use ip::*; pub use metadata_compat::*; +pub use object_encryption_keys::*; diff --git a/crates/utils/src/http/object_encryption_keys.rs b/crates/utils/src/http/object_encryption_keys.rs new file mode 100644 index 000000000..c08459c7a --- /dev/null +++ b/crates/utils/src/http/object_encryption_keys.rs @@ -0,0 +1,169 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Canonical metadata keys persisted for encrypted objects and the replication +//! transport mapping that carries SSE-C material between sites. +//! +//! The stored-key constants are the single source of truth shared by the SSE +//! writer (`rustfs::storage::sse`), the replication boundary (`rustfs_ecstore`), +//! and log redaction (`rustfs_filemeta`). Keys listed in +//! [`SSEC_REPLICATION_TRANSPORT_HEADERS`] are renamed onto the wire for SSE-C +//! ciphertext passthrough; every other encryption key must be stripped from +//! outbound replication metadata via [`is_replication_stripped_encryption_key`]. + +use super::headers::{AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5}; + +pub const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id"; +pub const INTERNAL_ENCRYPTION_KEY_HEADER: &str = "x-rustfs-encryption-key"; +pub const INTERNAL_ENCRYPTION_IV_HEADER: &str = "x-rustfs-encryption-iv"; +pub const INTERNAL_ENCRYPTION_ALGORITHM_HEADER: &str = "x-rustfs-encryption-algorithm"; +pub const INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER: &str = "x-rustfs-encryption-original-size"; +pub const INTERNAL_ENCRYPTION_CONTEXT_HEADER: &str = "x-rustfs-encryption-context"; +pub const INTERNAL_ENCRYPTION_TAG_HEADER: &str = "x-rustfs-encryption-tag"; +pub const SSEC_ORIGINAL_SIZE_HEADER: &str = "x-amz-server-side-encryption-customer-original-size"; +pub const MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER: &str = "X-Minio-Internal-Encrypted-Multipart"; +pub const MINIO_INTERNAL_ENCRYPTION_IV_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Iv"; +pub const MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm"; +pub const MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Sealed-Key"; +pub const MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key"; +pub const MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Kms-Sealed-Key"; +pub const MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id"; +pub const MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key"; +pub const MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Context"; + +pub const REPLICATION_SSEC_ALGORITHM_HEADER: &str = "X-Rustfs-Replication-Ssec-Algorithm"; +pub const REPLICATION_SSEC_KEY_MD5_HEADER: &str = "X-Rustfs-Replication-Ssec-Key-Md5"; +pub const REPLICATION_SSEC_ORIGINAL_SIZE_HEADER: &str = "X-Rustfs-Replication-Ssec-Original-Size"; +pub const REPLICATION_ENCRYPTION_IV_HEADER: &str = "X-Rustfs-Replication-Encryption-Iv"; +pub const REPLICATION_SSE_IV_HEADER: &str = "X-Rustfs-Replication-Server-Side-Encryption-Iv"; +pub const REPLICATION_SSE_SEAL_ALGORITHM_HEADER: &str = "X-Rustfs-Replication-Server-Side-Encryption-Seal-Algorithm"; +pub const REPLICATION_SSE_SEALED_KEY_HEADER: &str = "X-Rustfs-Replication-Server-Side-Encryption-Sealed-Key"; +pub const REPLICATION_ENCRYPTED_MULTIPART_HEADER: &str = "X-Rustfs-Replication-Encrypted-Multipart"; + +/// Stored SSE-C metadata keys and the wire names they replicate under. +/// +/// Source keys must match what `encryption_material_to_metadata` persists; the +/// reconciliation test in `rustfs::storage::sse` pins that correspondence. +pub const SSEC_REPLICATION_TRANSPORT_HEADERS: &[(&str, &str)] = &[ + (AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, REPLICATION_SSEC_ALGORITHM_HEADER), + (AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5, REPLICATION_SSEC_KEY_MD5_HEADER), + (SSEC_ORIGINAL_SIZE_HEADER, REPLICATION_SSEC_ORIGINAL_SIZE_HEADER), + (INTERNAL_ENCRYPTION_IV_HEADER, REPLICATION_ENCRYPTION_IV_HEADER), + (MINIO_INTERNAL_ENCRYPTION_IV_HEADER, REPLICATION_SSE_IV_HEADER), + (MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER, REPLICATION_SSE_SEAL_ALGORITHM_HEADER), + (MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, REPLICATION_SSE_SEALED_KEY_HEADER), + (MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, REPLICATION_ENCRYPTED_MULTIPART_HEADER), +]; + +/// Prefixes of replication SSE transport keys whose values carry encryption +/// material and must never reach logs. Consumed by `rustfs_filemeta` redaction. +pub const REPLICATION_SSE_TRANSPORT_PREFIXES: &[&str] = &[ + "x-rustfs-replication-server-side-encryption-", + "x-rustfs-replication-encryption-", + "x-rustfs-replication-ssec-", +]; + +/// Maps a stored SSE-C metadata key to its replication transport name. +pub fn ssec_replication_transport_header(stored_key: &str) -> Option<&'static str> { + SSEC_REPLICATION_TRANSPORT_HEADERS + .iter() + .find(|(stored, _)| stored.eq_ignore_ascii_case(stored_key)) + .map(|(_, transport)| *transport) +} + +/// Returns true for metadata keys that must never leave the source site as +/// plain replication metadata: encryption envelopes, SSE intent headers, and +/// SSE-C material. SSE-C passthrough re-adds its keys through the transport +/// mapping instead. +pub fn is_replication_stripped_encryption_key(key: &str) -> bool { + // The dual-key invariant writes an x-rustfs-internal- twin next to every + // x-minio-internal- SSE key; cover it here so this predicate is safe to + // use standalone, without an is_internal_key backstop. + super::is_encryption_metadata_key(key) + || super::is_sse_header(key) + || key.eq_ignore_ascii_case(SSEC_ORIGINAL_SIZE_HEADER) + || super::starts_with_ignore_ascii_case(key, "x-rustfs-internal-server-side-encryption-") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transport_lookup_is_case_insensitive() { + assert_eq!( + ssec_replication_transport_header("X-AMZ-SERVER-SIDE-ENCRYPTION-CUSTOMER-ALGORITHM"), + Some(REPLICATION_SSEC_ALGORITHM_HEADER) + ); + assert_eq!( + ssec_replication_transport_header("x-minio-internal-server-side-encryption-sealed-key"), + Some(REPLICATION_SSE_SEALED_KEY_HEADER) + ); + assert_eq!(ssec_replication_transport_header("x-rustfs-encryption-key"), None); + } + + #[test] + fn stripped_predicate_covers_envelopes_intents_and_ssec_material() { + // Managed-SSE envelope material (x-rustfs-encryption-* prefix). + assert!(is_replication_stripped_encryption_key(INTERNAL_ENCRYPTION_KEY_HEADER)); + assert!(is_replication_stripped_encryption_key(INTERNAL_ENCRYPTION_KEY_ID_HEADER)); + assert!(is_replication_stripped_encryption_key(INTERNAL_ENCRYPTION_CONTEXT_HEADER)); + // MinIO-internal sealed material, including the managed rio-v2 keys + // that only a non-default feature build ever writes — pinning them + // here keeps the default CI honest about the full key population. + assert!(is_replication_stripped_encryption_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)); + assert!(is_replication_stripped_encryption_key(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)); + assert!(is_replication_stripped_encryption_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)); + assert!(is_replication_stripped_encryption_key(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)); + assert!(is_replication_stripped_encryption_key(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)); + assert!(is_replication_stripped_encryption_key(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER)); + assert!(is_replication_stripped_encryption_key(MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER)); + // The dual-key invariant's rustfs-internal twin must be covered + // standalone, without relying on an is_internal_key backstop. + assert!(is_replication_stripped_encryption_key( + "x-rustfs-internal-server-side-encryption-sealed-key" + )); + // SSE intent headers, including the KMS key id. + assert!(is_replication_stripped_encryption_key("x-amz-server-side-encryption")); + assert!(is_replication_stripped_encryption_key("x-amz-server-side-encryption-aws-kms-key-id")); + assert!(is_replication_stripped_encryption_key(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM)); + // is_sse_header does not cover the SSE-C original-size key; the + // predicate must add it explicitly. + assert!(is_replication_stripped_encryption_key(SSEC_ORIGINAL_SIZE_HEADER)); + assert!(is_replication_stripped_encryption_key( + "X-Amz-Server-Side-Encryption-Customer-Original-Size" + )); + // Ordinary user metadata passes through. + assert!(!is_replication_stripped_encryption_key("x-amz-meta-app")); + assert!(!is_replication_stripped_encryption_key("content-type")); + } + + #[test] + fn transport_prefixes_cover_every_transport_value_key() { + // Every transport key that carries material must match a redaction + // prefix; the multipart flag is a boolean marker and is exempt. + for (_, transport) in SSEC_REPLICATION_TRANSPORT_HEADERS { + if transport.eq_ignore_ascii_case(REPLICATION_ENCRYPTED_MULTIPART_HEADER) { + continue; + } + let lower = transport.to_lowercase(); + assert!( + REPLICATION_SSE_TRANSPORT_PREFIXES + .iter() + .any(|prefix| lower.starts_with(prefix)), + "transport key {transport} is not covered by a redaction prefix" + ); + } + } +} diff --git a/rustfs/src/storage/sse.rs b/rustfs/src/storage/sse.rs index 78088ed07..181db44ab 100644 --- a/rustfs/src/storage/sse.rs +++ b/rustfs/src/storage/sse.rs @@ -112,21 +112,15 @@ use tracing::{debug, error}; const LOG_COMPONENT_STORAGE: &str = "storage"; const LOG_SUBSYSTEM_SSE: &str = "sse"; -const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id"; -const INTERNAL_ENCRYPTION_KEY_HEADER: &str = "x-rustfs-encryption-key"; -const INTERNAL_ENCRYPTION_IV_HEADER: &str = "x-rustfs-encryption-iv"; -const INTERNAL_ENCRYPTION_ALGORITHM_HEADER: &str = "x-rustfs-encryption-algorithm"; -const INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER: &str = "x-rustfs-encryption-original-size"; -const SSEC_ORIGINAL_SIZE_HEADER: &str = "x-amz-server-side-encryption-customer-original-size"; -const MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER: &str = "X-Minio-Internal-Encrypted-Multipart"; -const MINIO_INTERNAL_ENCRYPTION_IV_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Iv"; -const MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm"; -const MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Sealed-Key"; -const MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key"; -const MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Kms-Sealed-Key"; -const MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id"; -const MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key"; -const MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Context"; +use rustfs_utils::http::object_encryption_keys::{ + INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_CONTEXT_HEADER, INTERNAL_ENCRYPTION_IV_HEADER, + INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER, INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER, + INTERNAL_ENCRYPTION_TAG_HEADER, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER, MINIO_INTERNAL_ENCRYPTION_IV_HEADER, + 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, +}; #[cfg(feature = "rio-v2")] const MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM: &str = "DAREv2-HMAC-SHA256"; #[cfg(feature = "rio-v2")] @@ -1378,8 +1372,8 @@ fn normalize_encryption_metadata_case( INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER, - "x-rustfs-encryption-context", - "x-rustfs-encryption-tag", + INTERNAL_ENCRYPTION_CONTEXT_HEADER, + INTERNAL_ENCRYPTION_TAG_HEADER, INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER, MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_IV_HEADER, @@ -1783,7 +1777,7 @@ pub fn encryption_material_to_metadata(material: &EncryptionMaterial) -> Result< && !kms_context.is_empty() { if let Ok(serialized) = serde_json::to_string(kms_context) { - metadata.insert("x-rustfs-encryption-context".to_string(), serialized); + metadata.insert(INTERNAL_ENCRYPTION_CONTEXT_HEADER.to_string(), serialized); } if matches!(material.sse_type, SSEType::SseKms) && let Ok(encoded) = encode_minio_kms_context(kms_context) @@ -3153,9 +3147,9 @@ pub fn strip_managed_encryption_metadata(metadata: &mut HashMap) INTERNAL_ENCRYPTION_KEY_ID_HEADER, INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER, - "x-rustfs-encryption-tag", + INTERNAL_ENCRYPTION_TAG_HEADER, INTERNAL_ENCRYPTION_KEY_HEADER, - "x-rustfs-encryption-context", + INTERNAL_ENCRYPTION_CONTEXT_HEADER, INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER, MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_IV_HEADER, @@ -3261,13 +3255,13 @@ fn normalize_managed_metadata(metadata: &HashMap) -> HashMap>(&decoded) && let Ok(encoded) = serde_json::to_string(&context) { - normalized.insert("x-rustfs-encryption-context".to_string(), encoded); + normalized.insert(INTERNAL_ENCRYPTION_CONTEXT_HEADER.to_string(), encoded); } normalized @@ -4262,6 +4256,86 @@ mod tests { assert_eq!(metadata.get(SSEC_ORIGINAL_SIZE_HEADER).map(String::as_str), Some("1024")); } + fn material_variant(sse_type: SSEType, key_kind: EncryptionKeyKind) -> EncryptionMaterial { + let (server_side_encryption, kms_key_id, encrypted_data_key, customer_key_md5, managed_kms_context) = match sse_type { + SSEType::SseC => ( + ServerSideEncryption::from_static(ServerSideEncryption::AES256), + None, + None, + Some("d41d8cd98f00b204e9800998ecf8427e".to_string()), + None, + ), + SSEType::SseS3 => ( + ServerSideEncryption::from_static(ServerSideEncryption::AES256), + None, + Some(vec![0u8; 32]), + None, + None, + ), + SSEType::SseKms => ( + ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS), + Some("kms-key-1".to_string()), + Some(vec![0u8; 32]), + None, + Some(HashMap::from([("app".to_string(), "test".to_string())])), + ), + }; + EncryptionMaterial { + sse_type, + server_side_encryption, + kms_key_id, + algorithm: SSECustomerAlgorithm::from("AES256".to_string()), + key_bytes: [0u8; 32], + base_nonce: [0u8; 12], + encrypted_data_key, + customer_key_md5, + original_size: Some(1024), + key_kind, + managed_kms_context, + managed_sealed_key: None, + } + } + + /// Reconciliation contract with the replication boundary (backlog#1783): + /// every metadata key this module persists must either be remapped by the + /// SSE-C transport table or be caught by the replication strip predicate. + /// A new stored key that is in neither turns this test red before it can + /// silently leak through outbound replication metadata. + #[test] + fn test_encryption_metadata_keys_reconcile_with_replication_transport_and_strip() { + use rustfs_utils::http::object_encryption_keys::{ + is_replication_stripped_encryption_key, ssec_replication_transport_header, + }; + + let variants = [ + (SSEType::SseC, EncryptionKeyKind::Direct), + (SSEType::SseS3, EncryptionKeyKind::Direct), + (SSEType::SseKms, EncryptionKeyKind::Direct), + ]; + + for (sse_type, key_kind) in variants { + let metadata = encryption_material_to_metadata(&material_variant(sse_type, key_kind)) + .expect("encryption material should serialize"); + assert!(!metadata.is_empty()); + + for key in metadata.keys() { + assert!( + ssec_replication_transport_header(key).is_some() || is_replication_stripped_encryption_key(key), + "stored key {key} ({sse_type:?}/{key_kind:?}) is neither transport-mapped nor stripped for replication" + ); + if !matches!(sse_type, SSEType::SseC) { + // Managed SSE never takes the transport mapping; every key + // must be structurally stripped so envelopes cannot leave + // the source site. + assert!( + is_replication_stripped_encryption_key(key), + "managed-SSE stored key {key} ({sse_type:?}) escapes the replication strip predicate" + ); + } + } + } + } + #[tokio::test] async fn test_sse_encryption_rejects_kms_key_with_invalid_algorithm() { let bucket = "test-bucket";