Refactor code structure for improved readability and maintainability

This commit is contained in:
唐小鸭
2026-07-24 14:01:40 +08:00
parent 5ea9a1fd8f
commit d69d682193
32 changed files with 3902 additions and 792 deletions
+2
View File
@@ -126,6 +126,8 @@ rustfs-madmin.workspace = true
reqwest = { workspace = true }
aes-gcm = { workspace = true, features = ["rand_core"] }
chacha20poly1305.workspace = true
chacha20.workspace = true
zeroize.workspace = true
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
urlencoding = { workspace = true }
smallvec = { workspace = true, features = ["serde"] }
+8 -4
View File
@@ -361,9 +361,10 @@ pub mod notification {
pub mod object {
pub use crate::object_api::{
BLOCK_SIZE_V2, ERASURE_ALGORITHM, GetObjectBodyCacheHook, GetObjectBodyCacheHookLookup, GetObjectBodySource,
GetObjectReader, ObjectInfo, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, StreamConsumer,
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
GetObjectReader, GetObjectSse, ObjectInfo, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader,
StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
unregister_object_mutation_hook,
};
pub use crate::store::PreparedGetObjectReader;
}
@@ -399,7 +400,10 @@ pub mod set_disk {
}
pub mod sse {
pub use crate::sse::{ManagedDekProvider, ManagedSseScheme, managed_dek_provider};
pub use crate::sse::{
ManagedDekProvider, ManagedSseScheme, PersistedEncryptionError, PersistedManagedEncryption,
classify_persisted_managed_encryption, decrypt_minio_static_kms_dek,
};
}
pub mod store_list {
@@ -1625,6 +1625,7 @@ mod tests {
..Default::default()
},
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}
@@ -657,6 +657,7 @@ mod tests {
stream: Box::new(r),
object_info: Default::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
});
@@ -687,6 +688,7 @@ mod tests {
stream: Box::new(r),
object_info: Default::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
});
let buf = read_multipart_part(&mut reader, 100).await.unwrap();
@@ -153,6 +153,7 @@ pub fn new_getobjectreader<'a>(
object_info: oi.clone(),
stream: Box::new(input_reader),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
};
r
@@ -166,6 +167,7 @@ pub fn new_getobjectreader<'a>(
object_info: oi.clone(),
stream: Box::new(input_reader),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
});
+2
View File
@@ -2089,6 +2089,7 @@ mod tests {
}),
object_info: self.object_info(bucket, object),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}
@@ -3315,6 +3316,7 @@ mod tests {
stream: Box::new(Cursor::new(data)),
object_info,
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}
+1
View File
@@ -1240,6 +1240,7 @@ mod tests {
stream: Box::new(Cursor::new(raw_payload.clone())),
object_info: object_info.clone(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
};
+11 -1
View File
@@ -406,6 +406,9 @@ impl WritePlan {
false,
)?,
WriteEncryptionMode::Singlepart { base_nonce } => HashReader::from_reader(
#[cfg(feature = "rio-v2")]
rustfs_rio::EncryptReader::new(reader, encryption.key_bytes, base_nonce),
#[cfg(not(feature = "rio-v2"))]
EncryptReader::new(reader, encryption.key_bytes, base_nonce),
HashReader::SIZE_PRESERVE_LAYER,
actual_size,
@@ -417,6 +420,9 @@ impl WritePlan {
base_nonce,
multipart_part_number,
} => HashReader::from_reader(
#[cfg(feature = "rio-v2")]
rustfs_rio::EncryptReader::new_multipart(reader, encryption.key_bytes, base_nonce, multipart_part_number),
#[cfg(not(feature = "rio-v2"))]
EncryptReader::new_multipart(reader, encryption.key_bytes, base_nonce, multipart_part_number),
HashReader::SIZE_PRESERVE_LAYER,
actual_size,
@@ -509,6 +515,10 @@ mod tests {
.await
.expect("read transformed ciphertext");
#[cfg(feature = "rio-v2")]
let decrypt_reader =
rustfs_rio::DecryptReader::new_multipart(Cursor::new(ciphertext), key_bytes, base_nonce, vec![part_number]);
#[cfg(not(feature = "rio-v2"))]
let decrypt_reader = DecryptReader::new_multipart(Cursor::new(ciphertext), key_bytes, base_nonce, vec![part_number]);
let mut decompressed = DecompressReader::new(Box::new(decrypt_reader), CompressionAlgorithm::default());
@@ -705,7 +715,7 @@ mod tests {
.expect("read transformed ciphertext");
let mut decrypted_compressed = Vec::new();
DecryptReader::new(Cursor::new(ciphertext), key_bytes, base_nonce)
rustfs_rio::DecryptReader::new(Cursor::new(ciphertext), key_bytes, base_nonce)
.read_to_end(&mut decrypted_compressed)
.await
.expect("decrypt compressed stream");
+349 -324
View File
@@ -13,7 +13,19 @@
// limitations under the License.
use super::*;
use crate::sse::{ManagedDekProvider, ManagedSseScheme, managed_dek_provider as classify_managed_dek_provider};
#[cfg(feature = "rio-v2")]
use crate::sse::PersistedManagedEncryption;
#[cfg(feature = "rio-v2")]
use crate::sse::{
AMZ_SERVER_SIDE_ENCRYPTION_KMS_KEY_ID, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_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_S3_SEALED_KEY_HEADER,
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM, decrypt_minio_static_kms_dek,
};
use crate::sse::{
DEFAULT_SSE_ALGORITHM, INTERNAL_ENCRYPTION_IV_HEADER, INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER,
MINIO_INTERNAL_ENCRYPTION_IV_HEADER, ManagedDekProvider, classify_persisted_managed_encryption,
};
#[cfg(feature = "rio-v2")]
use aes_gcm::aead::Payload;
use aes_gcm::{
@@ -28,47 +40,27 @@ use hmac::{Hmac, Mac};
use md5::{Digest, Md5};
use rustfs_kms::types::ObjectEncryptionContext;
#[cfg(feature = "rio-v2")]
use rustfs_kms::{MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, RUSTFS_ENCRYPTION_CONTEXT_HEADER, decode_managed_kms_context};
use rustfs_utils::http::{
AMZ_SERVER_SIDE_ENCRYPTION, SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER, get_consistent_metadata_value,
};
use rustfs_kms::{MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, decode_managed_kms_context};
use rustfs_utils::http::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
use rustfs_utils::path::path_join_buf;
#[cfg(feature = "rio-v2")]
use serde::Deserialize;
#[cfg(feature = "rio-v2")]
use sha2::Sha256;
use std::collections::HashMap;
use std::env;
#[cfg(feature = "rio-v2")]
use zeroize::Zeroizing;
use crate::io_support::rio::Index;
const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id";
const INTERNAL_ENCRYPTION_KEY_HEADER: &str = "x-rustfs-encryption-key";
const INTERNAL_ENCRYPTION_IV_HEADER: &str = "x-rustfs-encryption-iv";
const INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER: &str = "x-rustfs-encryption-original-size";
const SSEC_ORIGINAL_SIZE_HEADER: &str = "x-amz-server-side-encryption-customer-original-size";
const DEFAULT_SSE_ALGORITHM: &str = "AES256";
const SSE_KMS_ALGORITHM: &str = "aws:kms";
#[cfg(feature = "rio-v2")]
const DARE_PAYLOAD_SIZE: i64 = 64 * 1024;
#[cfg(feature = "rio-v2")]
const DARE_PACKAGE_SIZE: i64 = DARE_PAYLOAD_SIZE + 32;
const MINIO_INTERNAL_ENCRYPTION_IV_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Iv";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Kms-Sealed-Key";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Sealed-Key";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM: &str = "DAREv2-HMAC-SHA256";
#[cfg(feature = "rio-v2")]
const DARE_VERSION_20: u8 = 0x20;
#[cfg(feature = "rio-v2")]
const DARE_CIPHER_AES_256_GCM: u8 = 0x00;
@@ -82,13 +74,6 @@ const DARE_TAG_SIZE: usize = 16;
const SEALED_KEY_IV_SIZE: usize = 32;
#[cfg(feature = "rio-v2")]
const SEALED_KEY_SIZE: usize = DARE_HEADER_SIZE + 32 + DARE_TAG_SIZE;
#[cfg(feature = "rio-v2")]
const MINIO_SECRET_KEY_RANDOM_SIZE: usize = 28;
#[cfg(feature = "rio-v2")]
const MINIO_SECRET_KEY_IV_SIZE: usize = 16;
#[cfg(feature = "rio-v2")]
const MINIO_SECRET_KEY_NONCE_SIZE: usize = 12;
#[cfg(feature = "rio-v2")]
type HmacSha256 = Hmac<Sha256>;
@@ -113,14 +98,6 @@ fn build_object_encryption_context(
object_context
}
#[cfg(feature = "rio-v2")]
fn is_legacy_rustfs_managed_metadata(metadata: &HashMap<String, String>) -> bool {
metadata_get(metadata, INTERNAL_ENCRYPTION_KEY_HEADER).is_some()
&& metadata_get(metadata, INTERNAL_ENCRYPTION_IV_HEADER).is_some()
&& metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER).is_none()
&& metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER).is_none()
}
fn part_plaintext_size(part: &ObjectPartInfo) -> i64 {
if part.actual_size > 0 {
part.actual_size
@@ -304,11 +281,19 @@ pub struct GetObjectReader {
pub stream: Box<dyn AsyncRead + Unpin + Send + Sync>,
pub object_info: ObjectInfo,
pub buffered_body: Option<Bytes>,
pub resolved_sse: Option<GetObjectSse>,
/// Cache-hook provenance; defaults to [`GetObjectBodySource::Unprobed`] for
/// every reader that never passed through the app-layer cache probe.
pub body_source: GetObjectBodySource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GetObjectSse {
SseC { customer_key_md5: String },
SseS3,
SseKms { key_id: String },
}
impl GetObjectReader {
/// Builds a fully materialized reader from a cache-coordinated body.
pub fn from_cache_body(mut object_info: ObjectInfo, body: Bytes) -> Result<Self> {
@@ -317,6 +302,7 @@ impl GetObjectReader {
stream: Box::new(std::io::Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source: GetObjectBodySource::HookServed,
})
}
@@ -334,12 +320,13 @@ impl GetObjectReader {
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
struct EncryptionMaterial {
key_bytes: [u8; 32],
base_nonce: [u8; 12],
key_kind: EncryptionKeyKind,
reader_backend: crate::io_support::rio::ReadEncryptionBackend,
resolved_sse: Option<GetObjectSse>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -585,6 +572,7 @@ impl ReadPlan {
stream: reader,
object_info: oi.clone(),
buffered_body: None,
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
},
self.storage_offset,
@@ -640,6 +628,7 @@ impl ReadPlan {
stream: final_reader,
object_info,
buffered_body: None,
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
},
self.storage_offset,
@@ -736,12 +725,14 @@ impl ReadPlan {
let mut object_info = oi.clone();
object_info.size = self.object_size;
let resolved_sse = material.resolved_sse;
Ok((
GetObjectReader {
stream: final_reader,
object_info,
buffered_body: None,
resolved_sse,
body_source: GetObjectBodySource::Unprobed,
},
self.storage_offset,
@@ -1131,19 +1122,19 @@ fn is_supported_sealed_object_key_cipher(cipher: u8) -> bool {
}
#[cfg(feature = "rio-v2")]
fn decrypt_sealed_object_key_payload(sealing_key: [u8; 32], header: &[u8], sealed_key: &[u8]) -> Result<Vec<u8>> {
fn decrypt_sealed_object_key_payload(sealing_key: &[u8; 32], header: &[u8], sealed_key: &[u8]) -> Result<Vec<u8>> {
let nonce = &header[4..16];
let ciphertext = &sealed_key[DARE_HEADER_SIZE..];
let aad = &header[..4];
match header[1] {
DARE_CIPHER_AES_256_GCM => {
let cipher = Aes256Gcm::new_from_slice(&sealing_key)
let cipher = Aes256Gcm::new_from_slice(sealing_key)
.map_err(|err| Error::other(format!("invalid AES-GCM sealing key: {err}")))?;
let nonce = Nonce::try_from(nonce).map_err(|_| Error::other("invalid sealed object-key package nonce"))?;
cipher.decrypt(&nonce, Payload { msg: ciphertext, aad })
}
DARE_CIPHER_CHACHA20_POLY1305 => {
let cipher = ChaCha20Poly1305::new_from_slice(&sealing_key)
let cipher = ChaCha20Poly1305::new_from_slice(sealing_key)
.map_err(|err| Error::other(format!("invalid ChaCha20-Poly1305 sealing key: {err}")))?;
let nonce =
chacha20poly1305::Nonce::try_from(nonce).map_err(|_| Error::other("invalid sealed object-key package nonce"))?;
@@ -1290,8 +1281,8 @@ fn try_unseal_minio_object_key(
return Err(Error::other("invalid sealed object-key payload header"));
}
let sealing_key = derive_sealing_key(external_key, iv, managed_sse_domain(metadata), bucket, object);
let plaintext = decrypt_sealed_object_key_payload(sealing_key, header, &sealed_key)?;
let sealing_key = Zeroizing::new(derive_sealing_key(external_key, iv, managed_sse_domain(metadata), bucket, object));
let plaintext = Zeroizing::new(decrypt_sealed_object_key_payload(&sealing_key, header, &sealed_key)?);
let object_key: [u8; 32] = plaintext
.as_slice()
.try_into()
@@ -1339,12 +1330,19 @@ fn resolve_ssec_material(oi: &ObjectInfo, headers: &HeaderMap<HeaderValue>) -> R
}
#[cfg(feature = "rio-v2")]
if let Some(object_key) = try_unseal_minio_object_key(&oi.user_defined, &oi.bucket, &oi.name, key_bytes)? {
if metadata_get(&oi.user_defined, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER).is_some()
|| metadata_get(&oi.user_defined, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER).is_some()
{
let object_key = try_unseal_minio_object_key(&oi.user_defined, &oi.bucket, &oi.name, key_bytes)?
.ok_or_else(|| Error::other("incomplete or invalid MinIO SSE-C sealed object-key metadata"))?;
return Ok(EncryptionMaterial {
key_bytes: object_key,
base_nonce: [0u8; 12],
key_kind: EncryptionKeyKind::Object,
reader_backend: crate::io_support::rio::ReadEncryptionBackend::V2,
resolved_sse: Some(GetObjectSse::SseC {
customer_key_md5: expected_md5,
}),
});
}
@@ -1353,6 +1351,9 @@ fn resolve_ssec_material(oi: &ObjectInfo, headers: &HeaderMap<HeaderValue>) -> R
base_nonce: read_stored_ssec_nonce(&oi.user_defined, &oi.bucket, &oi.name),
key_kind: EncryptionKeyKind::Direct,
reader_backend: crate::io_support::rio::ReadEncryptionBackend::Legacy,
resolved_sse: Some(GetObjectSse::SseC {
customer_key_md5: expected_md5,
}),
})
}
@@ -1375,52 +1376,94 @@ fn read_stored_ssec_nonce(metadata: &HashMap<String, String>, bucket: &str, key:
}
async fn resolve_managed_material(bucket: &str, object: &str, metadata: &HashMap<String, String>) -> Result<EncryptionMaterial> {
let normalized_metadata = normalize_managed_metadata(metadata);
let encrypted_dek = metadata_get(&normalized_metadata, INTERNAL_ENCRYPTION_KEY_HEADER)
.ok_or_else(|| Error::other("missing managed encrypted DEK"))?;
let encrypted_dek = BASE64_STANDARD
.decode(encrypted_dek)
.map_err(|e| Error::other(format!("failed to decode managed encrypted DEK: {e}")))?;
let encrypted_dek = metadata_get(metadata, INTERNAL_ENCRYPTION_KEY_HEADER);
#[cfg(feature = "rio-v2")]
let encrypted_dek = encrypted_dek.or_else(|| metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER));
let encrypted_dek = match encrypted_dek {
Some(encrypted_dek) => BASE64_STANDARD
.decode(encrypted_dek)
.map_err(|e| Error::other(format!("failed to decode managed encrypted DEK: {e}")))?,
None => Vec::new(),
};
let persisted_format =
classify_persisted_managed_encryption(metadata, &encrypted_dek).map_err(|err| Error::other(err.to_string()))?;
let kms_key_id = metadata_get(&normalized_metadata, INTERNAL_ENCRYPTION_KEY_ID_HEADER).unwrap_or("default");
let kms_key_id = metadata_get(metadata, INTERNAL_ENCRYPTION_KEY_ID_HEADER).filter(|value| !value.is_empty());
#[cfg(feature = "rio-v2")]
let kms_key_id = kms_key_id
.or_else(|| metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER).filter(|value| !value.is_empty()))
.or_else(|| metadata_get(metadata, AMZ_SERVER_SIDE_ENCRYPTION_KMS_KEY_ID).filter(|value| !value.is_empty()));
let kms_key_id = kms_key_id.unwrap_or("default");
let resolved_sse = match persisted_format.scheme() {
crate::sse::ManagedSseScheme::SseS3 => GetObjectSse::SseS3,
crate::sse::ManagedSseScheme::SseKms => GetObjectSse::SseKms {
key_id: kms_key_id.to_string(),
},
};
#[cfg(feature = "rio-v2")]
let kms_context = decode_managed_kms_context(metadata).map_err(|err| Error::other(err.to_string()))?;
#[cfg(not(feature = "rio-v2"))]
let kms_context: Option<HashMap<String, String>> = None;
let object_context = build_object_encryption_context(bucket, object, kms_context.as_ref());
let decrypted_key = match managed_dek_provider(metadata, &encrypted_dek)? {
ManagedDekProvider::LocalSseS3 => decrypt_local_sse_dek(&encrypted_dek, kms_key_id, &object_context)?,
ManagedDekProvider::Kms => {
let service = crate::runtime::sources::object_encryption_service()
let provider = persisted_format.provider();
#[cfg(feature = "rio-v2")]
let minio_static_key = if matches!(provider, ManagedDekProvider::Kms) {
decrypt_minio_static_kms_dek(kms_key_id, &encrypted_dek, &object_context.encryption_context)
.map_err(|err| Error::other(err.to_string()))?
} else {
None
};
#[cfg(not(feature = "rio-v2"))]
let minio_static_key: Option<[u8; 32]> = None;
let service = match provider {
ManagedDekProvider::LocalSseS3 | ManagedDekProvider::MinioKeyValue => None,
ManagedDekProvider::Kms if minio_static_key.is_some() => None,
ManagedDekProvider::Kms => Some(
crate::runtime::sources::object_encryption_service()
.await
.ok_or_else(|| Error::other("KMS encryption service is required to decrypt this object"))?;
#[cfg(feature = "rio-v2")]
let data_key = if is_legacy_rustfs_managed_metadata(&normalized_metadata) {
service.decrypt_legacy_data_key(&encrypted_dek).await
} else {
service.decrypt_data_key(&encrypted_dek, &object_context).await
};
#[cfg(not(feature = "rio-v2"))]
let data_key = service.decrypt_data_key(&encrypted_dek, &object_context).await;
.ok_or_else(|| Error::other("KMS encryption service is required to decrypt this object"))?,
),
};
let decrypted_key = if let Some(key) = minio_static_key {
key
} else if let Some(service) = service {
#[cfg(feature = "rio-v2")]
let data_key = if matches!(
persisted_format,
PersistedManagedEncryption::LegacySseS3Local
| PersistedManagedEncryption::LegacySseS3Kms
| PersistedManagedEncryption::LegacySseKms
) {
service.decrypt_legacy_data_key(&encrypted_dek).await
} else {
service.decrypt_data_key(&encrypted_dek, &object_context).await
};
#[cfg(not(feature = "rio-v2"))]
let data_key = service.decrypt_data_key(&encrypted_dek, &object_context).await;
data_key
.map_err(|e| Error::other(format!("failed to decrypt managed data key: {e}")))?
.plaintext_key
}
data_key
.map_err(|e| Error::other(format!("failed to decrypt managed data key: {e}")))?
.plaintext_key
} else {
decrypt_local_sse_dek(&encrypted_dek)?
};
#[cfg(feature = "rio-v2")]
if let Some(object_key) = try_unseal_minio_object_key(&normalized_metadata, bucket, object, decrypted_key)? {
if persisted_format.uses_object_key() {
let object_key = try_unseal_minio_object_key(metadata, bucket, object, decrypted_key)?
.ok_or_else(|| Error::other("MinIO managed SSE metadata is missing a valid sealed object key"))?;
return Ok(EncryptionMaterial {
key_bytes: object_key,
base_nonce: [0u8; 12],
key_kind: EncryptionKeyKind::Object,
reader_backend: crate::io_support::rio::ReadEncryptionBackend::V2,
resolved_sse: Some(resolved_sse),
});
}
let iv_b64 = metadata_get(&normalized_metadata, INTERNAL_ENCRYPTION_IV_HEADER)
let iv_b64 = metadata_get(metadata, INTERNAL_ENCRYPTION_IV_HEADER)
.or_else(|| metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_IV_HEADER))
.ok_or_else(|| Error::other("missing managed encryption IV"))?;
let iv = BASE64_STANDARD
.decode(iv_b64)
@@ -1435,79 +1478,15 @@ async fn resolve_managed_material(bucket: &str, object: &str, metadata: &HashMap
base_nonce,
key_kind: EncryptionKeyKind::Direct,
reader_backend: crate::io_support::rio::ReadEncryptionBackend::Legacy,
resolved_sse: Some(resolved_sse),
})
}
fn managed_dek_provider(metadata: &HashMap<String, String>, encrypted_dek: &[u8]) -> Result<ManagedDekProvider> {
let algorithm = get_consistent_metadata_value(metadata, AMZ_SERVER_SIDE_ENCRYPTION)
.map_err(|_| Error::other(format!("conflicting managed encryption metadata for {AMZ_SERVER_SIDE_ENCRYPTION}")))?;
let scheme = match algorithm {
Some(SSE_KMS_ALGORITHM) => ManagedSseScheme::SseKms,
Some(DEFAULT_SSE_ALGORITHM) | None => ManagedSseScheme::SseS3,
Some(algorithm) => return Err(Error::other(format!("unsupported stored server-side encryption {algorithm}"))),
};
// RUSTFS_COMPAT_TODO(rustfs-5063): Keep legacy SSE-S3 KMS envelopes readable. Remove after SSE-S3 migration rewraps every referenced legacy DEK.
let has_kms_envelope = rustfs_kms::is_data_key_envelope(encrypted_dek);
Ok(classify_managed_dek_provider(scheme, has_kms_envelope))
}
fn normalize_managed_metadata(metadata: &HashMap<String, String>) -> HashMap<String, String> {
#[cfg(feature = "rio-v2")]
{
let mut normalized = metadata.clone();
if metadata_get(&normalized, INTERNAL_ENCRYPTION_KEY_HEADER).is_none()
&& let Some(value) = metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)
.or_else(|| metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER))
.or_else(|| metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER))
{
normalized.insert(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), value.to_string());
}
if metadata_get(&normalized, INTERNAL_ENCRYPTION_IV_HEADER).is_none()
&& let Some(value) = metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_IV_HEADER)
{
normalized.insert(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), value.to_string());
}
if metadata_get(&normalized, INTERNAL_ENCRYPTION_KEY_ID_HEADER).is_none()
&& let Some(value) = metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)
{
normalized.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), value.to_string());
}
if metadata_get(&normalized, RUSTFS_ENCRYPTION_CONTEXT_HEADER).is_none()
&& let Some(value) = metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER)
&& let Ok(decoded) = BASE64_STANDARD.decode(value)
&& let Ok(context) = serde_json::from_slice::<HashMap<String, String>>(&decoded)
&& let Ok(encoded) = serde_json::to_string(&context)
{
normalized.insert(RUSTFS_ENCRYPTION_CONTEXT_HEADER.to_string(), encoded);
}
normalized
}
#[cfg(not(feature = "rio-v2"))]
{
metadata.clone()
}
}
fn decrypt_local_sse_dek(encrypted_dek: &[u8], _kms_key_id: &str, object_context: &ObjectEncryptionContext) -> Result<[u8; 32]> {
if let Ok(plaintext) = decrypt_rustfs_local_sse_dek(encrypted_dek) {
return Ok(plaintext);
}
#[cfg(feature = "rio-v2")]
{
decrypt_minio_secret_key_dek(encrypted_dek, object_context)
}
#[cfg(not(feature = "rio-v2"))]
{
let _ = object_context;
Err(Error::other("invalid managed DEK format"))
fn decrypt_local_sse_dek(encrypted_dek: &[u8]) -> Result<[u8; 32]> {
if encrypted_dek.is_empty() {
return local_sse_master_key();
}
decrypt_rustfs_local_sse_dek(encrypted_dek)
}
fn decrypt_rustfs_local_sse_dek(encrypted_dek: &[u8]) -> Result<[u8; 32]> {
@@ -1541,103 +1520,6 @@ fn decrypt_rustfs_local_sse_dek(encrypted_dek: &[u8]) -> Result<[u8; 32]> {
.map_err(|_| Error::other("managed DEK has invalid plaintext length"))
}
#[cfg(feature = "rio-v2")]
#[derive(Deserialize)]
struct MinioLegacyCiphertext {
#[serde(rename = "aead")]
algorithm: String,
iv: Vec<u8>,
nonce: Vec<u8>,
bytes: Vec<u8>,
}
#[cfg(feature = "rio-v2")]
fn decrypt_minio_secret_key_dek(encrypted_dek: &[u8], object_context: &ObjectEncryptionContext) -> Result<[u8; 32]> {
let key = local_sse_master_key()?;
let (ciphertext, iv, nonce) = parse_minio_secret_key_ciphertext(encrypted_dek)?;
let associated_data = marshal_minio_kms_context(&object_context.encryption_context);
let mut mac = HmacSha256::new_from_slice(&key).map_err(|err| Error::other(format!("invalid local SSE master key: {err}")))?;
mac.update(&iv);
let sealing_key = mac.finalize().into_bytes();
let cipher = Aes256Gcm::new_from_slice(sealing_key.as_slice())
.map_err(|err| Error::other(format!("invalid MinIO sealing key: {err}")))?;
let nonce = Nonce::try_from(&nonce[..]).map_err(|_| Error::other("invalid MinIO managed DEK nonce"))?;
let plaintext = cipher
.decrypt(
&nonce,
aes_gcm::aead::Payload {
msg: &ciphertext,
aad: &associated_data,
},
)
.map_err(|err| Error::other(format!("failed to decrypt MinIO managed DEK: {err}")))?;
plaintext
.as_slice()
.try_into()
.map_err(|_| Error::other("MinIO managed DEK has invalid plaintext length"))
}
#[cfg(feature = "rio-v2")]
fn parse_minio_secret_key_ciphertext(
encrypted_dek: &[u8],
) -> Result<(Vec<u8>, [u8; MINIO_SECRET_KEY_IV_SIZE], [u8; MINIO_SECRET_KEY_NONCE_SIZE])> {
if encrypted_dek.first() == Some(&b'{') && encrypted_dek.last() == Some(&b'}') {
let legacy: MinioLegacyCiphertext = serde_json::from_slice(encrypted_dek)
.map_err(|err| Error::other(format!("failed to parse MinIO legacy managed DEK: {err}")))?;
if legacy.algorithm != "AES-256-GCM-HMAC-SHA-256" {
return Err(Error::other(format!(
"unsupported MinIO legacy managed DEK algorithm {}",
legacy.algorithm
)));
}
let iv = legacy
.iv
.as_slice()
.try_into()
.map_err(|_| Error::other("invalid MinIO legacy managed DEK IV length"))?;
let nonce = legacy
.nonce
.as_slice()
.try_into()
.map_err(|_| Error::other("invalid MinIO legacy managed DEK nonce length"))?;
return Ok((legacy.bytes, iv, nonce));
}
if encrypted_dek.len() <= MINIO_SECRET_KEY_RANDOM_SIZE {
return Err(Error::other("invalid MinIO managed DEK length"));
}
let split_at = encrypted_dek.len() - MINIO_SECRET_KEY_RANDOM_SIZE;
let (ciphertext, random) = encrypted_dek.split_at(split_at);
let iv = random[..MINIO_SECRET_KEY_IV_SIZE]
.try_into()
.map_err(|_| Error::other("invalid MinIO managed DEK IV length"))?;
let nonce = random[MINIO_SECRET_KEY_IV_SIZE..]
.try_into()
.map_err(|_| Error::other("invalid MinIO managed DEK nonce length"))?;
Ok((ciphertext.to_vec(), iv, nonce))
}
#[cfg(feature = "rio-v2")]
fn marshal_minio_kms_context(context: &HashMap<String, String>) -> Vec<u8> {
let mut entries: Vec<_> = context.iter().collect();
entries.sort_by_key(|(left, _)| *left);
let mut json = String::from("{");
for (index, (key, value)) in entries.into_iter().enumerate() {
if index > 0 {
json.push(',');
}
json.push_str(&serde_json::to_string(key).expect("string key serializes"));
json.push(':');
json.push_str(&serde_json::to_string(value).expect("string value serializes"));
}
json.push('}');
json.into_bytes()
}
fn local_sse_master_key() -> Result<[u8; 32]> {
#[cfg(test)]
if let Some(key) = decode_master_key_env("__RUSTFS_SSE_SIMPLE_CMK")? {
@@ -1728,6 +1610,7 @@ mod tests {
assert_eq!(reader.body_source, GetObjectBodySource::HookServed);
assert_eq!(reader.buffered_body.as_ref(), Some(&body));
assert_eq!(reader.resolved_sse, None);
assert_eq!(reader.object_info.size, 11);
assert_eq!(reader.object_info.actual_size, 11);
assert!(reader.object_info.is_compressed());
@@ -1788,20 +1671,38 @@ mod tests {
#[cfg(feature = "rio-v2")]
#[test]
fn test_legacy_managed_metadata_excludes_sealed_keys() {
let legacy_metadata = HashMap::from([
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), "encrypted-dek".to_string()),
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "nonce".to_string()),
]);
assert!(is_legacy_rustfs_managed_metadata(&legacy_metadata));
fn resolve_ssec_material_rejects_partial_object_key_metadata() {
let customer_key = [0x42u8; 32];
let headers = ssec_headers_from_key(customer_key);
for partial in [
HashMap::from([(
MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode([0x11u8; SEALED_KEY_SIZE]),
)]),
HashMap::from([(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
)]),
] {
let mut metadata = HashMap::from([
(SSEC_ALGORITHM_HEADER.to_string(), DEFAULT_SSE_ALGORITHM.to_string()),
(SSEC_KEY_MD5_HEADER.to_string(), BASE64_STANDARD.encode(md5_bytes(customer_key))),
]);
metadata.extend(partial);
let object_info = ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
user_defined: Arc::new(metadata),
..Default::default()
};
let sealed_metadata = HashMap::from([
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), "encrypted-dek".to_string()),
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "nonce".to_string()),
(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(), "sealed-key".to_string()),
]);
assert!(!is_legacy_rustfs_managed_metadata(&sealed_metadata));
assert!(
resolve_ssec_material(&object_info, &headers)
.expect_err("partial MinIO SSE-C metadata must fail closed")
.to_string()
.contains("incomplete or invalid")
);
}
}
#[cfg(feature = "rio-v2")]
@@ -2129,57 +2030,6 @@ mod tests {
format!("{}:{}", BASE64_STANDARD.encode(nonce), BASE64_STANDARD.encode(ciphertext))
}
#[test]
fn managed_dek_provider_routes_by_algorithm_and_persisted_envelope() {
let local_dek = encrypt_managed_dek_for_test([0x24; 32], [0x42; 32]);
let kms_dek = serde_json::to_vec(&serde_json::json!({
"key_id": "legacy-data-key",
"master_key_id": "legacy-master-key",
"key_spec": "AES_256",
"encrypted_key": [1, 2, 3, 4],
"nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
"encryption_context": {},
"created_at": "2024-01-01T00:00:00+00:00"
}))
.expect("legacy KMS envelope should serialize");
assert_eq!(
managed_dek_provider(
&HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), DEFAULT_SSE_ALGORITHM.to_string())]),
local_dek.as_bytes(),
)
.expect("SSE-S3 local DEK should be classified"),
ManagedDekProvider::LocalSseS3
);
assert_eq!(
managed_dek_provider(
&HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), SSE_KMS_ALGORITHM.to_string())]),
local_dek.as_bytes(),
)
.expect("SSE-KMS should be classified by its stored algorithm"),
ManagedDekProvider::Kms
);
assert_eq!(
managed_dek_provider(
&HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), DEFAULT_SSE_ALGORITHM.to_string())]),
&kms_dek,
)
.expect("legacy SSE-S3 KMS envelope should be classified"),
ManagedDekProvider::Kms
);
assert_eq!(
managed_dek_provider(&HashMap::new(), &kms_dek).expect("legacy KMS envelope without algorithm should be classified"),
ManagedDekProvider::Kms
);
assert!(
managed_dek_provider(
&HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "unsupported".to_string())]),
local_dek.as_bytes(),
)
.is_err()
);
}
#[cfg(feature = "rio-v2")]
fn seal_managed_s3_object_key_for_test(
bucket: &str,
@@ -2197,9 +2047,21 @@ mod tests {
data_key: [u8; 32],
object_key: [u8; 32],
cipher_id: u8,
) -> ([u8; 32], Vec<u8>) {
seal_managed_object_key_for_test(bucket, object, data_key, object_key, "SSE-S3", cipher_id)
}
#[cfg(feature = "rio-v2")]
fn seal_managed_object_key_for_test(
bucket: &str,
object: &str,
data_key: [u8; 32],
object_key: [u8; 32],
domain: &str,
cipher_id: u8,
) -> ([u8; 32], Vec<u8>) {
let iv = [0x24u8; SEALED_KEY_IV_SIZE];
let sealing_key = derive_sealing_key(data_key, iv, "SSE-S3", bucket, object);
let sealing_key = derive_sealing_key(data_key, iv, domain, bucket, object);
let mut header = [0u8; DARE_HEADER_SIZE];
header[0] = DARE_VERSION_20;
@@ -2275,7 +2137,7 @@ mod tests {
#[tokio::test]
async fn resolve_managed_material_accepts_chacha20_poly1305_header_variant() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
let data_key = [0x24; 32];
let data_key = [0u8; 32];
let object_key = [0x33; 32];
let (iv, sealed_key) = seal_managed_s3_object_key_for_test_with_cipher(
"bucket",
@@ -2285,7 +2147,6 @@ mod tests {
DARE_CIPHER_CHACHA20_POLY1305,
);
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0u8; 32]);
let metadata = HashMap::from([
(
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(),
@@ -2296,11 +2157,6 @@ mod tests {
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
),
(
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(encrypted_dek.as_bytes()),
),
(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "default".to_string()),
]);
let material = resolve_managed_material("bucket", "object", &metadata)
@@ -2530,11 +2386,52 @@ mod tests {
));
}
#[tokio::test]
async fn test_zero_length_ssec_read_still_requires_customer_key() {
let object_info = ObjectInfo {
size: 0,
user_defined: Arc::new(HashMap::from([
(SSEC_ALGORITHM_HEADER.to_string(), DEFAULT_SSE_ALGORITHM.to_string()),
(SSEC_KEY_MD5_HEADER.to_string(), BASE64_STANDARD.encode([0x11; 16])),
(SSEC_ORIGINAL_SIZE_HEADER.to_string(), "0".to_string()),
])),
..Default::default()
};
let result = ReadPlan::build(None, &object_info, &ObjectOptions::default(), &HeaderMap::new()).await;
assert!(result.is_err(), "zero-length SSE-C reads must not bypass customer-key authorization");
}
#[tokio::test]
async fn test_zero_length_sse_kms_read_still_requires_kms() {
let object_info = ObjectInfo {
size: 0,
bucket: "bucket".to_string(),
name: "zero-kms".to_string(),
user_defined: Arc::new(HashMap::from([
("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()),
(
INTERNAL_ENCRYPTION_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(b"opaque-kms-ciphertext"),
),
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([0x12; 12])),
(INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER.to_string(), "0".to_string()),
])),
..Default::default()
};
let result = ReadPlan::build(None, &object_info, &ObjectOptions::default(), &HeaderMap::new()).await;
assert!(result.is_err(), "zero-length SSE-KMS reads must not bypass KMS authorization");
}
#[tokio::test]
async fn test_get_object_reader_allows_encrypted_full_object_passthrough() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
let plaintext = b"managed-full-object".to_vec();
let data_key = [0x21; 32];
let data_key = [0u8; 32];
#[cfg(not(feature = "rio-v2"))]
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0u8; 32]);
let bucket = "bucket";
let object = "managed-full-object";
@@ -2550,7 +2447,6 @@ mod tests {
.expect("encrypt managed object");
HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
("x-rustfs-encryption-key".to_string(), BASE64_STANDARD.encode(encrypted_dek.as_bytes())),
("x-rustfs-encryption-original-size".to_string(), plaintext.len().to_string()),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
@@ -2611,7 +2507,8 @@ mod tests {
async fn test_get_object_reader_decrypts_managed_sse_range_on_plaintext_semantics() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
let plaintext = b"0123456789abcdefghijklmnopqrstuvwxyz".to_vec();
let data_key = [0x23; 32];
let data_key = [0u8; 32];
#[cfg(not(feature = "rio-v2"))]
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0u8; 32]);
let bucket = "bucket";
let object = "managed-range-object";
@@ -2627,7 +2524,6 @@ mod tests {
.expect("encrypt managed ranged object");
HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
("x-rustfs-encryption-key".to_string(), BASE64_STANDARD.encode(encrypted_dek.as_bytes())),
("x-rustfs-encryption-original-size".to_string(), plaintext.len().to_string()),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
@@ -2698,7 +2594,8 @@ mod tests {
],
async {
let plaintext = b"managed-local-fallback".to_vec();
let data_key = [0x22; 32];
let data_key = [0x33; 32];
#[cfg(not(feature = "rio-v2"))]
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0x33; 32]);
let bucket = "bucket";
let object = "managed-local-fallback";
@@ -2714,7 +2611,6 @@ mod tests {
.expect("encrypt managed object with local fallback key");
HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
("x-rustfs-encryption-key".to_string(), BASE64_STANDARD.encode(encrypted_dek.as_bytes())),
("x-rustfs-encryption-original-size".to_string(), plaintext.len().to_string()),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
@@ -2775,8 +2671,7 @@ mod tests {
async fn test_get_object_reader_accepts_minio_only_managed_metadata() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
let plaintext = b"managed-minio-metadata".to_vec();
let data_key = [0x23; 32];
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0u8; 32]);
let data_key = [0u8; 32];
let bucket = "bucket";
let object = "managed-minio-metadata";
let object_key = [0x44; 32];
@@ -2794,10 +2689,6 @@ mod tests {
size: encrypted.len() as i64,
user_defined: Arc::new(HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
(
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(encrypted_dek.as_bytes()),
),
(
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(sealed_key),
@@ -2807,7 +2698,6 @@ mod tests {
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
),
(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "default".to_string()),
("x-minio-internal-actual-size".to_string(), plaintext.len().to_string()),
])),
..Default::default()
@@ -2823,6 +2713,7 @@ mod tests {
.await
.expect("managed encrypted reads should accept MinIO-style metadata");
assert_eq!(reader.resolved_sse, Some(GetObjectSse::SseS3));
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read managed plaintext");
@@ -2834,6 +2725,128 @@ mod tests {
.await;
}
#[cfg(feature = "rio-v2")]
#[tokio::test]
async fn test_get_object_reader_accepts_minio_legacy_key_value_metadata() {
let external_key = [0x23; 32];
async_with_vars([("RUSTFS_SSE_S3_MASTER_KEY", Some(BASE64_STANDARD.encode(external_key)))], async {
let plaintext = b"managed-minio-key-value-metadata".to_vec();
let bucket = "bucket";
let object = "managed-minio-key-value-metadata";
let object_key = [0x45; 32];
let (sealing_iv, sealed_key) = seal_managed_s3_object_key_for_test(bucket, object, external_key, object_key);
let mut encrypted = Vec::new();
crate::io_support::rio::EncryptReader::new_with_object_key(Cursor::new(plaintext.clone()), object_key)
.read_to_end(&mut encrypted)
.await
.expect("encrypt managed object");
let object_info = ObjectInfo {
bucket: bucket.to_string(),
name: object.to_string(),
size: encrypted.len() as i64,
user_defined: Arc::new(HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
(
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(sealed_key),
),
(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode(sealing_iv)),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
),
("x-minio-internal-actual-size".to_string(), plaintext.len().to_string()),
])),
..Default::default()
};
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(encrypted)),
None,
&object_info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("MinIO historical K/V metadata should remain readable");
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.await
.expect("read MinIO historical K/V object");
assert_eq!(reader.resolved_sse, Some(GetObjectSse::SseS3));
assert_eq!(actual, plaintext);
})
.await;
}
#[cfg(feature = "rio-v2")]
#[tokio::test]
async fn test_get_object_reader_accepts_minio_legacy_kms_key_value_metadata() {
let external_key = [0x26; 32];
async_with_vars([("RUSTFS_SSE_S3_MASTER_KEY", Some(BASE64_STANDARD.encode(external_key)))], async {
let plaintext = b"managed-minio-kms-key-value-metadata".to_vec();
let bucket = "bucket";
let object = "managed-minio-kms-key-value-metadata";
let object_key = [0x48; 32];
let (sealing_iv, sealed_key) =
seal_managed_object_key_for_test(bucket, object, external_key, object_key, "SSE-KMS", DARE_CIPHER_AES_256_GCM);
let mut encrypted = Vec::new();
crate::io_support::rio::EncryptReader::new_with_object_key(Cursor::new(plaintext.clone()), object_key)
.read_to_end(&mut encrypted)
.await
.expect("encrypt managed KMS object");
let object_info = ObjectInfo {
bucket: bucket.to_string(),
name: object.to_string(),
size: encrypted.len() as i64,
user_defined: Arc::new(HashMap::from([
("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()),
(
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(sealed_key),
),
(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode(sealing_iv)),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
),
("x-minio-internal-actual-size".to_string(), plaintext.len().to_string()),
])),
..Default::default()
};
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(encrypted)),
None,
&object_info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("MinIO historical SSE-KMS K/V metadata should remain readable");
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.await
.expect("read MinIO historical SSE-KMS K/V object");
assert_eq!(
reader.resolved_sse,
Some(GetObjectSse::SseKms {
key_id: "default".to_string(),
})
);
assert_eq!(actual, plaintext);
})
.await;
}
#[tokio::test]
async fn test_get_object_reader_compressed_range_returns_physical_offset_from_index() {
let mut index = Index::new();
@@ -3106,6 +3119,12 @@ mod tests {
.await
.expect("ssec read should be supported");
assert_eq!(
reader.resolved_sse,
Some(GetObjectSse::SseC {
customer_key_md5: BASE64_STANDARD.encode(md5_bytes(key_bytes)),
})
);
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read decrypted ssec object");
@@ -3168,6 +3187,12 @@ mod tests {
.await
.expect("rio-v2 ssec sealed-object-key read should be supported");
assert_eq!(
reader.resolved_sse,
Some(GetObjectSse::SseC {
customer_key_md5: BASE64_STANDARD.encode(md5_bytes(customer_key)),
})
);
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
@@ -141,6 +141,7 @@ impl MigrationBackendSpy {
stream: Box::new(Cursor::new(vec![0_u8; 3])),
object_info: ObjectInfo::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
}
}
+1
View File
@@ -8194,6 +8194,7 @@ mod tests {
..Default::default()
},
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}
+56 -1
View File
@@ -206,7 +206,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
// });
// }
if object_info.size == 0 {
if object_info.size == 0 && !object_info.is_encrypted() {
record_get_object_reader_path_observation(GET_OBJECT_PATH_EMPTY, object_class, size_bucket);
// if let Some(rs) = range {
// let _ = rs.get_offset_length(object_info.size)?;
@@ -216,6 +216,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(Vec::new())),
object_info,
buffered_body: Some(Bytes::new()),
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
};
return Ok(reader);
@@ -303,6 +304,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
};
return Ok(reader);
@@ -381,6 +383,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
};
return Ok(reader);
@@ -462,6 +465,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source: GetObjectBodySource::HookServed,
};
if lock_optimization_enabled {
@@ -505,6 +509,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source,
};
if lock_optimization_enabled {
@@ -547,6 +552,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source,
};
if lock_optimization_enabled {
@@ -3311,6 +3317,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
stream: Box::new(TransitionUploadReader::new(pr, Arc::clone(&consumed))),
object_info: oi,
buffered_body: None,
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
});
@@ -4047,6 +4054,54 @@ pub(in crate::set_disk::ops) mod hermetic_set_disks_support {
}
}
#[cfg(test)]
mod zero_length_encrypted_read_tests {
use super::hermetic_set_disks_support::hermetic_set_disks;
use super::*;
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
#[tokio::test]
async fn encrypted_zero_length_object_does_not_use_plain_empty_fast_path() {
let (_temp_dirs, _disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "zero-length-encrypted-read";
let object = "empty";
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(Vec::new());
set_disks
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("empty object should be written");
let metadata = HashMap::from([(rustfs_utils::http::SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]);
set_disks
.put_object_metadata(
bucket,
object,
&ObjectOptions {
eval_metadata: Some(metadata),
..Default::default()
},
)
.await
.expect("SSE-C metadata should be persisted");
let result = set_disks
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await;
let error = match result {
Ok(_) => panic!("encrypted empty object must validate SSE-C headers"),
Err(error) => error,
};
assert!(
error.to_string().contains("SSE-C") || error.to_string().contains("customer"),
"the encrypted read must fail at SSE-C validation, got: {error}"
);
}
}
#[cfg(test)]
mod metadata_mutation_generation_tests {
use super::hermetic_set_disks_support::hermetic_set_disks;
+914 -10
View File
@@ -12,6 +12,43 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit, Payload},
};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
use chacha20poly1305::ChaCha20Poly1305;
use hmac::{Hmac, Mac};
use rustfs_utils::http::{AMZ_SERVER_SIDE_ENCRYPTION, get_consistent_metadata_value};
use serde::Deserialize;
use sha2::Sha256;
use std::collections::HashMap;
#[cfg(not(any(test, debug_assertions)))]
use std::sync::OnceLock;
use thiserror::Error;
use zeroize::Zeroizing;
pub(crate) const DEFAULT_SSE_ALGORITHM: &str = "AES256";
const SSE_KMS_ALGORITHM: &str = "aws:kms";
pub(crate) const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id";
pub(crate) const INTERNAL_ENCRYPTION_KEY_HEADER: &str = "x-rustfs-encryption-key";
pub(crate) const INTERNAL_ENCRYPTION_IV_HEADER: &str = "x-rustfs-encryption-iv";
pub(crate) const AMZ_SERVER_SIDE_ENCRYPTION_KMS_KEY_ID: &str = "x-amz-server-side-encryption-aws-kms-key-id";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_IV_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Iv";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Kms-Sealed-Key";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER: &str =
"X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM: &str = "DAREv2-HMAC-SHA256";
const MINIO_STATIC_KMS_KEY_ENV: &str = "RUSTFS_MINIO_STATIC_KMS_KEY";
const MINIO_STATIC_KMS_RANDOM_SIZE: usize = 28;
const MINIO_STATIC_KMS_IV_SIZE: usize = 16;
const MINIO_STATIC_KMS_NONCE_SIZE: usize = 12;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManagedSseScheme {
SseS3,
@@ -21,26 +58,893 @@ pub enum ManagedSseScheme {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManagedDekProvider {
LocalSseS3,
MinioKeyValue,
Kms,
}
pub fn managed_dek_provider(scheme: ManagedSseScheme, has_kms_envelope: bool) -> ManagedDekProvider {
match scheme {
ManagedSseScheme::SseKms => ManagedDekProvider::Kms,
ManagedSseScheme::SseS3 if has_kms_envelope => ManagedDekProvider::Kms,
ManagedSseScheme::SseS3 => ManagedDekProvider::LocalSseS3,
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PersistedManagedEncryption {
LegacySseS3Local,
LegacySseS3Kms,
LegacySseKms,
MinioSseS3KeyValue,
MinioSseS3Kms,
MinioSseKmsKms,
}
impl PersistedManagedEncryption {
pub fn scheme(self) -> ManagedSseScheme {
match self {
Self::LegacySseS3Local | Self::LegacySseS3Kms | Self::MinioSseS3KeyValue | Self::MinioSseS3Kms => {
ManagedSseScheme::SseS3
}
Self::LegacySseKms | Self::MinioSseKmsKms => ManagedSseScheme::SseKms,
}
}
pub fn provider(self) -> ManagedDekProvider {
match self {
Self::LegacySseS3Local => ManagedDekProvider::LocalSseS3,
Self::MinioSseS3KeyValue => ManagedDekProvider::MinioKeyValue,
Self::LegacySseS3Kms | Self::LegacySseKms | Self::MinioSseS3Kms | Self::MinioSseKmsKms => ManagedDekProvider::Kms,
}
}
pub fn uses_object_key(self) -> bool {
matches!(self, Self::MinioSseS3KeyValue | Self::MinioSseS3Kms | Self::MinioSseKmsKms)
}
}
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum PersistedEncryptionError {
#[error("conflicting values for encryption metadata {field}")]
ConflictingValue { field: &'static str },
#[error("conflicting MinIO managed SSE format markers")]
ConflictingMinioMarkers,
#[error("incomplete managed SSE metadata: missing {field}")]
MissingField { field: &'static str },
#[error("unsupported stored server-side encryption {algorithm}")]
UnsupportedSseAlgorithm { algorithm: String },
#[error("unsupported MinIO seal algorithm {algorithm}")]
UnsupportedSealAlgorithm { algorithm: String },
#[error("stored SSE algorithm conflicts with the persisted encryption format")]
SchemeConflict,
#[error("conflicting encrypted data-key metadata")]
ConflictingEncryptedDataKey,
#[error("conflicting KMS key-id metadata")]
ConflictingKmsKeyId,
#[error("invalid MinIO static KMS configuration: {reason}")]
InvalidMinioStaticKmsConfiguration { reason: String },
#[error("invalid MinIO static KMS ciphertext: {reason}")]
InvalidMinioStaticKmsCiphertext { reason: String },
#[error("unrecognized legacy managed SSE encrypted data-key format")]
UnknownLegacyEnvelope,
}
pub fn classify_persisted_managed_encryption(
metadata: &HashMap<String, String>,
encrypted_dek: &[u8],
) -> Result<PersistedManagedEncryption, PersistedEncryptionError> {
let public_algorithm = consistent(metadata, AMZ_SERVER_SIDE_ENCRYPTION)?;
if let Some(algorithm) = public_algorithm
&& !matches!(algorithm, DEFAULT_SSE_ALGORITHM | SSE_KMS_ALGORITHM)
{
return Err(PersistedEncryptionError::UnsupportedSseAlgorithm {
algorithm: algorithm.to_string(),
});
}
let s3_sealed_key = consistent(metadata, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)?;
let kms_sealed_key = consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)?;
if s3_sealed_key.is_some() && kms_sealed_key.is_some() {
return Err(PersistedEncryptionError::ConflictingMinioMarkers);
}
if s3_sealed_key.is_some() || kms_sealed_key.is_some() {
if s3_sealed_key.is_some() {
require_non_empty(s3_sealed_key, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)?;
}
if kms_sealed_key.is_some() {
require_non_empty(kms_sealed_key, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)?;
}
require_non_empty(
consistent(metadata, MINIO_INTERNAL_ENCRYPTION_IV_HEADER)?,
MINIO_INTERNAL_ENCRYPTION_IV_HEADER,
)?;
let seal_algorithm = require_non_empty(
consistent(metadata, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER)?,
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
)?;
if seal_algorithm != MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM {
return Err(PersistedEncryptionError::UnsupportedSealAlgorithm {
algorithm: seal_algorithm.to_string(),
});
}
let kms_key_id = consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)?;
let encrypted_data_key = consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)?;
let has_kms_pair = match (kms_key_id, encrypted_data_key) {
(Some(key_id), Some(data_key)) => {
require_non_empty(Some(key_id), MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)?;
require_non_empty(Some(data_key), MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)?;
true
}
(None, None) => false,
(None, Some(_)) => {
return Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER,
});
}
(Some(_), None) => {
return Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER,
});
}
};
validate_encrypted_key_alias(metadata)?;
validate_kms_key_id_alias(metadata)?;
return match (s3_sealed_key, kms_sealed_key, public_algorithm) {
(Some(_), None, Some(SSE_KMS_ALGORITHM)) | (None, Some(_), Some(DEFAULT_SSE_ALGORITHM)) => {
Err(PersistedEncryptionError::SchemeConflict)
}
(Some(_), None, _) if !has_kms_pair => Ok(PersistedManagedEncryption::MinioSseS3KeyValue),
(Some(_), None, _) => Ok(PersistedManagedEncryption::MinioSseS3Kms),
(None, Some(_), _) if !has_kms_pair => Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER,
}),
(None, Some(_), _) => Ok(PersistedManagedEncryption::MinioSseKmsKms),
_ => Err(PersistedEncryptionError::ConflictingMinioMarkers),
};
}
if consistent(metadata, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER)?.is_some()
|| consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)?.is_some()
{
return Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
});
}
require_non_empty(consistent(metadata, INTERNAL_ENCRYPTION_KEY_HEADER)?, INTERNAL_ENCRYPTION_KEY_HEADER)?;
require_non_empty(consistent(metadata, INTERNAL_ENCRYPTION_IV_HEADER)?, INTERNAL_ENCRYPTION_IV_HEADER)?;
match public_algorithm {
Some(SSE_KMS_ALGORITHM) if is_local_sse_s3_envelope(encrypted_dek) => Err(PersistedEncryptionError::SchemeConflict),
Some(SSE_KMS_ALGORITHM) => Ok(PersistedManagedEncryption::LegacySseKms),
Some(DEFAULT_SSE_ALGORITHM) | None if rustfs_kms::is_data_key_envelope(encrypted_dek) => {
// RUSTFS_COMPAT_TODO(rustfs-5063): older SSE-S3 objects may contain KMS-wrapped DEKs. Remove after every referenced legacy DEK has been rewrapped with the local SSE-S3 provider.
Ok(PersistedManagedEncryption::LegacySseS3Kms)
}
Some(DEFAULT_SSE_ALGORITHM) | None if is_local_sse_s3_envelope(encrypted_dek) => {
Ok(PersistedManagedEncryption::LegacySseS3Local)
}
Some(DEFAULT_SSE_ALGORITHM) | None => Err(PersistedEncryptionError::UnknownLegacyEnvelope),
Some(_) => unreachable!("unsupported algorithms return before format classification"),
}
}
fn consistent<'a>(
metadata: &'a HashMap<String, String>,
field: &'static str,
) -> Result<Option<&'a str>, PersistedEncryptionError> {
get_consistent_metadata_value(metadata, field).map_err(|_| PersistedEncryptionError::ConflictingValue { field })
}
fn require_non_empty<'a>(value: Option<&'a str>, field: &'static str) -> Result<&'a str, PersistedEncryptionError> {
value
.filter(|value| !value.is_empty())
.ok_or(PersistedEncryptionError::MissingField { field })
}
fn validate_encrypted_key_alias(metadata: &HashMap<String, String>) -> Result<(), PersistedEncryptionError> {
let Some(rustfs_key) = consistent(metadata, INTERNAL_ENCRYPTION_KEY_HEADER)? else {
return Ok(());
};
let minio_key = require_non_empty(
consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)?,
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER,
)?;
if rustfs_key != minio_key {
return Err(PersistedEncryptionError::ConflictingEncryptedDataKey);
}
Ok(())
}
fn validate_kms_key_id_alias(metadata: &HashMap<String, String>) -> Result<(), PersistedEncryptionError> {
let values = [
consistent(metadata, INTERNAL_ENCRYPTION_KEY_ID_HEADER)?,
consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)?,
consistent(metadata, AMZ_SERVER_SIDE_ENCRYPTION_KMS_KEY_ID)?,
];
let mut present = values.into_iter().flatten().filter(|value| !value.is_empty());
let Some(first) = present.next() else {
return Ok(());
};
if present.any(|value| value != first) {
return Err(PersistedEncryptionError::ConflictingKmsKeyId);
}
Ok(())
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MinioStaticKmsJsonCiphertext {
#[serde(rename = "aead")]
algorithm: String,
id: Option<String>,
iv: String,
nonce: String,
bytes: String,
}
#[derive(Clone, Copy)]
enum MinioStaticKmsAlgorithm {
Aes256,
ChaCha20,
}
#[derive(Clone)]
struct ConfiguredMinioStaticKmsKey {
id: String,
material: Zeroizing<[u8; 32]>,
}
struct ParsedMinioStaticKmsCiphertext {
ciphertext: Vec<u8>,
iv: [u8; 16],
nonce: [u8; 12],
algorithm: MinioStaticKmsAlgorithm,
}
pub fn decrypt_minio_static_kms_dek(
kms_key_id: &str,
encrypted_dek: &[u8],
context: &HashMap<String, String>,
) -> Result<Option<[u8; 32]>, PersistedEncryptionError> {
let Some(configured_key) = minio_static_kms_key()? else {
return Ok(None);
};
if configured_key.id != kms_key_id {
return Ok(None);
}
let parsed = parse_minio_static_kms_ciphertext(encrypted_dek)?;
let ParsedMinioStaticKmsCiphertext {
ciphertext,
iv,
nonce,
algorithm,
} = parsed;
let master_key = configured_key.material;
let sealing_key: Zeroizing<[u8; 32]> = Zeroizing::new(match algorithm {
MinioStaticKmsAlgorithm::Aes256 => {
let mut mac = HmacSha256::new_from_slice(master_key.as_slice()).map_err(|err| {
PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("invalid HMAC key: {err}"),
}
})?;
mac.update(&iv);
mac.finalize().into_bytes().into()
}
MinioStaticKmsAlgorithm::ChaCha20 => chacha20::hchacha::<chacha20::R20>((&*master_key).into(), (&iv).into()).into(),
});
let associated_data = marshal_minio_kms_context(context);
let plaintext = Zeroizing::new(
match algorithm {
MinioStaticKmsAlgorithm::Aes256 => Aes256Gcm::new_from_slice(sealing_key.as_slice())
.map_err(|err| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("invalid AES sealing key: {err}"),
})?
.decrypt(
&Nonce::from(nonce),
Payload {
msg: &ciphertext,
aad: &associated_data,
},
),
MinioStaticKmsAlgorithm::ChaCha20 => ChaCha20Poly1305::new_from_slice(sealing_key.as_slice())
.map_err(|err| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("invalid ChaCha20 sealing key: {err}"),
})?
.decrypt(
&chacha20poly1305::Nonce::from(nonce),
Payload {
msg: &ciphertext,
aad: &associated_data,
},
),
}
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: "AEAD authentication failed".to_string(),
})?,
);
plaintext
.as_slice()
.try_into()
.map(Some)
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: "plaintext data key must be 32 bytes".to_string(),
})
}
fn minio_static_kms_key() -> Result<Option<ConfiguredMinioStaticKmsKey>, PersistedEncryptionError> {
#[cfg(not(any(test, debug_assertions)))]
{
static CONFIG: OnceLock<Result<Option<ConfiguredMinioStaticKmsKey>, PersistedEncryptionError>> = OnceLock::new();
return CONFIG.get_or_init(parse_minio_static_kms_key).clone();
}
#[cfg(any(test, debug_assertions))]
parse_minio_static_kms_key()
}
fn parse_minio_static_kms_key() -> Result<Option<ConfiguredMinioStaticKmsKey>, PersistedEncryptionError> {
let Some(value) = std::env::var_os(MINIO_STATIC_KMS_KEY_ENV) else {
return Ok(None);
};
let value =
Zeroizing::new(
value
.into_string()
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: format!("{MINIO_STATIC_KMS_KEY_ENV} must be valid UTF-8"),
})?,
);
let (key_id, encoded_key) =
value
.split_once(':')
.ok_or_else(|| PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: format!("{MINIO_STATIC_KMS_KEY_ENV} must use <key-id>:<base64-key>"),
})?;
if key_id.is_empty() {
return Err(PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: "key ID must not be empty".to_string(),
});
}
let decoded_key = Zeroizing::new(BASE64_STANDARD.decode(encoded_key).map_err(|_| {
PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: "key material must be valid Base64".to_string(),
}
})?);
let key: [u8; 32] =
decoded_key
.as_slice()
.try_into()
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: "key material must decode to exactly 32 bytes".to_string(),
})?;
if key == [0u8; 32] {
return Err(PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: "key material must not be all zero".to_string(),
});
}
Ok(Some(ConfiguredMinioStaticKmsKey {
id: key_id.to_string(),
material: Zeroizing::new(key),
}))
}
fn parse_minio_static_kms_ciphertext(encrypted_dek: &[u8]) -> Result<ParsedMinioStaticKmsCiphertext, PersistedEncryptionError> {
if encrypted_dek.first() == Some(&b'{') && encrypted_dek.last() == Some(&b'}') {
let envelope: MinioStaticKmsJsonCiphertext =
serde_json::from_slice(encrypted_dek).map_err(|err| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("invalid legacy JSON: {err}"),
})?;
let _ = envelope.id;
let algorithm = match envelope.algorithm.as_str() {
"AES-256-GCM-HMAC-SHA-256" => MinioStaticKmsAlgorithm::Aes256,
"ChaCha20Poly1305" => MinioStaticKmsAlgorithm::ChaCha20,
algorithm => {
return Err(PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("unsupported algorithm {algorithm}"),
});
}
};
return Ok(ParsedMinioStaticKmsCiphertext {
ciphertext: decode_minio_static_kms_field("bytes", &envelope.bytes)?,
iv: decode_minio_static_kms_array("iv", &envelope.iv)?,
nonce: decode_minio_static_kms_array("nonce", &envelope.nonce)?,
algorithm,
});
}
if encrypted_dek.len() <= MINIO_STATIC_KMS_RANDOM_SIZE {
return Err(PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: "binary ciphertext is too short".to_string(),
});
}
let split_at = encrypted_dek.len() - MINIO_STATIC_KMS_RANDOM_SIZE;
let (ciphertext, random) = encrypted_dek.split_at(split_at);
Ok(ParsedMinioStaticKmsCiphertext {
ciphertext: ciphertext.to_vec(),
iv: random[..MINIO_STATIC_KMS_IV_SIZE].try_into().map_err(|_| {
PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: "invalid binary IV length".to_string(),
}
})?,
nonce: random[MINIO_STATIC_KMS_IV_SIZE..MINIO_STATIC_KMS_IV_SIZE + MINIO_STATIC_KMS_NONCE_SIZE]
.try_into()
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: "invalid binary nonce length".to_string(),
})?,
algorithm: MinioStaticKmsAlgorithm::Aes256,
})
}
fn decode_minio_static_kms_field(field: &'static str, value: &str) -> Result<Vec<u8>, PersistedEncryptionError> {
BASE64_STANDARD
.decode(value)
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("{field} must be valid Base64"),
})
}
fn decode_minio_static_kms_array<const N: usize>(field: &'static str, value: &str) -> Result<[u8; N], PersistedEncryptionError> {
decode_minio_static_kms_field(field, value)?.try_into().map_err(|_| {
PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("{field} must decode to exactly {N} bytes"),
}
})
}
fn marshal_minio_kms_context(context: &HashMap<String, String>) -> Vec<u8> {
let mut entries: Vec<_> = context.iter().collect();
entries.sort_by_key(|(key, _)| *key);
let mut json = String::from("{");
for (index, (key, value)) in entries.into_iter().enumerate() {
if index > 0 {
json.push(',');
}
push_minio_json_string(&mut json, key);
json.push(':');
push_minio_json_string(&mut json, value);
}
json.push('}');
json.into_bytes()
}
fn push_minio_json_string(output: &mut String, value: &str) {
output.push('"');
for character in value.chars() {
match character {
'"' => output.push_str("\\\""),
'\\' => output.push_str("\\\\"),
'\n' => output.push_str("\\n"),
'\r' => output.push_str("\\r"),
'\t' => output.push_str("\\t"),
'<' => output.push_str("\\u003c"),
'>' => output.push_str("\\u003e"),
'&' => output.push_str("\\u0026"),
'\u{2028}' => output.push_str("\\u2028"),
'\u{2029}' => output.push_str("\\u2029"),
character if character <= '\u{1f}' => {
const HEX: &[u8; 16] = b"0123456789abcdef";
let byte = character as u8;
output.push_str("\\u00");
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
character => output.push(character),
}
}
output.push('"');
}
fn is_local_sse_s3_envelope(encrypted_dek: &[u8]) -> bool {
let Ok(encoded) = std::str::from_utf8(encrypted_dek) else {
return false;
};
let Some((nonce, ciphertext)) = encoded.split_once(':') else {
return false;
};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
BASE64_STANDARD.decode(nonce).is_ok_and(|nonce| nonce.len() == 12)
&& BASE64_STANDARD
.decode(ciphertext)
.is_ok_and(|ciphertext| ciphertext.len() == 48)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
fn local_envelope() -> Vec<u8> {
format!("{}:{}", BASE64_STANDARD.encode([1u8; 12]), BASE64_STANDARD.encode([2u8; 48])).into_bytes()
}
fn kms_envelope() -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"key_id": "data-key",
"master_key_id": "master-key",
"key_spec": "AES_256",
"encrypted_key": [1, 2, 3, 4],
"nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
"encryption_context": {},
"created_at": "2024-01-01T00:00:00+00:00"
}))
.expect("serialize KMS envelope fixture")
}
fn legacy_metadata(algorithm: Option<&str>, encrypted_dek: &[u8]) -> HashMap<String, String> {
let mut metadata = HashMap::from([
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), BASE64_STANDARD.encode(encrypted_dek)),
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([3u8; 12])),
]);
if let Some(algorithm) = algorithm {
metadata.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), algorithm.to_string());
}
metadata
}
fn minio_metadata(kms: bool, encrypted_dek: &[u8]) -> HashMap<String, String> {
let object_key_header = if kms {
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER
} else {
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER
};
let mut metadata = HashMap::from([
(object_key_header.to_string(), BASE64_STANDARD.encode([4u8; 64])),
(
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(encrypted_dek),
),
(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "default".to_string()),
(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([5u8; 32])),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
),
]);
metadata.insert(
AMZ_SERVER_SIDE_ENCRYPTION.to_string(),
if kms { SSE_KMS_ALGORITHM } else { DEFAULT_SSE_ALGORITHM }.to_string(),
);
metadata
}
#[test]
fn managed_sse_routing_is_determined_by_scheme_and_envelope() {
assert_eq!(managed_dek_provider(ManagedSseScheme::SseS3, false), ManagedDekProvider::LocalSseS3);
assert_eq!(managed_dek_provider(ManagedSseScheme::SseS3, true), ManagedDekProvider::Kms);
assert_eq!(managed_dek_provider(ManagedSseScheme::SseKms, false), ManagedDekProvider::Kms);
assert_eq!(managed_dek_provider(ManagedSseScheme::SseKms, true), ManagedDekProvider::Kms);
fn classifies_released_legacy_formats() {
let local = local_envelope();
let kms = kms_envelope();
assert_eq!(
classify_persisted_managed_encryption(&legacy_metadata(Some(DEFAULT_SSE_ALGORITHM), &local), &local)
.expect("classify local Direct format"),
PersistedManagedEncryption::LegacySseS3Local
);
assert_eq!(
classify_persisted_managed_encryption(&legacy_metadata(Some(DEFAULT_SSE_ALGORITHM), &kms), &kms)
.expect("classify legacy SSE-S3 KMS envelope"),
PersistedManagedEncryption::LegacySseS3Kms
);
assert_eq!(
classify_persisted_managed_encryption(
&legacy_metadata(Some(SSE_KMS_ALGORITHM), b"opaque-kms-key"),
b"opaque-kms-key"
)
.expect("classify opaque SSE-KMS envelope"),
PersistedManagedEncryption::LegacySseKms
);
}
#[test]
fn minio_markers_override_envelope_shape() {
let local = local_envelope();
let kms = kms_envelope();
assert_eq!(
classify_persisted_managed_encryption(&minio_metadata(false, &kms), &kms)
.expect("SSE-S3 marker selects MinIO SSE-S3"),
PersistedManagedEncryption::MinioSseS3Kms
);
assert_eq!(
classify_persisted_managed_encryption(&minio_metadata(true, &local), &local)
.expect("SSE-KMS marker selects MinIO SSE-KMS"),
PersistedManagedEncryption::MinioSseKmsKms
);
}
#[test]
fn accepts_minio_sse_s3_key_value_only_when_kms_pair_is_absent() {
let local = local_envelope();
let mut metadata = minio_metadata(false, &local);
metadata.remove(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER);
metadata.remove(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&metadata, &[]).expect("classify MinIO SSE-S3 K/V metadata"),
PersistedManagedEncryption::MinioSseS3KeyValue
);
metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), String::new());
metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), String::new());
assert_eq!(
classify_persisted_managed_encryption(&metadata, &[]),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER
})
);
}
#[test]
fn rejects_minio_sse_kms_without_kms_pair() {
let local = local_envelope();
let mut metadata = minio_metadata(true, &local);
metadata.remove(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER);
metadata.remove(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&metadata, &[]),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER
})
);
}
#[test]
fn persisted_minio_provider_does_not_depend_on_runtime_kms_availability() {
let static_kms = br#"{"aead":"AES-256-GCM-HMAC-SHA-256","iv":"AQEBAQEBAQEBAQEBAQEBAQ==","nonce":"AgICAgICAgICAgIC","bytes":"AwMDAw=="}"#;
let opaque_kms = b"opaque-external-kms-ciphertext";
assert_eq!(
classify_persisted_managed_encryption(&minio_metadata(false, static_kms), static_kms)
.expect("classify MinIO static KMS envelope")
.provider(),
ManagedDekProvider::Kms
);
assert_eq!(
classify_persisted_managed_encryption(&minio_metadata(true, opaque_kms), opaque_kms)
.expect("classify external KMS ciphertext")
.provider(),
ManagedDekProvider::Kms
);
let mut key_value = minio_metadata(false, opaque_kms);
key_value.remove(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER);
key_value.remove(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&key_value, &[])
.expect("classify historical K/V provider")
.provider(),
ManagedDekProvider::MinioKeyValue
);
}
#[test]
fn decrypts_minio_static_kms_binary_aes_ciphertext() {
let master_key = [0x31; 32];
let plaintext_key = [0x52; 32];
let iv = [0x63; 16];
let nonce = [0x74; 12];
let context = HashMap::from([("bucket".to_string(), "bucket/object".to_string())]);
let mut mac = HmacSha256::new_from_slice(&master_key).expect("valid master key");
mac.update(&iv);
let sealing_key = mac.finalize().into_bytes();
let ciphertext = Aes256Gcm::new_from_slice(sealing_key.as_slice())
.expect("valid sealing key")
.encrypt(
&Nonce::from(nonce),
Payload {
msg: &plaintext_key,
aad: &marshal_minio_kms_context(&context),
},
)
.expect("encrypt fixture");
let mut envelope = ciphertext;
envelope.extend_from_slice(&iv);
envelope.extend_from_slice(&nonce);
let configured_key = format!("minio-key:{}", BASE64_STANDARD.encode(master_key));
temp_env::with_var(MINIO_STATIC_KMS_KEY_ENV, Some(configured_key), || {
assert_eq!(
decrypt_minio_static_kms_dek("minio-key", &envelope, &context).expect("decrypt binary ciphertext"),
Some(plaintext_key)
);
assert_eq!(
decrypt_minio_static_kms_dek("external-key", &envelope, &context)
.expect("different key ID must remain external KMS"),
None
);
});
}
#[test]
fn decrypts_official_minio_legacy_chacha20_ciphertext() {
let configured_key = "my-key:eEm+JI9/q4JhH8QwKvf3LKo4DEBl6QbfvAl1CAbMIv8=";
let ciphertext = br#"{"aead":"ChaCha20Poly1305","iv":"JbI+vwvYww1lCb5VpkAFuQ==","nonce":"ARjIjJxBSD541Gz8","bytes":"KCbEc2sA0TLvA7aWTWa23AdccVfJMpOxwgG8hm+4PaNrxYfy1xFWZg2gEenVrOgv"}"#;
let expected: [u8; 32] = BASE64_STANDARD
.decode("zmS7NrG765UZ0ZN85oPjybelxqVvpz01vxsSpOISy2M=")
.expect("decode official plaintext")
.try_into()
.expect("official plaintext is 32 bytes");
temp_env::with_var(MINIO_STATIC_KMS_KEY_ENV, Some(configured_key), || {
assert_eq!(
decrypt_minio_static_kms_dek("my-key", ciphertext, &HashMap::new()).expect("decrypt official MinIO ciphertext"),
Some(expected)
);
});
}
#[test]
fn rejects_malformed_config_and_unknown_legacy_fields() {
temp_env::with_var(MINIO_STATIC_KMS_KEY_ENV, Some("missing-separator"), || {
assert!(matches!(
decrypt_minio_static_kms_dek("my-key", b"ciphertext", &HashMap::new()),
Err(PersistedEncryptionError::InvalidMinioStaticKmsConfiguration { .. })
));
});
let configured_key = format!("my-key:{}", BASE64_STANDARD.encode([0x31; 32]));
let ciphertext =
br#"{"aead":"AES-256-GCM-HMAC-SHA-256","iv":"Y2NjY2NjY2NjY2NjY2NjYw==","nonce":"dHR0dHR0dHR0dHR0","bytes":"AA==","extra":true}"#;
temp_env::with_var(MINIO_STATIC_KMS_KEY_ENV, Some(configured_key), || {
assert!(matches!(
decrypt_minio_static_kms_dek("my-key", ciphertext, &HashMap::new()),
Err(PersistedEncryptionError::InvalidMinioStaticKmsCiphertext { .. })
));
});
}
#[test]
fn rejects_all_zero_minio_static_kms_key() {
let configured_key = format!("my-key:{}", BASE64_STANDARD.encode([0u8; 32]));
temp_env::with_var(MINIO_STATIC_KMS_KEY_ENV, Some(configured_key), || {
assert!(matches!(
decrypt_minio_static_kms_dek("my-key", b"ciphertext", &HashMap::new()),
Err(PersistedEncryptionError::InvalidMinioStaticKmsConfiguration { .. })
));
});
}
#[test]
fn marshals_minio_kms_context_with_go_json_escaping() {
let context = HashMap::from([
("z".to_string(), "line\n".to_string()),
("<\u{8}".to_string(), "&\u{2028}".to_string()),
]);
assert_eq!(marshal_minio_kms_context(&context), br#"{"\u003c\u0008":"\u0026\u2028","z":"line\n"}"#);
}
#[test]
fn rejects_conflicting_or_partial_minio_metadata() {
let local = local_envelope();
let mut both = minio_metadata(false, &local);
both.insert(
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode([6u8; 64]),
);
assert_eq!(
classify_persisted_managed_encryption(&both, &local),
Err(PersistedEncryptionError::ConflictingMinioMarkers)
);
let mut wrong_scheme = minio_metadata(true, &local);
wrong_scheme.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), DEFAULT_SSE_ALGORITHM.to_string());
assert_eq!(
classify_persisted_managed_encryption(&wrong_scheme, &local),
Err(PersistedEncryptionError::SchemeConflict)
);
let mut partial = minio_metadata(false, &local);
partial.remove(MINIO_INTERNAL_ENCRYPTION_IV_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&partial, &local),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_IV_HEADER
})
);
let mut missing_key_id = minio_metadata(false, &local);
missing_key_id.remove(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&missing_key_id, &local),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER
})
);
let mut missing_data_key = minio_metadata(false, &local);
missing_data_key.remove(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&missing_data_key, &local),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER
})
);
let mut unknown_algorithm = minio_metadata(false, &local);
unknown_algorithm.insert(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
"future-seal-algorithm".to_string(),
);
assert_eq!(
classify_persisted_managed_encryption(&unknown_algorithm, &local),
Err(PersistedEncryptionError::UnsupportedSealAlgorithm {
algorithm: "future-seal-algorithm".to_string()
})
);
for field in [
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER,
] {
let mut empty = minio_metadata(false, &local);
empty.insert(field.to_string(), String::new());
assert_eq!(
classify_persisted_managed_encryption(&empty, &local),
Err(PersistedEncryptionError::MissingField { field })
);
}
for field in [
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER,
] {
let metadata = HashMap::from([(field.to_string(), "orphaned".to_string())]);
assert_eq!(
classify_persisted_managed_encryption(&metadata, &local),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER
})
);
}
}
#[test]
fn rejects_unknown_legacy_envelope_and_conflicting_alias() {
let unknown = b"not-an-envelope";
assert_eq!(
classify_persisted_managed_encryption(&legacy_metadata(Some(DEFAULT_SSE_ALGORITHM), unknown), unknown),
Err(PersistedEncryptionError::UnknownLegacyEnvelope)
);
let local = local_envelope();
let mut conflicting = minio_metadata(false, &local);
conflicting.insert(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), BASE64_STANDARD.encode(b"different"));
assert_eq!(
classify_persisted_managed_encryption(&conflicting, &local),
Err(PersistedEncryptionError::ConflictingEncryptedDataKey)
);
let mut conflicting_key_id = minio_metadata(false, &local);
conflicting_key_id.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), "different-key".to_string());
assert_eq!(
classify_persisted_managed_encryption(&conflicting_key_id, &local),
Err(PersistedEncryptionError::ConflictingKmsKeyId)
);
assert_eq!(
classify_persisted_managed_encryption(&legacy_metadata(Some(SSE_KMS_ALGORITHM), &local), &local),
Err(PersistedEncryptionError::SchemeConflict)
);
}
#[test]
fn accepts_case_insensitive_minio_markers_and_rejects_conflicting_duplicates() {
let local = local_envelope();
let mut metadata = minio_metadata(false, &local);
let sealed_key = metadata
.remove(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)
.expect("MinIO fixture contains the sealed-key marker");
metadata.insert(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_ascii_lowercase(), sealed_key);
assert_eq!(
classify_persisted_managed_encryption(&metadata, &local).expect("classify mixed-case MinIO metadata"),
PersistedManagedEncryption::MinioSseS3Kms
);
metadata.insert(
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode([0x77u8; 64]),
);
assert_eq!(
classify_persisted_managed_encryption(&metadata, &local),
Err(PersistedEncryptionError::ConflictingValue {
field: MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER
})
);
}
}
+1
View File
@@ -642,6 +642,7 @@ mod tests {
stream: Box::new(Cursor::new(self.read_payload.clone())),
object_info: self.object_info(bucket, object, self.read_payload.len()),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}
+4
View File
@@ -2646,6 +2646,7 @@ mod tests {
stream: Box::new(Cursor::new(Vec::<u8>::new())),
object_info: ObjectInfo::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
};
@@ -2686,6 +2687,7 @@ mod tests {
stream: Box::new(Cursor::new(vec![1, 2, 3])),
object_info: ObjectInfo::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
};
@@ -2723,6 +2725,7 @@ mod tests {
stream: Box::new(Cursor::new(vec![1, 2, 3])),
object_info: ObjectInfo::default(),
buffered_body: Some(Bytes::from_static(b"123")),
resolved_sse: None,
body_source: Default::default(),
};
@@ -2760,6 +2763,7 @@ mod tests {
stream: Box::new(Cursor::new(vec![1, 2, 3])),
object_info: ObjectInfo::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
};
+169 -52
View File
@@ -7,10 +7,12 @@ use std::path::{Path, PathBuf};
mod storage_api;
use rustfs_filemeta::{FileInfo, FileInfoOpts, get_file_info};
use rustfs_utils::HashAlgorithm;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use storage_api::minio_generated_read::{
DiskAPI as _, DiskOption, Endpoint, Erasure, GetObjectReader, ObjectInfo, ObjectOptions, create_bitrot_reader, new_disk,
DiskAPI as _, DiskOption, Endpoint, Erasure, GetObjectReader, HTTPRangeSpec, ObjectInfo, ObjectOptions, create_bitrot_reader,
new_disk,
};
use temp_env::async_with_vars;
use tokio::io::{AsyncReadExt, AsyncWrite};
@@ -22,6 +24,11 @@ struct ManifestRecord {
backend_files: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RequestRecord {
headers: std::collections::HashMap<String, String>,
}
#[derive(Default)]
struct VecAsyncWriter {
bytes: Vec<u8>,
@@ -56,21 +63,17 @@ fn case_dir(case_id: &str) -> PathBuf {
fixture_root().join("cases").join(case_id)
}
fn beta5_fixture_root() -> PathBuf {
std::env::var_os("RUSTFS_BETA5_FIXTURE_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../rio-v2/tests/fixtures/rustfs-beta5-generated"))
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> T {
let text = fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
serde_json::from_str(&text).unwrap_or_else(|err| panic!("parse {}: {err}", path.display()))
}
fn require_fixture_case(case_id: &str) -> PathBuf {
let path = case_dir(case_id);
assert!(
path.is_dir(),
"fixture case missing: {}. Run scripts/minio_fixture_lab/lab.py capture-matrix first.",
path.display()
);
path
}
fn read_plaintext_sha256(case_dir: &Path) -> String {
fs::read_to_string(case_dir.join("plaintext.sha256"))
.unwrap_or_else(|err| panic!("read plaintext.sha256 under {}: {err}", case_dir.display()))
@@ -78,9 +81,9 @@ fn read_plaintext_sha256(case_dir: &Path) -> String {
.to_string()
}
fn minio_static_kms_key_b64() -> String {
std::env::var("RUSTFS_MINIO_STATIC_KMS_KEY_B64")
.unwrap_or_else(|_| panic!("RUSTFS_MINIO_STATIC_KMS_KEY_B64 must point to the 32-byte static MinIO KMS key"))
fn minio_static_kms_key() -> String {
std::env::var("RUSTFS_MINIO_STATIC_KMS_KEY")
.unwrap_or_else(|_| panic!("RUSTFS_MINIO_STATIC_KMS_KEY must use <key-id>:<base64-32-byte-key>"))
}
fn object_xl_meta_path(case_dir: &Path, manifest: &ManifestRecord) -> PathBuf {
@@ -117,37 +120,53 @@ fn sha256_hex(bytes: &[u8]) -> String {
hex_simd::encode_to_string(Sha256::digest(bytes), hex_simd::AsciiCase::Lower)
}
async fn load_fixture_reader_input(case_id: &str) -> (ObjectInfo, Vec<u8>, String) {
let case_dir = require_fixture_case(case_id);
async fn load_fixture_reader_input(case_id: &str) -> (ObjectInfo, Vec<u8>, String, http::HeaderMap) {
load_fixture_reader_input_from(case_dir(case_id)).await
}
async fn load_fixture_reader_input_from(case_dir: PathBuf) -> (ObjectInfo, Vec<u8>, String, http::HeaderMap) {
assert!(case_dir.is_dir(), "fixture case missing: {}", case_dir.display());
let manifest: ManifestRecord = read_json(&case_dir.join("manifest.json"));
let request: RequestRecord = read_json(&case_dir.join("request.json"));
let expected_sha256 = read_plaintext_sha256(&case_dir);
let file_info = load_file_info(&case_dir, &manifest);
let encrypted = encrypted_fixture_bytes(&case_dir, &manifest, &file_info).await;
let object_info = load_object_info(&file_info, &manifest);
(object_info, encrypted, expected_sha256)
let mut headers = http::HeaderMap::new();
for (name, value) in request.headers {
let name = http::header::HeaderName::from_bytes(name.as_bytes())
.unwrap_or_else(|err| panic!("invalid fixture request header {name}: {err}"));
let value =
http::HeaderValue::try_from(value).unwrap_or_else(|err| panic!("invalid fixture request header value: {err}"));
headers.insert(name, value);
}
(object_info, encrypted, expected_sha256, headers)
}
async fn read_fixture_plaintext(encrypted: Vec<u8>, object_info: ObjectInfo, kms_key_b64: String) -> Result<Vec<u8>, String> {
async fn read_fixture_plaintext(
encrypted: Vec<u8>,
object_info: ObjectInfo,
headers: http::HeaderMap,
static_kms_key: Option<String>,
range: Option<HTTPRangeSpec>,
) -> Result<Vec<u8>, String> {
let object_size = object_info.size;
let full_object = range.is_none();
async_with_vars(
[
("RUSTFS_SSE_S3_MASTER_KEY", Some(kms_key_b64)),
("RUSTFS_MINIO_STATIC_KMS_KEY", static_kms_key),
("__RUSTFS_SSE_SIMPLE_CMK", None::<String>),
],
async move {
let (mut reader, offset, length) = GetObjectReader::new(
Box::new(Cursor::new(encrypted)),
None,
&object_info,
&ObjectOptions::default(),
&http::HeaderMap::new(),
)
.await
.map_err(|err| format!("construct GetObjectReader from MinIO raw fixture: {err:?}"))?;
let (mut reader, offset, length) =
GetObjectReader::new(Box::new(Cursor::new(encrypted)), range, &object_info, &ObjectOptions::default(), &headers)
.await
.map_err(|err| format!("construct GetObjectReader from MinIO raw fixture: {err:?}"))?;
if offset != 0 || length != object_size {
if full_object && (offset != 0 || length != object_size) {
return Err(format!("unexpected fixture range offset={offset} length={length} size={object_size}"));
}
@@ -164,6 +183,12 @@ async fn read_fixture_plaintext(encrypted: Vec<u8>, object_info: ObjectInfo, kms
}
async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, file_info: &FileInfo) -> Vec<u8> {
let erasure = Erasure::new_with_options(
file_info.erasure.data_blocks,
file_info.erasure.parity_blocks,
file_info.erasure.block_size,
file_info.uses_legacy_checksum,
);
let mut disks = Vec::with_capacity(file_info.erasure.distribution.len());
for disk_number in 1..=file_info.erasure.distribution.len() {
let disk_root = case_dir.join("backend").join(format!("disk{disk_number}"));
@@ -198,8 +223,13 @@ async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, fil
let mut encrypted = Vec::new();
for part in &file_info.parts {
let checksum_info = file_info.erasure.get_checksum_info(part.number);
let checksum_algorithm = if file_info.uses_legacy_checksum && checksum_info.algorithm == HashAlgorithm::HighwayHash256S {
HashAlgorithm::HighwayHash256SLegacy
} else {
checksum_info.algorithm.clone()
};
let path = format!("{}/{}/part.{}", manifest.object, data_dir, part.number);
let shard_read_len = file_info.erasure.shard_file_size(part.size as i64);
let shard_read_len = erasure.shard_file_size(part.size as i64);
let mut readers = Vec::with_capacity(disks.len());
for (idx, disk) in disk_order.iter().enumerate() {
let reader = create_bitrot_reader(
@@ -210,7 +240,7 @@ async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, fil
0,
shard_read_len as usize,
file_info.erasure.shard_size(),
checksum_info.algorithm.clone(),
checksum_algorithm.clone(),
false,
false,
)
@@ -219,11 +249,6 @@ async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, fil
readers.push(reader);
}
let erasure = Erasure::new(
file_info.erasure.data_blocks,
file_info.erasure.parity_blocks,
file_info.erasure.block_size,
);
let mut writer = VecAsyncWriter::default();
let (written, err) = erasure.decode(&mut writer, readers, 0, part.size, part.size).await;
if let Some(err) = err {
@@ -244,6 +269,12 @@ async fn reads_minio_generated_sse_s3_multipart_fixture() {
assert_fixture_round_trip("sse-s3-multipart-8m", 8 * 1024 * 1024).await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_s3_singlepart_fixture() {
assert_fixture_round_trip("sse-s3-singlepart-64k", 64 * 1024).await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_kms_multipart_fixture() {
@@ -252,11 +283,47 @@ async fn reads_minio_generated_sse_kms_multipart_fixture() {
#[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() {
let (object_info, encrypted, _) = load_fixture_reader_input("sse-s3-multipart-8m").await;
let wrong_key_b64 = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=".to_string();
async fn reads_minio_generated_sse_kms_singlepart_fixture() {
assert_fixture_round_trip("sse-kms-singlepart-64k", 64 * 1024).await;
}
let result = read_fixture_plaintext(encrypted, object_info, wrong_key_b64).await;
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_c_multipart_fixture() {
assert_fixture_round_trip("sse-c-multipart-8m", 8 * 1024 * 1024).await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_c_singlepart_fixture() {
assert_fixture_round_trip("sse-c-singlepart-64k", 64 * 1024).await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_s3_range_fixture() {
assert_fixture_range_round_trip("sse-s3-multipart-8m").await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_kms_range_fixture() {
assert_fixture_range_round_trip("sse-kms-multipart-8m").await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_c_range_fixture() {
assert_fixture_range_round_trip("sse-c-multipart-8m").await;
}
#[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() {
let (object_info, encrypted, _, headers) = load_fixture_reader_input("sse-s3-multipart-8m").await;
let wrong_key_b64 = "minio-default-key:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=".to_string();
let result = read_fixture_plaintext(encrypted, object_info, headers, Some(wrong_key_b64), None).await;
assert!(result.is_err(), "wrong KMS key must fail closed");
}
@@ -264,22 +331,16 @@ async fn rejects_minio_generated_sse_s3_fixture_with_wrong_kms_key() {
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn rejects_minio_generated_sse_s3_fixture_with_truncated_ciphertext() {
let (object_info, mut encrypted, expected_sha256) = load_fixture_reader_input("sse-s3-multipart-8m").await;
let (object_info, mut encrypted, _, headers) = load_fixture_reader_input("sse-s3-multipart-8m").await;
encrypted.truncate(encrypted.len() / 2);
let result = read_fixture_plaintext(encrypted, object_info, minio_static_kms_key_b64()).await;
let result = read_fixture_plaintext(encrypted, object_info, headers, Some(minio_static_kms_key()), None).await;
if let Ok(plaintext) = result {
assert_ne!(
sha256_hex(&plaintext),
expected_sha256,
"truncated ciphertext must not restore the original plaintext"
);
}
assert!(result.is_err(), "truncated ciphertext must return an explicit read error");
}
async fn assert_fixture_round_trip(case_id: &str, expected_size: i64) {
let (object_info, encrypted, expected_sha256) = load_fixture_reader_input(case_id).await;
let (object_info, encrypted, expected_sha256, headers) = load_fixture_reader_input(case_id).await;
// `ObjectInfo.size` is the on-disk size. For SSE objects that is the
// DARE-encrypted size (plaintext + 32 bytes per 64 KiB block), which is
// deliberately larger than the logical object size. The size a client sees
@@ -287,9 +348,9 @@ async fn assert_fixture_round_trip(case_id: &str, expected_size: i64) {
// `decrypted_size()`/`get_actual_size()`, so assert against that — the raw
// `size` field would never equal the plaintext length for encrypted objects.
let decrypted_size = object_info.decrypted_size().expect("decrypted size from MinIO metadata");
let kms_key_b64 = minio_static_kms_key_b64();
let kms_key = minio_static_kms_key();
let plaintext = read_fixture_plaintext(encrypted, object_info, kms_key_b64)
let plaintext = read_fixture_plaintext(encrypted, object_info, headers, Some(kms_key), None)
.await
.expect("fixture must restore with the configured KMS key");
@@ -297,3 +358,59 @@ async fn assert_fixture_round_trip(case_id: &str, expected_size: i64) {
assert_eq!(plaintext.len(), expected_size as usize);
assert_eq!(sha256_hex(&plaintext), expected_sha256);
}
async fn assert_fixture_range_round_trip(case_id: &str) {
const START: usize = 65_520;
const END: usize = 65_680;
let (object_info, encrypted, _, headers) = load_fixture_reader_input(case_id).await;
let kms_key = minio_static_kms_key();
let plaintext = read_fixture_plaintext(encrypted.clone(), object_info.clone(), headers.clone(), Some(kms_key.clone()), None)
.await
.expect("fixture full read must restore");
let ranged = read_fixture_plaintext(
encrypted,
object_info,
headers,
Some(kms_key),
Some(HTTPRangeSpec {
is_suffix_length: false,
start: START as i64,
end: END as i64,
}),
)
.await
.expect("fixture range read must restore");
assert_eq!(ranged, plaintext[START..=END]);
}
#[tokio::test]
#[ignore = "requires a fixture generated by the pinned RustFS beta.5 release"]
async fn reads_real_rustfs_beta5_sse_kms_fixture_through_production_reader() {
const CASE_ID: &str = "rustfs-beta5-sse-kms-singlepart-64k";
const KMS_KEY_ID: &str = "beta5-test-key";
let root = beta5_fixture_root();
let manager = rustfs_kms::init_global_kms_service_manager();
manager
.configure(
rustfs_kms::KmsConfig::local(root.join("kms"))
.with_default_key(KMS_KEY_ID.to_string())
.with_insecure_development_defaults(),
)
.await
.expect("configure production local KMS for beta.5 fixture");
manager.start().await.expect("start production local KMS for beta.5 fixture");
let (object_info, encrypted, expected_sha256, headers) =
load_fixture_reader_input_from(root.join("cases").join(CASE_ID)).await;
let expected_size = object_info.decrypted_size().expect("beta.5 encrypted object size");
let plaintext = read_fixture_plaintext(encrypted, object_info, headers, None, None)
.await
.expect("current production GET reader must decrypt the real beta.5 KMS fixture");
assert_eq!(expected_size, 64 * 1024);
assert_eq!(plaintext.len() as i64, expected_size);
assert_eq!(sha256_hex(&plaintext), expected_sha256);
}
+1
View File
@@ -36,6 +36,7 @@ pub(crate) mod legacy_bitrot_read {
}
pub(crate) mod minio_generated_read {
pub(crate) use super::storage_contracts::HTTPRangeSpec;
pub(crate) use super::{
DiskAPI, DiskOption, Endpoint, Erasure, GetObjectReader, ObjectInfo, ObjectOptions, create_bitrot_reader, new_disk,
};
@@ -136,7 +136,7 @@ tests read):
# Pass case ids to override, or "all" for the full default matrix.
./capture_via_docker.sh
RUSTFS_MINIO_STATIC_KMS_KEY_B64=IyqsU3kMFloCNup4BsZtf/rmfHVcTgznO2F25CkEH1g= \
RUSTFS_MINIO_STATIC_KMS_KEY=minio-default-key:IyqsU3kMFloCNup4BsZtf/rmfHVcTgznO2F25CkEH1g= \
cargo test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored
```
@@ -145,6 +145,28 @@ This is exactly what the nightly `minio-interop` GitHub Actions workflow runs
SSE-C cases still need the host-`minio` + TLS path above; the Docker helper
targets the SSE-S3 / SSE-KMS multipart cases the interop tests assert on.
## RustFS beta.5 KMS Compatibility Fixture
The same CI gate also downloads the pinned RustFS `1.0.0-beta.5` release,
verifies its published archive SHA-256, starts it with the production local KMS
backend, writes a real SSE-KMS object, and exports the resulting four-disk
backend plus its one-time KMS key directory:
```bash
uv run python ./capture_rustfs_beta5.py
RUSTFS_BETA5_FIXTURE_ROOT=../fixtures/rustfs-beta5-generated \
cargo test -p rustfs-ecstore --features rio-v2 \
--test minio_generated_read_test \
reads_real_rustfs_beta5_sse_kms_fixture_through_production_reader \
-- --ignored
```
Outside Linux x86_64, pass `--rustfs-binary` with the matching beta.5 binary.
The generated fixture and KMS key stay under the ignored fixture root and are
recreated for every CI run; neither key material nor plaintext keys are logged
or committed.
## Capture Guidance
For each case, preserve these inputs when possible:
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import platform
import secrets
import shutil
import stat
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from lab import (
FixtureCase,
LabPaths,
S3Client,
build_payload_file,
head_case,
stop_process,
store_case_artifacts,
upload_case,
wait_for_s3_ready,
)
RELEASE = "1.0.0-beta.5"
CASE_ID = "rustfs-beta5-sse-kms-singlepart-64k"
KMS_KEY_ID = "beta5-test-key"
LINUX_X86_64_ASSET = "rustfs-linux-x86_64-gnu-v1.0.0-beta.5.zip"
LINUX_X86_64_SHA256 = "73529e732adc3c2c5c78c7f2c2e4e331a20e5bee1e2db341ee7c762299e4b327"
DEFAULT_ROOT = Path(__file__).resolve().parents[1] / "fixtures" / "rustfs-beta5-generated"
def release_asset_url(asset: str) -> str:
return f"https://github.com/rustfs/rustfs/releases/download/{RELEASE}/{asset}"
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def resolve_binary(explicit_binary: Path | None, workdir: Path) -> Path:
if explicit_binary is not None:
binary = explicit_binary.resolve()
if not binary.is_file():
raise FileNotFoundError(f"RustFS beta.5 binary not found: {binary}")
return binary
if platform.system() != "Linux" or platform.machine() not in {"x86_64", "AMD64"}:
raise RuntimeError("--rustfs-binary is required outside Linux x86_64")
archive = workdir / LINUX_X86_64_ASSET
urllib.request.urlretrieve(release_asset_url(LINUX_X86_64_ASSET), archive)
actual = sha256_file(archive)
if actual != LINUX_X86_64_SHA256:
raise RuntimeError(f"RustFS beta.5 archive SHA-256 mismatch: expected {LINUX_X86_64_SHA256}, got {actual}")
with zipfile.ZipFile(archive) as bundle:
bundle.extract("rustfs", workdir)
binary = workdir / "rustfs"
binary.chmod(binary.stat().st_mode | stat.S_IXUSR)
return binary
def write_local_kms_key(key_dir: Path) -> None:
key_dir.mkdir(parents=True, mode=0o700)
key_path = key_dir / f"{KMS_KEY_ID}.key"
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
payload = {
"key_id": KMS_KEY_ID,
"version": 1,
"algorithm": "AES_256",
"usage": "EncryptDecrypt",
"status": "Active",
"description": None,
"metadata": {},
"created_at": now,
"rotated_at": None,
"created_by": "fixture-lab",
"encrypted_key_material": base64.b64encode(secrets.token_bytes(32)).decode("ascii"),
"nonce": [],
}
key_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
key_path.chmod(0o600)
def wait_for_process_s3(client: S3Client, process: subprocess.Popen[str], timeout_seconds: int) -> None:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if process.poll() is not None:
raise RuntimeError("RustFS beta.5 exited before its S3 API became ready")
try:
client.list_buckets()
return
except (RuntimeError, urllib.error.URLError):
time.sleep(1)
wait_for_s3_ready(client, 1)
def capture(args: argparse.Namespace) -> Path:
root = args.root.resolve()
if root.exists():
shutil.rmtree(root)
root.mkdir(parents=True)
with tempfile.TemporaryDirectory(prefix="rustfs-beta5-fixture-") as temporary:
workdir = Path(temporary)
binary = resolve_binary(args.rustfs_binary, workdir)
key_dir = workdir / "kms"
write_local_kms_key(key_dir)
disks = [workdir / f"disk{index}" for index in range(1, 5)]
for disk in disks:
disk.mkdir()
endpoint = args.endpoint
parsed = urlparse(endpoint)
if parsed.scheme != "http" or not parsed.netloc:
raise ValueError("beta.5 fixture endpoint must be an http:// URL")
command = [
str(binary),
"server",
"--address",
parsed.netloc,
"--access-key",
"minioadmin",
"--secret-key",
"minioadmin",
"--kms-enable",
"--kms-backend",
"local",
"--kms-key-dir",
str(key_dir),
"--kms-default-key-id",
KMS_KEY_ID,
*(str(disk) for disk in disks),
]
environment = os.environ.copy()
environment["RUSTFS_UNSAFE_BYPASS_DISK_CHECK"] = "true"
server_log_path = workdir / "server.log"
case = FixtureCase(
case_id=CASE_ID,
bucket="demo",
object_name="dir/object.bin",
encryption="SSE-KMS",
size_bytes=64 * 1024,
multipart=False,
kms_key_id=KMS_KEY_ID,
)
payload_file = workdir / "payload.bin"
plaintext_sha256 = build_payload_file(payload_file, case.size_bytes)
with server_log_path.open("w", encoding="utf-8") as server_log:
process = subprocess.Popen(
command,
cwd=str(workdir),
env=environment,
stdout=server_log,
stderr=subprocess.STDOUT,
text=True,
)
try:
client = S3Client(endpoint)
wait_for_process_s3(client, process, args.timeout_seconds)
client.create_bucket(case.bucket)
request_payload = upload_case(client, case, payload_file)
head_payload = head_case(client, case)
finally:
stop_process(process)
export_root = workdir / "export"
export_root.mkdir()
for index, disk in enumerate(disks, start=1):
shutil.copytree(disk, export_root / f"disk{index}")
shutil.copytree(key_dir, root / "kms")
return store_case_artifacts(
paths=LabPaths(root=root, cases=root / "cases"),
case_id=case.case_id,
bucket=case.bucket,
object_name=case.object_name,
source_tree=export_root,
version_id=head_payload.get("VersionId"),
request_payload=request_payload,
head_payload=head_payload,
plaintext_sha256=plaintext_sha256,
notes="Captured from the pinned RustFS 1.0.0-beta.5 release with the production local KMS backend.",
capture_payload={
"release": RELEASE,
"binary_sha256": sha256_file(binary),
"disk_count": len(disks),
"kms_key_id": KMS_KEY_ID,
},
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Capture a real RustFS beta.5 SSE-KMS fixture")
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
parser.add_argument("--rustfs-binary", type=Path)
parser.add_argument("--endpoint", default="http://127.0.0.1:19011")
parser.add_argument("--timeout-seconds", type=int, default=90)
return parser
def main() -> int:
case_dir = capture(build_parser().parse_args())
print(case_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -2,6 +2,7 @@ from __future__ import annotations
import importlib.util
import base64
import json
import sys
import tempfile
import unittest
@@ -21,6 +22,21 @@ def load_lab_module():
lab = load_lab_module()
sys.modules["lab"] = lab
def load_beta5_module():
module_path = Path(__file__).with_name("capture_rustfs_beta5.py")
spec = importlib.util.spec_from_file_location("rustfs_beta5_fixture_capture", module_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
beta5 = load_beta5_module()
class DiscoverMinioLauncherTests(unittest.TestCase):
@@ -203,5 +219,31 @@ class KmsSecretKeyTests(unittest.TestCase):
)
class RustfsBeta5CaptureTests(unittest.TestCase):
def test_explicit_beta5_binary_is_used_without_download(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
binary = Path(temp_dir) / "rustfs"
binary.write_bytes(b"beta5")
resolved = beta5.resolve_binary(binary, Path(temp_dir))
self.assertEqual(resolved, binary.resolve())
def test_local_kms_key_has_beta5_compatible_shape_and_permissions(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
key_dir = Path(temp_dir) / "kms"
beta5.write_local_kms_key(key_dir)
key_path = key_dir / f"{beta5.KMS_KEY_ID}.key"
payload = json.loads(key_path.read_text(encoding="utf-8"))
self.assertEqual(payload["key_id"], beta5.KMS_KEY_ID)
self.assertEqual(payload["algorithm"], "AES_256")
self.assertEqual(payload["usage"], "EncryptDecrypt")
self.assertEqual(len(base64.b64decode(payload["encrypted_key_material"])), 32)
self.assertEqual(payload["nonce"], [])
self.assertEqual(key_path.stat().st_mode & 0o777, 0o600)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -2514,6 +2514,7 @@ mod tests {
stream: Box::new(Cursor::new(data)),
object_info: ObjectInfo::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}