fix(sse): read MinIO SSE-C objects, and pin the customer-key check

MinIO SSE-C objects were unreadable for the same reason its managed objects were: the dispatch demanded a header MinIO never persists. A MinIO SSE-C object stores exactly three keys — the internal sealed key, IV, and seal algorithm — and keeps the customer algorithm on the request, returning it on the response. Recognizing the internal sealed-key slot as well is the whole fix on that side; the customer key still has to be supplied by the caller.

The stored customer-key MD5 needed narrower handling. MinIO stores none, so comparing against it made every migrated object fail. The comparison is now skipped only when there is nothing to compare — no stored MD5 *and* the object carries MinIO's SSE-C slot — which does not weaken what the check buys: it is an early, friendlier rejection, while the key itself is proven by the object-key unseal, whose AEAD fails on a wrong key. A negative test holds that line by reading the fixture with a well-formed but wrong customer key.

Writing that test surfaced a gap worth closing on its own: disabling the stored-MD5 comparison for *every* object left all 115 tests in this file green, so nothing guarded it for RustFS-written objects either, and a later widening of the skip would have gone unnoticed. ssec_stored_md5_mismatch_is_refused_when_an_md5_is_stored now fails when that happens.

Verified against fixtures from a real MinIO server: the interop suite is 7/7, including SSE-C multipart, and both mutations — widening the MD5 skip, and the earlier slot-vs-key-id inference — turn it red.

