mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-01 17:58:22 +00:00
fix(sse): diagnose unresolvable encrypted metadata on reads (#6784)
This commit is contained in:
+361
-45
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user