mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-11 23:56:53 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user