mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-10 15:16:56 +00:00
Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8194,6 +8194,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
buffered_body: None,
|
||||
resolved_sse: None,
|
||||
body_source: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user