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