From fc93a279743c037e071eaa5293e102dd3687309a Mon Sep 17 00:00:00 2001 From: houseme Date: Thu, 18 Jun 2026 15:29:52 +0800 Subject: [PATCH] fix(storage): harden local SSE-S3 fallback (#3564) * fix: harden local SSE-S3 fallback * test: update managed SSE-S3 assumptions * chore: keep issue plan local only * test(ci): seed local SSE key for s3-tests --- crates/ecstore/src/store_api/readers.rs | 6 +- rustfs/src/app/multipart_usecase.rs | 331 ++++++++++++------------ rustfs/src/storage/sse.rs | 286 ++++++++++++++------ scripts/s3-tests/run.sh | 8 + 4 files changed, 386 insertions(+), 245 deletions(-) diff --git a/crates/ecstore/src/store_api/readers.rs b/crates/ecstore/src/store_api/readers.rs index 831f2b19d..43ad84f3c 100644 --- a/crates/ecstore/src/store_api/readers.rs +++ b/crates/ecstore/src/store_api/readers.rs @@ -2220,11 +2220,11 @@ mod tests { } #[tokio::test] - async fn test_get_object_reader_uses_local_managed_fallback_without_env() { + async fn test_get_object_reader_uses_local_managed_fallback_with_explicit_sse_s3_key() { async_with_vars( [ ("__RUSTFS_SSE_SIMPLE_CMK", None::), - ("RUSTFS_SSE_S3_MASTER_KEY", None::), + ("RUSTFS_SSE_S3_MASTER_KEY", Some(BASE64_STANDARD.encode([0u8; 32]))), ], async { let plaintext = b"managed-local-fallback".to_vec(); @@ -2288,7 +2288,7 @@ mod tests { &HeaderMap::new(), ) .await - .expect("managed encrypted reads should fall back to the local SSE-S3 key"); + .expect("managed encrypted reads should use the configured local SSE-S3 key"); let mut actual = Vec::new(); reader.read_to_end(&mut actual).await.expect("read managed plaintext"); diff --git a/rustfs/src/app/multipart_usecase.rs b/rustfs/src/app/multipart_usecase.rs index 5b444c75f..4bbec0e87 100644 --- a/rustfs/src/app/multipart_usecase.rs +++ b/rustfs/src/app/multipart_usecase.rs @@ -1342,6 +1342,7 @@ mod tests { AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, }; use std::{collections::HashMap, io::Cursor}; + use temp_env::async_with_vars; use tokio::io::AsyncReadExt; fn build_request(input: T, method: Method) -> S3Request { @@ -1457,172 +1458,182 @@ mod tests { #[tokio::test] async fn managed_multipart_roundtrip_preserves_session_nonce_between_parts() { - let prepare_request = PrepareEncryptionRequest { - bucket: "bucket", - key: "object", - server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), - ssekms_key_id: None, - ssekms_context: None, - sse_customer_algorithm: None, - sse_customer_key: None, - sse_customer_key_md5: None, - }; - let session_material = sse_prepare_encryption(prepare_request) - .await - .expect("prepare multipart encryption") - .expect("managed multipart session material"); - let mut session_metadata = encryption_material_to_metadata(&session_material); - mark_encrypted_multipart_metadata(&mut session_metadata); + let local_sse_master_key = base64_simd::STANDARD.encode_to_string([0x24u8; 32]); + async_with_vars( + [ + ("__RUSTFS_SSE_SIMPLE_CMK", None::), + ("RUSTFS_SSE_S3_MASTER_KEY", Some(local_sse_master_key)), + ], + async { + let prepare_request = PrepareEncryptionRequest { + bucket: "bucket", + key: "object", + server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), + ssekms_key_id: None, + ssekms_context: None, + sse_customer_algorithm: None, + sse_customer_key: None, + sse_customer_key_md5: None, + }; + let session_material = sse_prepare_encryption(prepare_request) + .await + .expect("prepare multipart encryption") + .expect("managed multipart session material"); + let mut session_metadata = encryption_material_to_metadata(&session_material); + mark_encrypted_multipart_metadata(&mut session_metadata); - let part_one_plaintext = vec![0x31; rustfs_rio::DEFAULT_ENCRYPTION_BLOCK_SIZE + 23]; - let part_two_plaintext = vec![0x32; rustfs_rio::DEFAULT_ENCRYPTION_BLOCK_SIZE * 2 + 7]; + let part_one_plaintext = vec![0x31; rustfs_rio::DEFAULT_ENCRYPTION_BLOCK_SIZE + 23]; + let part_two_plaintext = vec![0x32; rustfs_rio::DEFAULT_ENCRYPTION_BLOCK_SIZE * 2 + 7]; - let part_one_material = sse_decryption(DecryptionRequest { - bucket: "bucket", - key: "object", - metadata: &session_metadata, - sse_customer_key: None, - sse_customer_key_md5: None, - }) - .await - .expect("decrypt session one") - .expect("part one material"); - let mut encrypted_one = Vec::new(); - #[cfg(feature = "rio-v2")] - let mut part_one_reader = match part_one_material.key_kind { - crate::storage::sse::EncryptionKeyKind::Object => EncryptReader::new_multipart_with_object_key( - Cursor::new(part_one_plaintext.clone()), - part_one_material.key_bytes, - 1, - ), - crate::storage::sse::EncryptionKeyKind::Direct => EncryptReader::new_multipart( - Cursor::new(part_one_plaintext.clone()), - part_one_material.key_bytes, - part_one_material.base_nonce, - 1, - ), - }; - #[cfg(not(feature = "rio-v2"))] - let mut part_one_reader = EncryptReader::new_multipart( - Cursor::new(part_one_plaintext.clone()), - part_one_material.key_bytes, - part_one_material.base_nonce, - 1, - ); - part_one_reader - .read_to_end(&mut encrypted_one) - .await - .expect("read encrypted part one"); + let part_one_material = sse_decryption(DecryptionRequest { + bucket: "bucket", + key: "object", + metadata: &session_metadata, + sse_customer_key: None, + sse_customer_key_md5: None, + }) + .await + .expect("decrypt session one") + .expect("part one material"); + let mut encrypted_one = Vec::new(); + #[cfg(feature = "rio-v2")] + let mut part_one_reader = match part_one_material.key_kind { + crate::storage::sse::EncryptionKeyKind::Object => EncryptReader::new_multipart_with_object_key( + Cursor::new(part_one_plaintext.clone()), + part_one_material.key_bytes, + 1, + ), + crate::storage::sse::EncryptionKeyKind::Direct => EncryptReader::new_multipart( + Cursor::new(part_one_plaintext.clone()), + part_one_material.key_bytes, + part_one_material.base_nonce, + 1, + ), + }; + #[cfg(not(feature = "rio-v2"))] + let mut part_one_reader = EncryptReader::new_multipart( + Cursor::new(part_one_plaintext.clone()), + part_one_material.key_bytes, + part_one_material.base_nonce, + 1, + ); + part_one_reader + .read_to_end(&mut encrypted_one) + .await + .expect("read encrypted part one"); - let part_two_material = sse_decryption(DecryptionRequest { - bucket: "bucket", - key: "object", - metadata: &session_metadata, - sse_customer_key: None, - sse_customer_key_md5: None, - }) - .await - .expect("decrypt session two") - .expect("part two material"); - let mut encrypted_two = Vec::new(); - #[cfg(feature = "rio-v2")] - let mut part_two_reader = match part_two_material.key_kind { - crate::storage::sse::EncryptionKeyKind::Object => EncryptReader::new_multipart_with_object_key( - Cursor::new(part_two_plaintext.clone()), - part_two_material.key_bytes, - 2, - ), - crate::storage::sse::EncryptionKeyKind::Direct => EncryptReader::new_multipart( - Cursor::new(part_two_plaintext.clone()), - part_two_material.key_bytes, - part_two_material.base_nonce, - 2, - ), - }; - #[cfg(not(feature = "rio-v2"))] - let mut part_two_reader = EncryptReader::new_multipart( - Cursor::new(part_two_plaintext.clone()), - part_two_material.key_bytes, - part_two_material.base_nonce, - 2, - ); - part_two_reader - .read_to_end(&mut encrypted_two) - .await - .expect("read encrypted part two"); + let part_two_material = sse_decryption(DecryptionRequest { + bucket: "bucket", + key: "object", + metadata: &session_metadata, + sse_customer_key: None, + sse_customer_key_md5: None, + }) + .await + .expect("decrypt session two") + .expect("part two material"); + let mut encrypted_two = Vec::new(); + #[cfg(feature = "rio-v2")] + let mut part_two_reader = match part_two_material.key_kind { + crate::storage::sse::EncryptionKeyKind::Object => EncryptReader::new_multipart_with_object_key( + Cursor::new(part_two_plaintext.clone()), + part_two_material.key_bytes, + 2, + ), + crate::storage::sse::EncryptionKeyKind::Direct => EncryptReader::new_multipart( + Cursor::new(part_two_plaintext.clone()), + part_two_material.key_bytes, + part_two_material.base_nonce, + 2, + ), + }; + #[cfg(not(feature = "rio-v2"))] + let mut part_two_reader = EncryptReader::new_multipart( + Cursor::new(part_two_plaintext.clone()), + part_two_material.key_bytes, + part_two_material.base_nonce, + 2, + ); + part_two_reader + .read_to_end(&mut encrypted_two) + .await + .expect("read encrypted part two"); - if session_material.key_kind == crate::storage::sse::EncryptionKeyKind::Object { - assert!(session_metadata.contains_key("X-Minio-Internal-Encrypted-Multipart")); - assert!(session_metadata.contains_key("X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key")); - } else { - assert!(session_metadata.contains_key("x-rustfs-encryption-iv")); - } + if session_material.key_kind == crate::storage::sse::EncryptionKeyKind::Object { + assert!(session_metadata.contains_key("X-Minio-Internal-Encrypted-Multipart")); + assert!(session_metadata.contains_key("X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key")); + } else { + assert!(session_metadata.contains_key("x-rustfs-encryption-iv")); + } - let parts = vec![ - ObjectPartInfo { - number: 1, - size: encrypted_one.len(), - actual_size: part_one_plaintext.len() as i64, - ..Default::default() + let parts = vec![ + ObjectPartInfo { + number: 1, + size: encrypted_one.len(), + actual_size: part_one_plaintext.len() as i64, + ..Default::default() + }, + ObjectPartInfo { + number: 2, + size: encrypted_two.len(), + actual_size: part_two_plaintext.len() as i64, + ..Default::default() + }, + ]; + + let mut encrypted_stream = Vec::with_capacity(encrypted_one.len() + encrypted_two.len()); + encrypted_stream.extend_from_slice(&encrypted_one); + encrypted_stream.extend_from_slice(&encrypted_two); + + let decryption_material = sse_decryption(DecryptionRequest { + bucket: "bucket", + key: "object", + metadata: &session_metadata, + sse_customer_key: None, + sse_customer_key_md5: None, + }) + .await + .expect("decrypt multipart") + .expect("managed decryption material"); + + let plaintext_size = multipart_plaintext_size(&parts, -1); + #[cfg(feature = "rio-v2")] + let decrypted_stream = match decryption_material.key_kind { + crate::storage::sse::EncryptionKeyKind::Object => boxed_reader(DecryptReader::new_multipart_with_object_key( + wrap_reader(Cursor::new(encrypted_stream)), + decryption_material.key_bytes, + multipart_part_numbers(&parts), + )), + crate::storage::sse::EncryptionKeyKind::Direct => boxed_reader(DecryptReader::new_multipart( + wrap_reader(Cursor::new(encrypted_stream)), + decryption_material.key_bytes, + decryption_material.base_nonce, + multipart_part_numbers(&parts), + )), + }; + #[cfg(not(feature = "rio-v2"))] + let decrypted_stream = boxed_reader(DecryptReader::new_multipart( + wrap_reader(Cursor::new(encrypted_stream)), + decryption_material.key_bytes, + decryption_material.base_nonce, + multipart_part_numbers(&parts), + )); + let mut decrypted_reader = HardLimitReader::new(decrypted_stream, plaintext_size); + + let mut decrypted = Vec::new(); + decrypted_reader + .read_to_end(&mut decrypted) + .await + .expect("read decrypted multipart data"); + + let mut expected = part_one_plaintext; + expected.extend_from_slice(&part_two_plaintext); + + assert_eq!(plaintext_size, expected.len() as i64); + assert_eq!(decrypted, expected); }, - ObjectPartInfo { - number: 2, - size: encrypted_two.len(), - actual_size: part_two_plaintext.len() as i64, - ..Default::default() - }, - ]; - - let mut encrypted_stream = Vec::with_capacity(encrypted_one.len() + encrypted_two.len()); - encrypted_stream.extend_from_slice(&encrypted_one); - encrypted_stream.extend_from_slice(&encrypted_two); - - let decryption_material = sse_decryption(DecryptionRequest { - bucket: "bucket", - key: "object", - metadata: &session_metadata, - sse_customer_key: None, - sse_customer_key_md5: None, - }) - .await - .expect("decrypt multipart") - .expect("managed decryption material"); - - let plaintext_size = multipart_plaintext_size(&parts, -1); - #[cfg(feature = "rio-v2")] - let decrypted_stream = match decryption_material.key_kind { - crate::storage::sse::EncryptionKeyKind::Object => boxed_reader(DecryptReader::new_multipart_with_object_key( - wrap_reader(Cursor::new(encrypted_stream)), - decryption_material.key_bytes, - multipart_part_numbers(&parts), - )), - crate::storage::sse::EncryptionKeyKind::Direct => boxed_reader(DecryptReader::new_multipart( - wrap_reader(Cursor::new(encrypted_stream)), - decryption_material.key_bytes, - decryption_material.base_nonce, - multipart_part_numbers(&parts), - )), - }; - #[cfg(not(feature = "rio-v2"))] - let decrypted_stream = boxed_reader(DecryptReader::new_multipart( - wrap_reader(Cursor::new(encrypted_stream)), - decryption_material.key_bytes, - decryption_material.base_nonce, - multipart_part_numbers(&parts), - )); - let mut decrypted_reader = HardLimitReader::new(decrypted_stream, plaintext_size); - - let mut decrypted = Vec::new(); - decrypted_reader - .read_to_end(&mut decrypted) - .await - .expect("read decrypted multipart data"); - - let mut expected = part_one_plaintext; - expected.extend_from_slice(&part_two_plaintext); - - assert_eq!(plaintext_size, expected.len() as i64); - assert_eq!(decrypted, expected); + ) + .await; } #[tokio::test] diff --git a/rustfs/src/storage/sse.rs b/rustfs/src/storage/sse.rs index bcaca76a4..eab4db562 100644 --- a/rustfs/src/storage/sse.rs +++ b/rustfs/src/storage/sse.rs @@ -1876,30 +1876,27 @@ impl TestSseDekProvider { } /// Create a local SSE DEK provider for SSE-S3 when KMS is not configured. - /// Uses RUSTFS_SSE_S3_MASTER_KEY (base64 32-byte) if set; otherwise a built-in default. - /// Allows PUT/GET to work without KMS (backward compatible). - pub fn new_for_local_sse() -> Self { - let master_key = match get_env_opt_str("RUSTFS_SSE_S3_MASTER_KEY") { - Some(v) if !v.trim().is_empty() => match BASE64_STANDARD.decode(v.trim()) { - Ok(decoded) if decoded.len() == 32 => { - let mut arr = [0u8; 32]; - arr.copy_from_slice(&decoded[..32]); - tracing::info!("Using RUSTFS_SSE_S3_MASTER_KEY for SSE-S3 (KMS not configured)"); - arr - } - _ => { - tracing::warn!("RUSTFS_SSE_S3_MASTER_KEY invalid (expected base64 32 bytes); using default for SSE-S3"); - [0u8; 32] - } - }, - _ => { - tracing::debug!( - "KMS not configured; using built-in default key for SSE-S3 (set RUSTFS_SSE_S3_MASTER_KEY for production)" - ); - [0u8; 32] - } + /// Requires RUSTFS_SSE_S3_MASTER_KEY to be a valid base64-encoded 32-byte key. + pub fn new_for_local_sse() -> Result { + let Some(raw_value) = get_env_opt_str("RUSTFS_SSE_S3_MASTER_KEY").filter(|value| !value.trim().is_empty()) else { + return Err(ApiError::from(StorageError::other( + "SSE-S3 requires RUSTFS_SSE_S3_MASTER_KEY to be set to a base64-encoded 32-byte key when KMS is not configured", + ))); }; - Self { master_key } + + let decoded = BASE64_STANDARD.decode(raw_value.trim()).map_err(|err| { + ApiError::from(StorageError::other(format!( + "RUSTFS_SSE_S3_MASTER_KEY must be valid base64 for SSE-S3 when KMS is not configured: {err}" + ))) + })?; + let master_key: [u8; 32] = decoded.try_into().map_err(|_| { + ApiError::from(StorageError::other( + "RUSTFS_SSE_S3_MASTER_KEY must decode to exactly 32 bytes for SSE-S3 when KMS is not configured", + )) + })?; + + tracing::info!("Using RUSTFS_SSE_S3_MASTER_KEY for SSE-S3 (KMS not configured)"); + Ok(Self { master_key }) } // Simple encryption of DEK @@ -1908,7 +1905,9 @@ impl TestSseDekProvider { let key = Key::::from(cmk_value); let cipher = Aes256Gcm::new(&key); - let nonce = Nonce::from([0u8; 12]); + let mut nonce_bytes = [0u8; 12]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from(nonce_bytes); let ciphertext = cipher .encrypt(&nonce, dek.as_slice()) .map_err(|_| ApiError::from(StorageError::other("Failed to encrypt DEK")))?; @@ -2038,7 +2037,7 @@ pub async fn get_sse_dek_provider() -> Result, ApiError> Arc::new(TestSseDekProvider::new()) } else { debug!("Using local SSE-S3 provider (KMS not configured)"); - Arc::new(TestSseDekProvider::new_for_local_sse()) + Arc::new(TestSseDekProvider::new_for_local_sse()?) }; let mut slot = GLOBAL_SSE_DEK_PROVIDER @@ -2334,6 +2333,7 @@ mod tests { use http::HeaderValue; use rustfs_rio::{DecryptReader, EncryptReader}; use std::sync::OnceLock; + use temp_env::async_with_vars; use tokio::sync::Mutex; static SSE_TEST_LOCK: OnceLock> = OnceLock::new(); @@ -2342,6 +2342,10 @@ mod tests { SSE_TEST_LOCK.get_or_init(|| Mutex::new(())).lock().await } + fn local_sse_master_key_b64() -> String { + BASE64_STANDARD.encode([0x24u8; 32]) + } + #[test] fn test_extract_ssec_params_from_headers() { let mut headers = http::HeaderMap::new(); @@ -2961,26 +2965,41 @@ mod tests { #[tokio::test] async fn test_sse_encryption_omits_kms_header_for_sse_s3_objects() { - let request = EncryptionRequest { - bucket: "test-bucket", - key: "test-key", - server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), - ssekms_key_id: None, - ssekms_context: None, - sse_customer_algorithm: None, - sse_customer_key: None, - sse_customer_key_md5: None, - content_size: 1024, - }; + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + let local_sse_master_key = local_sse_master_key_b64(); - let material = sse_encryption(request).await.expect("sse-s3 encryption should succeed"); - let material = material.expect("managed sse-s3 encryption should return material"); - let metadata = encryption_material_to_metadata(&material); + async_with_vars( + [ + ("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>), + ("RUSTFS_SSE_S3_MASTER_KEY", Some(local_sse_master_key.as_str())), + ], + async { + let request = EncryptionRequest { + bucket: "test-bucket", + key: "test-key", + server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), + ssekms_key_id: None, + ssekms_context: None, + sse_customer_algorithm: None, + sse_customer_key: None, + sse_customer_key_md5: None, + content_size: 1024, + }; - assert_eq!(material.kms_key_id, None); - assert_eq!(metadata.get("x-amz-server-side-encryption").map(String::as_str), Some("AES256")); - assert!(!metadata.contains_key("x-amz-server-side-encryption-aws-kms-key-id")); - assert_eq!(metadata.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER).map(String::as_str), Some("default")); + let material = sse_encryption(request).await.expect("sse-s3 encryption should succeed"); + let material = material.expect("managed sse-s3 encryption should return material"); + let metadata = encryption_material_to_metadata(&material); + + assert_eq!(material.kms_key_id, None); + assert_eq!(metadata.get("x-amz-server-side-encryption").map(String::as_str), Some("AES256")); + assert!(!metadata.contains_key("x-amz-server-side-encryption-aws-kms-key-id")); + assert_eq!(metadata.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER).map(String::as_str), Some("default")); + }, + ) + .await; + + reset_sse_dek_provider(); } #[test] @@ -3037,51 +3056,63 @@ mod tests { if let Some(manager) = rustfs_kms::get_global_kms_service_manager() { let _ = manager.stop().await; } + let local_sse_master_key = local_sse_master_key_b64(); - let request = EncryptionRequest { - bucket: "bucket", - key: "object", - server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), - ssekms_key_id: None, - ssekms_context: None, - sse_customer_algorithm: None, - sse_customer_key: None, - sse_customer_key_md5: None, - content_size: 4096, - }; + async_with_vars( + [ + ("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>), + ("RUSTFS_SSE_S3_MASTER_KEY", Some(local_sse_master_key.as_str())), + ], + async { + let request = EncryptionRequest { + bucket: "bucket", + key: "object", + server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), + ssekms_key_id: None, + ssekms_context: None, + sse_customer_algorithm: None, + sse_customer_key: None, + sse_customer_key_md5: None, + content_size: 4096, + }; - let material = sse_encryption(request) - .await - .expect("managed sse encryption") - .expect("managed sse material"); - assert_eq!(material.key_kind, EncryptionKeyKind::Object); + let material = sse_encryption(request) + .await + .expect("managed sse encryption") + .expect("managed sse material"); + assert_eq!(material.key_kind, EncryptionKeyKind::Object); - let metadata = encryption_material_to_metadata(&material); - assert!(!metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER)); - assert!(!metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER)); + let metadata = encryption_material_to_metadata(&material); + assert!(!metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER)); + assert!(!metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER)); - let sealing_iv = metadata - .get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER) - .expect("minio sealing iv should be stored"); - let sealed_key = metadata - .get(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER) - .expect("minio sealed key should be stored"); - assert_eq!(BASE64_STANDARD.decode(sealing_iv).expect("decode iv").len(), SEALED_KEY_IV_SIZE); - assert_eq!(BASE64_STANDARD.decode(sealed_key).expect("decode sealed key").len(), SEALED_KEY_SIZE); + let sealing_iv = metadata + .get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER) + .expect("minio sealing iv should be stored"); + let sealed_key = metadata + .get(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER) + .expect("minio sealed key should be stored"); + assert_eq!(BASE64_STANDARD.decode(sealing_iv).expect("decode iv").len(), SEALED_KEY_IV_SIZE); + assert_eq!(BASE64_STANDARD.decode(sealed_key).expect("decode sealed key").len(), SEALED_KEY_SIZE); - let decrypted = sse_decryption(DecryptionRequest { - bucket: "bucket", - key: "object", - metadata: &metadata, - sse_customer_key: None, - sse_customer_key_md5: None, - }) - .await - .expect("managed sse decryption") - .expect("managed decryption material"); + let decrypted = sse_decryption(DecryptionRequest { + bucket: "bucket", + key: "object", + metadata: &metadata, + sse_customer_key: None, + sse_customer_key_md5: None, + }) + .await + .expect("managed sse decryption") + .expect("managed decryption material"); - assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object); - assert_eq!(decrypted.key_bytes, material.key_bytes); + assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object); + assert_eq!(decrypted.key_bytes, material.key_bytes); + }, + ) + .await; + + reset_sse_dek_provider(); } #[cfg(feature = "rio-v2")] @@ -3381,6 +3412,97 @@ mod tests { println!("✅ Different nonces produce different ciphertext - test passed!"); } + #[test] + fn test_encrypt_dek_uses_random_nonce_prefixes() { + let dek = [0x11u8; 32]; + let cmk = [0x22u8; 32]; + + let encrypted_a = TestSseDekProvider::encrypt_dek(dek, cmk).expect("first DEK wrap should succeed"); + let encrypted_b = TestSseDekProvider::encrypt_dek(dek, cmk).expect("second DEK wrap should succeed"); + + let nonce_a = encrypted_a.split(':').next().expect("wrapped DEK should contain nonce"); + let nonce_b = encrypted_b.split(':').next().expect("wrapped DEK should contain nonce"); + + assert_ne!(nonce_a, nonce_b, "each DEK wrap should use a distinct nonce prefix"); + } + + #[test] + fn test_decrypt_dek_accepts_legacy_zero_nonce_payload() { + let dek = [0x33u8; 32]; + let cmk = [0x44u8; 32]; + let cipher = Aes256Gcm::new(&Key::::from(cmk)); + let legacy_nonce = Nonce::from([0u8; 12]); + let ciphertext = cipher + .encrypt(&legacy_nonce, dek.as_slice()) + .expect("legacy wrap should succeed"); + let legacy_payload = format!("{}:{}", BASE64_STANDARD.encode(legacy_nonce), BASE64_STANDARD.encode(ciphertext)); + + let decrypted = TestSseDekProvider::decrypt_dek(&legacy_payload, cmk).expect("legacy payload should remain decryptable"); + assert_eq!(decrypted, dek); + } + + #[tokio::test] + async fn test_sse_encryption_fails_closed_without_local_sse_master_key() { + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + async_with_vars( + [ + ("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>), + ("RUSTFS_SSE_S3_MASTER_KEY", None::<&str>), + ], + async { + let err = sse_encryption(EncryptionRequest { + bucket: "test-bucket", + key: "test-key", + server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), + ssekms_key_id: None, + ssekms_context: None, + sse_customer_algorithm: None, + sse_customer_key: None, + sse_customer_key_md5: None, + content_size: 1024, + }) + .await + .expect_err("SSE-S3 should fail closed without a configured local master key"); + + assert!(err.message.contains("RUSTFS_SSE_S3_MASTER_KEY")); + }, + ) + .await; + reset_sse_dek_provider(); + } + + #[tokio::test] + async fn test_sse_encryption_fails_closed_with_invalid_local_sse_master_key() { + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + async_with_vars( + [ + ("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>), + ("RUSTFS_SSE_S3_MASTER_KEY", Some("not-base64")), + ], + async { + let err = sse_encryption(EncryptionRequest { + bucket: "test-bucket", + key: "test-key", + server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), + ssekms_key_id: None, + ssekms_context: None, + sse_customer_algorithm: None, + sse_customer_key: None, + sse_customer_key_md5: None, + content_size: 1024, + }) + .await + .expect_err("SSE-S3 should fail closed with an invalid local master key"); + + assert!(err.message.contains("valid base64")); + }, + ) + .await; + reset_sse_dek_provider(); + } + #[tokio::test] async fn test_simple_sse_dek_provider_decrypt_with_encrypted_dek() { use std::io::Cursor; diff --git a/scripts/s3-tests/run.sh b/scripts/s3-tests/run.sh index 10920cfc8..4dd9c4511 100755 --- a/scripts/s3-tests/run.sh +++ b/scripts/s3-tests/run.sh @@ -196,6 +196,7 @@ S3TESTS_CONF="${S3TESTS_CONF:-s3tests.conf}" DEPLOY_MODE="${DEPLOY_MODE:-build}" RUSTFS_BINARY="${RUSTFS_BINARY:-}" NO_CACHE="${NO_CACHE:-false}" +S3TESTS_LOCAL_SSE_MASTER_KEY_DEFAULT="MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" # Additional directories (SCRIPT_DIR and PROJECT_ROOT defined earlier) ARTIFACTS_DIR="${PROJECT_ROOT}/artifacts/s3tests-${TEST_MODE}" @@ -210,6 +211,11 @@ fi DATA_DIR="${DATA_BASE}/test-data/${CONTAINER_NAME}" RUSTFS_PID="" +if [ "${DEPLOY_MODE}" != "existing" ] && [ -z "${RUSTFS_SSE_S3_MASTER_KEY:-}" ]; then + export RUSTFS_SSE_S3_MASTER_KEY="${S3TESTS_LOCAL_SSE_MASTER_KEY_DEFAULT}" + log_info "Using deterministic local SSE-S3 master key for the s3-tests harness" +fi + show_usage() { cat << EOF Usage: $0 [OPTIONS] @@ -233,6 +239,7 @@ Environment Variables: S3_SECRET_KEY - Main user secret key (default: rustfsadmin) S3_ALT_ACCESS_KEY - Alt user access key (default: rustfsalt) S3_ALT_SECRET_KEY - Alt user secret key (default: rustfsalt) + RUSTFS_SSE_S3_MASTER_KEY - Optional base64 32-byte key for local managed SSE fallback MAXFAIL - Stop after N failures (default: 1) XDIST - Enable parallel execution with N workers (default: 0) MARKEXPR - pytest marker expression (default: no marker filtering) @@ -449,6 +456,7 @@ elif [ "${DEPLOY_MODE}" = "docker" ]; then -e RUSTFS_ADDRESS=0.0.0.0:9000 \ -e RUSTFS_ACCESS_KEY="${S3_ACCESS_KEY}" \ -e RUSTFS_SECRET_KEY="${S3_SECRET_KEY}" \ + -e RUSTFS_SSE_S3_MASTER_KEY="${RUSTFS_SSE_S3_MASTER_KEY}" \ -e RUSTFS_VOLUMES="/data/rustfs0 /data/rustfs1 /data/rustfs2 /data/rustfs3" \ -v "/tmp/${CONTAINER_NAME}:/data" \ rustfs-ci || {