Refs rustfs/backlog#1638.
This commit is contained in:
overtrue
2026-08-19 09:24:30 +08:00
parent 035a6f431a
commit fd26567cd7
2 changed files with 161 additions and 2 deletions
@@ -260,6 +260,57 @@ async fn reads_minio_generated_sse_kms_multipart_fixture() {
assert_fixture_round_trip("sse-kms-multipart-8m", 8 * 1024 * 1024).await;
}
/// Read an SSE-C fixture, supplying the customer key the way a client does.
///
/// SSE-C needs no KMS at all — the key arrives on the request — so this path
/// shares nothing with the managed-SSE reads above beyond the fixture loader.
async fn read_ssec_fixture_plaintext(
encrypted: Vec<u8>,
object_info: ObjectInfo,
customer_key_b64: &str,
customer_key_md5_b64: &str,
) -> Result<Vec<u8>, String> {
let object_size = object_info.size;
reset_sse_dek_provider();
let mut headers = http::HeaderMap::new();
headers.insert(
http::HeaderName::from_static("x-amz-server-side-encryption-customer-algorithm"),
http::HeaderValue::from_static("AES256"),
);
headers.insert(
http::HeaderName::from_static("x-amz-server-side-encryption-customer-key"),
http::HeaderValue::from_str(customer_key_b64).expect("fixture customer key is a header value"),
);
headers.insert(
http::HeaderName::from_static("x-amz-server-side-encryption-customer-key-md5"),
http::HeaderValue::from_str(customer_key_md5_b64).expect("fixture customer key md5 is a header value"),
);
let resolver = SseObjectEncryptionResolver;
let (mut reader, offset, length) = GetObjectReader::new_with_resolver(
Box::new(Cursor::new(encrypted)),
None,
&object_info,
&ObjectOptions::default(),
&headers,
Some(&resolver),
)
.await
.map_err(|err| format!("construct GetObjectReader from MinIO SSE-C fixture: {err:?}"))?;
if offset != 0 || length != object_size {
return Err(format!("unexpected fixture range offset={offset} length={length} size={object_size}"));
}
let mut plaintext = Vec::new();
reader
.read_to_end(&mut plaintext)
.await
.map_err(|err| format!("read plaintext from MinIO SSE-C fixture: {err}"))?;
Ok(plaintext)
}
/// The interop claim must hold on the production key entry point, not only on
/// the test-only injection channel every other case here uses.
#[tokio::test]
@@ -274,6 +325,53 @@ async fn reads_minio_generated_sse_s3_fixture_through_production_master_key_env(
assert_eq!(sha256_hex(&plaintext), expected_sha256);
}
/// SSE-C is the one managed shape needing no KMS: the customer supplies the key
/// on every request, so this measures the read path alone.
#[tokio::test]
#[ignore = "requires generated MinIO fixture data"]
async fn reads_minio_generated_sse_c_multipart_fixture() {
// The fixture lab's fixed SSE-C key; recorded in the case's request.json.
const SSEC_KEY_B64: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
const SSEC_KEY_MD5_B64: &str = "tP/LI3N87DFaSk0aoqYgzg==";
let (object_info, encrypted, expected_sha256) = load_fixture_reader_input("sse-c-multipart-8m").await;
let plaintext = read_ssec_fixture_plaintext(encrypted, object_info, SSEC_KEY_B64, SSEC_KEY_MD5_B64)
.await
.expect("MinIO SSE-C fixture must restore with the customer key");
assert_eq!(sha256_hex(&plaintext), expected_sha256);
}
/// The read path skips the stored-MD5 comparison for MinIO SSE-C objects,
/// which store no MD5. This holds the line that made that safe: the customer
/// key is still proven by the object-key unseal, so a wrong key must fail even
/// with nothing to compare it against.
#[tokio::test]
#[ignore = "requires generated MinIO fixture data"]
async fn sse_c_wrong_customer_key_still_fails_without_a_stored_md5() {
// A well-formed 32-byte key that is not the one the fixture was sealed
// with, sent with its own correct MD5 so the request itself is valid.
const WRONG_KEY_B64: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
const WRONG_KEY_MD5_B64: &str = "0YB4bMPPCf9SNlqiKmM0uQ==";
let (object_info, encrypted, expected_sha256) = load_fixture_reader_input("sse-c-multipart-8m").await;
let result = read_ssec_fixture_plaintext(encrypted, object_info, WRONG_KEY_B64, WRONG_KEY_MD5_B64).await;
match result {
Err(_) => {}
// Never reached today, and asserted rather than assumed: if a future
// change let a wrong key through, returning the real plaintext would be
// the worst possible outcome.
Ok(plaintext) => assert_ne!(
sha256_hex(&plaintext),
expected_sha256,
"a wrong SSE-C customer key must never restore the original plaintext"
),
}
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn rejects_minio_generated_sse_s3_fixture_with_wrong_kms_key() {
+63 -2
View File
@@ -2061,10 +2061,20 @@ 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
// Check for SSE-C encryption.
//
// The stored customer-algorithm marker is what RustFS writes, but a
// MinIO-written object has only the internal sealed-key slot: MinIO keeps
// the customer algorithm on the request and synthesizes it back onto the
// response, never persisting it. Recognizing that slot as well is what lets
// a migrated SSE-C object be read at all; the customer key still has to be
// supplied, and is still checked against the stored MD5 below.
if request
.metadata
.contains_key("x-amz-server-side-encryption-customer-algorithm")
|| request
.metadata
.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)
{
let (key, key_md5) = match (request.sse_customer_key, request.sse_customer_key_md5) {
(Some(k), Some(md5)) => (k, md5),
@@ -2078,7 +2088,21 @@ 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)?;
// MinIO stores no customer-key MD5 — it keeps that header on the request
// and returns it on the response — so requiring one would make every
// migrated SSE-C object unreadable. Skipping the comparison when there is
// nothing to compare against does not weaken the check it performs: the
// stored MD5 is an early, friendlier rejection, while the key itself is
// proven by the object-key unseal below, whose AEAD fails on a wrong key.
// `sse_c_wrong_customer_key_still_fails_without_a_stored_md5` holds that
// line. Objects that *do* carry a stored MD5 are unaffected.
let minio_ssec_without_stored_md5 = stored_md5.is_none()
&& request
.metadata
.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER);
if !minio_ssec_without_stored_md5 {
verify_ssec_key_match(key_md5, stored_md5)?;
}
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());
@@ -4739,6 +4763,43 @@ mod tests {
}
#[cfg(feature = "rio-v2")]
/// A stored customer-key MD5 must still be compared against the one the
/// request presents.
///
/// The read path skips that comparison for MinIO SSE-C objects, which store
/// no MD5. Nothing pinned the check for objects that *do* store one —
/// disabling it outright left this file's 115 tests green — so a later
/// widening of that skip would have gone unnoticed. The mismatch has to be
/// refused here, at the request boundary, rather than surfacing later as a
/// decryption failure.
#[tokio::test]
async fn ssec_stored_md5_mismatch_is_refused_when_an_md5_is_stored() {
let key = SSECustomerKey::from(BASE64_STANDARD.encode([0x11u8; 32]));
let provided_md5 = SSECustomerKeyMD5::from(md5_base64(&[0x11u8; 32]));
let metadata = HashMap::from([
(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string()),
// A stored MD5 that belongs to a different key.
("x-amz-server-side-encryption-customer-key-md5".to_string(), md5_base64(&[0x22u8; 32])),
]);
let error = sse_decryption(DecryptionRequest {
bucket: "bucket",
key: "object",
metadata: &metadata,
sse_customer_key: Some(&key),
sse_customer_key_md5: Some(&provided_md5),
principal: None,
})
.await
.expect_err("a stored MD5 that does not match the request must be refused");
assert!(
format!("{error:?}").contains("did not match"),
"expected the parameter-mismatch refusal, got {error:?}"
);
}
#[tokio::test]
async fn test_sse_kms_roundtrip_persists_and_uses_minio_context() {
use rustfs_kms::types::{CreateKeyRequest, KeyUsage};