fix(sse): diagnose unresolvable encrypted metadata on reads (#6784)

This commit is contained in:
唐小鸭
2026-08-28 19:49:58 +08:00
committed by GitHub
parent 64705d7589
commit eb6b617ca2
6 changed files with 432 additions and 102 deletions
+361 -45
View File
@@ -490,7 +490,9 @@ impl EncryptionRequest<'_> {
"The provided encryption parameters did not match the multipart upload.",
));
}
verify_ssec_key_match(&validated.key_md5, stored_key_md5)
// Multipart session metadata is written by our own CreateMultipartUpload,
// which always records the MD5 — the MinIO no-stored-MD5 shape cannot occur.
verify_ssec_key_match(&validated.key_md5, stored_key_md5, false)
}
}
@@ -645,7 +647,7 @@ pub(crate) fn validate_sse_headers_for_read(metadata: &HashMap<String, String>,
|| headers.contains_key("x-amz-server-side-encryption-aws-kms-key-id")
|| headers.contains_key("x-amz-server-side-encryption-context");
let is_object_ssec = metadata.contains_key("x-amz-server-side-encryption-customer-algorithm");
let is_object_ssec = stored_ssec_metadata(metadata);
let is_object_sse = metadata.contains_key("x-amz-server-side-encryption");
if is_object_ssec {
@@ -687,6 +689,10 @@ pub(crate) fn map_get_object_reader_error(err: StorageError) -> ApiError {
let code = match resolution_error.kind() {
EncryptionResolutionErrorKind::InvalidRequest => S3ErrorCode::InvalidRequest,
EncryptionResolutionErrorKind::ServiceUnavailable => S3ErrorCode::ServiceUnavailable,
// A permanent property of the stored object, not a transient server
// fault: 5xx would invite client retry storms against an object
// this server can never decrypt.
EncryptionResolutionErrorKind::InvalidMetadata => S3ErrorCode::InvalidObjectState,
_ => S3ErrorCode::InternalError,
};
return ApiError {
@@ -1382,7 +1388,29 @@ impl ObjectEncryptionResolver for SseObjectEncryptionResolver {
.await
.map_err(map_encryption_resolution_error)?;
Ok(material.map(|material| ReadEncryptionMaterial {
let Some(material) = material else {
// Fail closed with a diagnosis instead of a bare `None`: the read
// plan classifies the object as encrypted from these same markers
// and would refuse to serve it anyway, but its generic error names
// neither the object's format nor what the operator can do about it.
if metadata
.keys()
.any(|key| rustfs_utils::http::is_object_encryption_marker(key))
{
#[cfg(not(feature = "rio-v2"))]
let message = "object is stored encrypted, but its decryption material could not be resolved: the \
encryption metadata is incomplete, or it is in the MinIO-compatible sealed format, \
which this server build has no read path for (reading it requires a build with the \
`rio-v2` feature)";
#[cfg(feature = "rio-v2")]
let message = "object is stored encrypted, but its decryption material could not be resolved: the \
encryption metadata is incomplete or in an unrecognized sealed format";
return Err(EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidMetadata, message));
}
return Ok(None);
};
Ok(Some(ReadEncryptionMaterial {
key_bytes: material.key_bytes,
mode: match material.key_kind {
EncryptionKeyKind::Direct => ReadEncryptionMode::Direct {
@@ -2111,10 +2139,7 @@ pub async fn sse_prepare_encryption(request: PrepareEncryptionRequest<'_>) -> Re
/// ```
pub async fn sse_decryption(request: DecryptionRequest<'_>) -> Result<Option<DecryptionMaterial>, ApiError> {
// Check for SSE-C encryption
if request
.metadata
.contains_key("x-amz-server-side-encryption-customer-algorithm")
{
if stored_ssec_metadata(request.metadata) {
let (key, key_md5) = match (request.sse_customer_key, request.sse_customer_key_md5) {
(Some(k), Some(md5)) => (k, md5),
_ => {
@@ -2127,7 +2152,7 @@ pub async fn sse_decryption(request: DecryptionRequest<'_>) -> Result<Option<Dec
// Verify that the provided key MD5 matches the stored MD5 for security
let stored_md5 = request.metadata.get("x-amz-server-side-encryption-customer-key-md5");
verify_ssec_key_match(key_md5, stored_md5)?;
verify_ssec_key_match(key_md5, stored_md5, minio_sealed_ssec_metadata(request.metadata))?;
let mut material = apply_ssec_decryption_material(request.bucket, request.key, request.metadata, key, key_md5).await?;
material.customer_key_md5 = Some(key_md5.clone());
@@ -2174,10 +2199,7 @@ pub struct SseReadResponseHeaders {
/// be established in this build — classify as `None`, exactly where
/// [`sse_decryption`] returned `None`.
pub async fn classify_sse_read_response(request: DecryptionRequest<'_>) -> Result<Option<SseReadResponseHeaders>, ApiError> {
if request
.metadata
.contains_key("x-amz-server-side-encryption-customer-algorithm")
{
if stored_ssec_metadata(request.metadata) {
let (key, key_md5) = match (request.sse_customer_key, request.sse_customer_key_md5) {
(Some(k), Some(md5)) => (k, md5),
_ => {
@@ -2189,7 +2211,7 @@ pub async fn classify_sse_read_response(request: DecryptionRequest<'_>) -> Resul
};
let stored_md5 = request.metadata.get("x-amz-server-side-encryption-customer-key-md5");
verify_ssec_key_match(key_md5, stored_md5)?;
verify_ssec_key_match(key_md5, stored_md5, minio_sealed_ssec_metadata(request.metadata))?;
let algorithm = request
.metadata
@@ -2229,11 +2251,13 @@ pub async fn classify_sse_read_response(request: DecryptionRequest<'_>) -> Resul
// Same key-id resolution chain as the unwrap path, so authorization and the
// response header name the same key.
let normalized_metadata = normalize_managed_metadata(request.metadata, Some(recode_minio_kms_context));
let kms_key_id = normalized_metadata
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
.or_else(|| request.metadata.get("x-amz-server-side-encryption-aws-kms-key-id"))
.cloned()
.unwrap_or_else(|| "default".to_string());
let kms_key_id = resolve_stored_kms_key_id(
normalized_metadata
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
.or_else(|| request.metadata.get("x-amz-server-side-encryption-aws-kms-key-id"))
.cloned(),
)
.await;
// Ahead of every other failure mode, so a denied caller learns nothing
// about the key beyond "not yours".
@@ -2619,6 +2643,27 @@ async fn apply_managed_decryption_material(
result
}
/// Key id an SSE-KMS read authorizes and reports when the stored metadata
/// carries none (legacy envelopes and MinIO-written objects predate the
/// explicit key-id headers).
///
/// The KMS service's configured default key is the key such envelopes were
/// actually wrapped under, so per-key authorization must target it — the bare
/// literal `default` would silently escape any policy narrowed to real key
/// ids. The literal remains only as the last resort when no service is
/// running, matching what the legacy write path recorded implicitly.
async fn resolve_stored_kms_key_id(stored: Option<String>) -> String {
if let Some(key_id) = stored {
return key_id;
}
if let Some(service) = runtime_sources::current_encryption_service().await
&& let Some(key_id) = service.get_default_key_id()
{
return key_id.clone();
}
"default".to_string()
}
async fn apply_managed_decryption_material_inner(
bucket: &str,
key: &str,
@@ -2664,11 +2709,13 @@ async fn apply_managed_decryption_material_inner(
let normalized_metadata = normalize_managed_metadata(metadata, Some(recode_minio_kms_context));
// Extract KMS key ID from metadata (optional, used for provider context)
let kms_key_id = normalized_metadata
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
.or_else(|| metadata.get("x-amz-server-side-encryption-aws-kms-key-id"))
.cloned()
.unwrap_or_else(|| "default".to_string());
let kms_key_id = resolve_stored_kms_key_id(
normalized_metadata
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
.or_else(|| metadata.get("x-amz-server-side-encryption-aws-kms-key-id"))
.cloned(),
)
.await;
// Ahead of every other failure mode below, so a denied caller learns nothing about the
// key beyond "not yours" — not whether it is disabled, pending deletion, or unreadable.
@@ -3764,7 +3811,27 @@ fn is_legacy_rustfs_managed_metadata(metadata: &HashMap<String, String>) -> bool
&& !metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)
}
#[cfg(feature = "rio-v2")]
/// True when the stored metadata marks the object as SSE-C protected.
///
/// RustFS persists the public `x-amz-server-side-encryption-customer-algorithm`
/// key on SSE-C writes. MinIO persists only its internal sealed-key slot and
/// synthesizes the public header onto responses (backlog#1638), so the slot is
/// the only durable evidence on a MinIO-written object. That shape is readable
/// solely through the rio-v2 sealed-object-key path, so it participates in
/// detection only in builds that can actually read it — default builds keep
/// the resolver's fail-closed diagnosis instead of demanding customer keys
/// they could not use.
fn stored_ssec_metadata(metadata: &HashMap<String, String>) -> bool {
metadata.contains_key("x-amz-server-side-encryption-customer-algorithm") || minio_sealed_ssec_metadata(metadata)
}
/// True when the object carries MinIO's SSE-C sealed-key slot — the shape that
/// stores no customer-key MD5 and proves the key through the AEAD unseal
/// instead.
fn minio_sealed_ssec_metadata(metadata: &HashMap<String, String>) -> bool {
cfg!(feature = "rio-v2") && metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)
}
#[cfg(feature = "rio-v2")]
/// Infer the managed SSE scheme from the MinIO sealed-key slot that is present.
///
@@ -3897,12 +3964,18 @@ pub fn generate_ssec_nonce(bucket: &str, key: &str) -> [u8; 12] {
///
/// Used during GetObject/HeadObject to ensure the client provided the correct key.
/// Returns 400 InvalidRequest on mismatch, consistent with AWS S3 behavior.
pub fn verify_ssec_key_match(provided_md5: &str, stored_md5: Option<&String>) -> Result<(), ApiError> {
pub fn verify_ssec_key_match(provided_md5: &str, stored_md5: Option<&String>, minio_sealed_ssec: bool) -> Result<(), ApiError> {
match stored_md5 {
Some(stored) if stored == provided_md5 => Ok(()),
Some(_) => Err(ssec_invalid_request(
"The provided encryption parameters did not match the ones used originally to encrypt the object.",
)),
// MinIO never persists the customer-key MD5, so this early check has
// nothing to compare against on a migrated object. The key itself is
// still proven: a wrong key fails the AEAD unseal of the sealed object
// key. The check exists only for the friendlier early error, so its
// absence is tolerated for exactly that shape and no other.
None if minio_sealed_ssec => Ok(()),
None => Err(ssec_invalid_request("Object has no stored SSE-C key metadata.")),
}
}
@@ -3924,8 +3997,7 @@ pub fn validate_ssec_for_read(
sse_customer_key: Option<&SSECustomerKey>,
sse_customer_key_md5: Option<&SSECustomerKeyMD5>,
) -> Result<(), ApiError> {
let stored_algorithm = metadata.get("x-amz-server-side-encryption-customer-algorithm");
if stored_algorithm.is_none() {
if !stored_ssec_metadata(metadata) {
return Ok(());
}
@@ -3941,7 +4013,10 @@ pub fn validate_ssec_for_read(
// Full param validation: decode key, verify 32 bytes, recompute MD5
// from actual key bytes and compare to the client-provided MD5 header.
let algorithm = stored_algorithm.cloned().unwrap_or_else(|| DEFAULT_SSE_ALGORITHM.to_string());
let algorithm = metadata
.get("x-amz-server-side-encryption-customer-algorithm")
.cloned()
.unwrap_or_else(|| DEFAULT_SSE_ALGORITHM.to_string());
let validated = validate_ssec_params(SsecParams {
algorithm,
key: key.to_string(),
@@ -3949,7 +4024,7 @@ pub fn validate_ssec_for_read(
})?;
let stored_md5 = metadata.get("x-amz-server-side-encryption-customer-key-md5");
verify_ssec_key_match(&validated.key_md5, stored_md5)
verify_ssec_key_match(&validated.key_md5, stored_md5, minio_sealed_ssec_metadata(metadata))
}
/// Build an `ApiError` with `InvalidRequest` (HTTP 400) for SSE-C related errors.
@@ -3966,21 +4041,21 @@ fn ssec_invalid_request(message: &str) -> ApiError {
mod tests {
use super::{
ApiError, DataKey, DecryptionRequest, EncryptionKeyKind, EncryptionMaterial, EncryptionRequest,
EncryptionResolutionErrorKind, INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER,
INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER, KmsAction, KmsKeyAuthorizer, KmsSseDekProvider,
KmsUnavailableError, 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, ObjectDekRewrapOutcome, ObjectEncryptionResolver,
PrepareEncryptionRequest, ReadEncryptionMode, ReadEncryptionRequest, SSEC_ORIGINAL_SIZE_HEADER, SSEType, SseDekProvider,
SseKmsPrincipal, SseObjectEncryptionResolver, SsecParams, StorageError, TestSseDekProvider,
EncryptionResolutionError, EncryptionResolutionErrorKind, INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
INTERNAL_ENCRYPTION_IV_HEADER, INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER, KmsAction,
KmsKeyAuthorizer, KmsSseDekProvider, KmsUnavailableError, 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, ObjectDekRewrapOutcome,
ObjectEncryptionResolver, PrepareEncryptionRequest, ReadEncryptionMode, ReadEncryptionRequest, SSEC_ORIGINAL_SIZE_HEADER,
SSEType, SseDekProvider, SseKmsPrincipal, SseObjectEncryptionResolver, SsecParams, StorageError, TestSseDekProvider,
apply_managed_decryption_material, apply_managed_encryption_material, authorize_sse_kms_object_read,
build_kms_request_context, classify_sse_read_response, encode_minio_kms_context, encryption_material_to_metadata,
extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers,
generate_ssec_nonce, is_managed_sse, kms_operation_error, map_get_object_reader_error, mark_encrypted_multipart_metadata,
md5_base64, normalize_managed_metadata, recode_minio_kms_context, reset_sse_dek_provider, resolve_effective_kms_key_id,
rewrap_object_encryption_metadata, sse_decryption, sse_encryption, sse_prepare_encryption,
resolve_stored_kms_key_id, rewrap_object_encryption_metadata, sse_decryption, sse_encryption, sse_prepare_encryption,
strip_managed_encryption_metadata, validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read,
validate_ssec_params, verify_ssec_key_match,
};
@@ -4243,6 +4318,79 @@ mod tests {
assert_eq!(error.kind(), EncryptionResolutionErrorKind::InvalidMetadata);
}
#[tokio::test]
async fn object_encryption_resolver_diagnoses_unresolvable_encrypted_metadata() {
// A MinIO-written SSE-C object carries only internal sealed-key slots:
// no public scheme header, no customer-algorithm metadata. The read
// must fail closed either way, but the failure differs by build: a
// default build has no read path for the shape and diagnoses that; a
// rio-v2 build recognizes it as SSE-C and asks for the customer key.
let metadata = HashMap::from([(
MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER.to_string(),
"c2VhbGVkLWtleQ==".to_string(),
)]);
let result = SseObjectEncryptionResolver
.resolve_read_material(ReadEncryptionRequest {
bucket: "bucket",
object: "object",
metadata: &metadata,
headers: &HeaderMap::new(),
})
.await;
let error = match result {
Err(error) => error,
Ok(_) => panic!("encryption markers without resolvable material must fail closed"),
};
#[cfg(not(feature = "rio-v2"))]
{
assert_eq!(error.kind(), EncryptionResolutionErrorKind::InvalidMetadata);
assert!(
error.to_string().contains("could not be resolved"),
"error must diagnose the unresolvable metadata, got: {error}"
);
}
#[cfg(feature = "rio-v2")]
{
assert_eq!(error.kind(), EncryptionResolutionErrorKind::InvalidRequest);
assert!(
error.to_string().contains("must be provided"),
"error must ask for the SSE-C parameters, got: {error}"
);
}
}
#[tokio::test]
async fn object_encryption_resolver_keeps_plaintext_objects_unresolved() {
let metadata = HashMap::from([("content-type".to_string(), "text/plain".to_string())]);
let material = SseObjectEncryptionResolver
.resolve_read_material(ReadEncryptionRequest {
bucket: "bucket",
object: "object",
metadata: &metadata,
headers: &HeaderMap::new(),
})
.await
.expect("plaintext metadata resolves without error");
assert!(material.is_none(), "plaintext objects carry no read material");
}
#[test]
fn map_get_object_reader_error_maps_unresolvable_metadata_to_invalid_object_state() {
let error = StorageError::Io(std::io::Error::other(EncryptionResolutionError::new(
EncryptionResolutionErrorKind::InvalidMetadata,
"object is marked encrypted, but no decryption material could be resolved from its metadata",
)));
let api_error = map_get_object_reader_error(error);
assert_eq!(api_error.code, S3ErrorCode::InvalidObjectState);
assert!(api_error.message.contains("no decryption material could be resolved"));
}
#[tokio::test]
async fn resolve_stored_kms_key_id_prefers_the_stored_id() {
assert_eq!(resolve_stored_kms_key_id(Some("app-key".to_string())).await, "app-key");
}
#[test]
fn normalize_encryption_metadata_case_accepts_lowercase_minio_internal_keys() {
let lowercase_key = MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_ascii_lowercase();
@@ -5511,7 +5659,7 @@ mod tests {
/// single leftover marker makes a plaintext destination report itself encrypted.
/// `object_api::readers` then takes its encrypted branch and demands read material,
/// but the material itself was stripped, so `sse_decryption` reports no encryption
/// and the read fails with "encrypted object metadata is incomplete" — a destination
/// and the read fails closed on unresolvable encryption metadata — a destination
/// that CopyObject wrote successfully becomes permanently unreadable.
#[test]
fn test_strip_managed_encryption_metadata_clears_encryption_markers() {
@@ -5837,6 +5985,163 @@ mod tests {
assert_eq!(resolved.key_bytes, material.key_bytes);
}
/// Rewrites rio-v2 SSE-C metadata into the shape MinIO actually persists:
/// only the internal sealed-key slot, IV, and seal algorithm — the public
/// scheme, customer-algorithm, and customer-key-MD5 keys are synthesized
/// onto responses by MinIO and never hit disk (backlog#1638).
#[cfg(feature = "rio-v2")]
fn strip_to_minio_ssec_shape(metadata: &mut HashMap<String, String>) {
metadata.remove("x-amz-server-side-encryption");
metadata.remove("x-amz-server-side-encryption-customer-algorithm");
metadata.remove("x-amz-server-side-encryption-customer-key-md5");
}
#[cfg(feature = "rio-v2")]
#[tokio::test]
async fn test_minio_shaped_ssec_metadata_decrypts_with_the_right_key_only() {
let customer_key_bytes = [0x42u8; 32];
let customer_key = BASE64_STANDARD.encode_to_string(customer_key_bytes);
let customer_key_md5 = md5_base64(customer_key_bytes);
let material = sse_encryption(EncryptionRequest {
bucket: "bucket",
key: "object",
server_side_encryption: None,
ssekms_key_id: None,
ssekms_context: None,
sse_customer_algorithm: Some("AES256".to_string()),
sse_customer_key: Some(customer_key.clone()),
sse_customer_key_md5: Some(customer_key_md5.clone()),
content_size: 4096,
principal: None,
})
.await
.expect("sse-c encryption")
.expect("sse-c material");
let mut metadata = encryption_material_to_metadata(&material).expect("sse-c metadata should serialize");
strip_to_minio_ssec_shape(&mut metadata);
assert!(
!metadata.contains_key("x-amz-server-side-encryption-customer-key-md5"),
"the MinIO shape stores no customer-key MD5"
);
// Correct key: detected via the sealed slot, proven by the unseal.
let decrypted = sse_decryption(DecryptionRequest {
bucket: "bucket",
key: "object",
metadata: &metadata,
sse_customer_key: Some(&customer_key),
sse_customer_key_md5: Some(&customer_key_md5),
principal: None,
})
.await
.expect("minio-shaped sse-c decryption")
.expect("minio-shaped sse-c material");
assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object);
assert_eq!(decrypted.key_bytes, material.key_bytes);
// Wrong key with a self-consistent MD5: the missing stored MD5 cannot
// catch it, so the AEAD unseal must — the relaxation is not a bypass.
let wrong_key_bytes = [0x43u8; 32];
let wrong_key = BASE64_STANDARD.encode_to_string(wrong_key_bytes);
let wrong_key_md5 = md5_base64(wrong_key_bytes);
let result = sse_decryption(DecryptionRequest {
bucket: "bucket",
key: "object",
metadata: &metadata,
sse_customer_key: Some(&wrong_key),
sse_customer_key_md5: Some(&wrong_key_md5),
principal: None,
})
.await;
assert!(result.is_err(), "a wrong customer key must fail the sealed-key unseal");
// No key at all: detected as SSE-C, so the caller is asked for the
// parameters instead of getting an opaque unreadable-object error.
let result = sse_decryption(DecryptionRequest {
bucket: "bucket",
key: "object",
metadata: &metadata,
sse_customer_key: None,
sse_customer_key_md5: None,
principal: None,
})
.await;
let error = result.expect_err("missing SSE-C parameters must fail closed");
assert!(error.message.contains("must be provided"), "got: {}", error.message);
}
#[cfg(feature = "rio-v2")]
#[tokio::test]
async fn test_minio_shaped_ssec_metadata_resolves_and_classifies() {
let customer_key_bytes = [0x51u8; 32];
let customer_key = BASE64_STANDARD.encode_to_string(customer_key_bytes);
let customer_key_md5 = md5_base64(customer_key_bytes);
let material = sse_encryption(EncryptionRequest {
bucket: "bucket",
key: "object",
server_side_encryption: None,
ssekms_key_id: None,
ssekms_context: None,
sse_customer_algorithm: Some("AES256".to_string()),
sse_customer_key: Some(customer_key.clone()),
sse_customer_key_md5: Some(customer_key_md5.clone()),
content_size: 4096,
principal: None,
})
.await
.expect("sse-c encryption")
.expect("sse-c material");
let mut metadata = encryption_material_to_metadata(&material).expect("sse-c metadata should serialize");
strip_to_minio_ssec_shape(&mut metadata);
let mut headers = HeaderMap::new();
headers.insert("x-amz-server-side-encryption-customer-algorithm", HeaderValue::from_static("AES256"));
headers.insert(
"x-amz-server-side-encryption-customer-key",
HeaderValue::from_str(&customer_key).expect("customer key header"),
);
headers.insert(
"x-amz-server-side-encryption-customer-key-md5",
HeaderValue::from_str(&customer_key_md5).expect("customer key MD5 header"),
);
let resolved = SseObjectEncryptionResolver
.resolve_read_material(ReadEncryptionRequest {
bucket: "bucket",
object: "object",
metadata: &metadata,
headers: &headers,
})
.await
.expect("minio-shaped SSE-C resolver")
.expect("minio-shaped SSE-C material");
assert_eq!(resolved.mode, ReadEncryptionMode::Object);
assert_eq!(resolved.key_bytes, material.key_bytes);
// HEAD-path key validation: no stored MD5 to compare against, so a
// self-consistent key passes here and the unseal remains the proof.
super::validate_ssec_for_read(&metadata, Some(&customer_key), Some(&customer_key_md5))
.expect("self-consistent SSE-C parameters validate");
super::validate_ssec_for_read(&metadata, None, None).expect_err("missing SSE-C parameters must be rejected");
// Response classification reports SSE-C, not managed SSE.
let classified = classify_sse_read_response(DecryptionRequest {
bucket: "bucket",
key: "object",
metadata: &metadata,
sse_customer_key: Some(&customer_key),
sse_customer_key_md5: Some(&customer_key_md5),
principal: None,
})
.await
.expect("minio-shaped sse-c classification")
.expect("minio-shaped sse-c response headers");
assert!(classified.sse_customer_algorithm.is_some());
assert!(classified.ssekms_key_id.is_none());
}
#[cfg(feature = "rio-v2")]
#[test]
fn test_mark_encrypted_multipart_metadata_sets_minio_marker() {
@@ -5845,23 +6150,34 @@ mod tests {
assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER));
}
#[test]
fn test_verify_ssec_key_match_tolerates_missing_md5_only_for_minio_sealed_ssec() {
// MinIO SSE-C objects store no customer-key MD5; the AEAD unseal is the
// real key proof, so the early check stands down for that shape only.
assert!(verify_ssec_key_match("provided_md5", None, true).is_ok());
// A stored MD5 always wins, even on the MinIO shape: a mismatch is a
// mismatch.
let stored = "stored_md5".to_string();
assert!(verify_ssec_key_match("provided_md5", Some(&stored), true).is_err());
}
#[test]
fn test_verify_ssec_key_match_success() {
let md5 = "test_md5".to_string();
let result = verify_ssec_key_match("test_md5", Some(&md5));
let result = verify_ssec_key_match("test_md5", Some(&md5), false);
assert!(result.is_ok());
}
#[test]
fn test_verify_ssec_key_match_mismatch() {
let md5 = "stored_md5".to_string();
let result = verify_ssec_key_match("provided_md5", Some(&md5));
let result = verify_ssec_key_match("provided_md5", Some(&md5), false);
assert!(result.is_err());
}
#[test]
fn test_verify_ssec_key_match_no_stored() {
let result = verify_ssec_key_match("provided_md5", None);
let result = verify_ssec_key_match("provided_md5", None, false);
assert!(result.is_err());
}
@@ -6572,13 +6888,13 @@ mod tests {
#[test]
fn test_verify_ssec_key_match_returns_invalid_request() {
let stored = "stored_md5".to_string();
let err = verify_ssec_key_match("wrong_md5", Some(&stored)).unwrap_err();
let err = verify_ssec_key_match("wrong_md5", Some(&stored), false).unwrap_err();
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
}
#[test]
fn test_verify_ssec_key_match_no_stored_returns_invalid_request() {
let err = verify_ssec_key_match("any_md5", None).unwrap_err();
let err = verify_ssec_key_match("any_md5", None, false).unwrap_err();
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
}