fix(replication): rebuild SSE metadata boundary for encrypted objects (#5872)

Groundwork for encrypted-object replication (backlog#1783, PR-A of 3):

- classify_replication_source_encryption: accept the AES256 marker that
  every stored SSE-C object carries; the SseC arm was unreachable.
- Fail closed on sealed material without an SSE marker (MinIO-written
  objects) instead of replicating ciphertext as plaintext.
- Replace the dead VALID_SSE_REPLICATION_HEADERS table with a transport
  map keyed by the metadata keys the SSE writer actually persists, shared
  via the new rustfs_utils::http::object_encryption_keys module.
- Structurally strip all encryption metadata from outbound replication
  (x-rustfs-encryption-* envelopes previously passed the filters).
- Skip decrypt_checksums for encrypted objects at the boundary so its
  is_multipart=false (a response-path contract) cannot misroute
  encrypted multipart objects once managed replication opens.
- Redact X-Rustfs-Replication-* SSE transport values in FileInfo Debug.

A reconciliation test pins that every key encryption_material_to_metadata
produces is either transport-mapped or stripped. All four SSE replication
e2e contracts still assert FAILED unchanged.
This commit is contained in:
唐小鸭
2026-08-09 11:05:11 +08:00
committed by GitHub
parent 9996d567d9
commit 10c7476883
7 changed files with 506 additions and 68 deletions
@@ -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<String, String>) ->
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(),
+32 -1
View File
@@ -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")
+29 -1
View File
@@ -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<String, String>);
@@ -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!("<redacted {} bytes>", sealed_key.len())));
assert!(dump.contains("text/plain"));
}
#[test]
fn debug_elides_inline_data_bytes() {
let fi = FileInfo {
+2 -2
View File
@@ -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";
+2
View File
@@ -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::*;
@@ -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"
);
}
}
}
+96 -22
View File
@@ -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<String, String>)
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<String, String>) -> HashMap<Str
normalized.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), value.clone());
}
if !normalized.contains_key("x-rustfs-encryption-context")
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::<HashMap<String, String>>(&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";