* feat(kms): implement secure handling of static KMS secret keys and enhance encryption context validation * feat: enhance local SSE DEK handling with JSON envelope format and versioning
4849 lines
194 KiB
Rust
4849 lines
194 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! Server-Side Encryption (SSE) utilities
|
|
//!
|
|
//! This module provides reusable components for handling S3 Server-Side Encryption:
|
|
//! - SSE-S3 (AES256): Server-managed encryption with S3-managed keys
|
|
//! - SSE-KMS (aws:kms): Server-managed encryption with KMS-managed keys
|
|
//! - SSE-C (AES256): Customer-provided encryption keys
|
|
//!
|
|
//! ## Architecture
|
|
//!
|
|
//! ### Unified API
|
|
//! The module provides two core functions that automatically route to the correct encryption method:
|
|
//! - `sse_encryption()` - Unified encryption entry point
|
|
//! - `sse_decryption()` - Unified decryption entry point
|
|
//!
|
|
//! ### Managed SSE (SSE-S3 / SSE-KMS)
|
|
//! - Keys are managed by the server-side KMS service
|
|
//! - Data keys are generated and encrypted by KMS
|
|
//! - Encryption metadata is stored in object metadata
|
|
//!
|
|
//! ### Customer-Provided Keys (SSE-C)
|
|
//! - Keys are provided by the client on every request
|
|
//! - Server validates key using MD5 hash
|
|
//! - Keys are NEVER stored on the server
|
|
//!
|
|
//! ## Usage Example
|
|
//!
|
|
//! ```rust,ignore
|
|
//! // Unified encryption API
|
|
//! let request = EncryptionRequest {
|
|
//! bucket: &bucket,
|
|
//! key: &key,
|
|
//! server_side_encryption: effective_sse.as_ref(),
|
|
//! ssekms_key_id: effective_kms_key_id.as_deref(),
|
|
//! sse_customer_algorithm: sse_customer_algorithm.as_ref(),
|
|
//! sse_customer_key: sse_customer_key.as_deref(),
|
|
//! sse_customer_key_md5: sse_customer_key_md5.as_deref(),
|
|
//! content_size: actual_size,
|
|
//! };
|
|
//!
|
|
//! if let Some(material) = sse_encryption(request).await? {
|
|
//! metadata.extend(encryption_material_to_metadata(&material)?);
|
|
//! }
|
|
//!
|
|
//! // Unified decryption API
|
|
//! let request = DecryptionRequest {
|
|
//! bucket: &bucket,
|
|
//! key: &key,
|
|
//! metadata: &metadata,
|
|
//! sse_customer_key: sse_customer_key.as_deref(),
|
|
//! sse_customer_key_md5: sse_customer_key_md5.as_deref(),
|
|
//! };
|
|
//!
|
|
//! if let Some(material) = sse_decryption(request).await? {
|
|
//! content_size = material.original_size.unwrap_or(actual_size);
|
|
//! }
|
|
//! ```
|
|
|
|
use super::StorageError;
|
|
use crate::storage::storage_api::runtime_sources_consumer::runtime_sources;
|
|
#[cfg(feature = "rio-v2")]
|
|
use aes_gcm::aead::Payload;
|
|
use aes_gcm::{
|
|
Aes256Gcm, Key, Nonce,
|
|
aead::{Aead, KeyInit},
|
|
};
|
|
use async_trait::async_trait;
|
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
|
#[cfg(feature = "rio-v2")]
|
|
use chacha20poly1305::ChaCha20Poly1305;
|
|
#[cfg(feature = "rio-v2")]
|
|
use hmac::{Hmac, Mac};
|
|
use http::{HeaderMap, HeaderValue};
|
|
use rand::Rng;
|
|
#[cfg(feature = "rio-v2")]
|
|
use rand::RngExt;
|
|
use rustfs_kms::{DataKey, KmsUnavailableError, is_data_key_envelope, types::ObjectEncryptionContext};
|
|
use rustfs_utils::get_env_opt_str;
|
|
use s3s::S3ErrorCode;
|
|
use s3s::dto::ServerSideEncryption;
|
|
use serde::{Deserialize, Serialize};
|
|
#[cfg(feature = "rio-v2")]
|
|
use sha2::Sha256;
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, LazyLock, RwLock};
|
|
use tracing::{debug, error};
|
|
|
|
const LOG_COMPONENT_STORAGE: &str = "storage";
|
|
const LOG_SUBSYSTEM_SSE: &str = "sse";
|
|
|
|
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_ALGORITHM_HEADER: &str = "x-rustfs-encryption-algorithm";
|
|
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 MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER: &str = "X-Minio-Internal-Encrypted-Multipart";
|
|
const MINIO_INTERNAL_ENCRYPTION_IV_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Iv";
|
|
const MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm";
|
|
const MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Sealed-Key";
|
|
const MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key";
|
|
const MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Kms-Sealed-Key";
|
|
const MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id";
|
|
const MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key";
|
|
const MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Context";
|
|
#[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;
|
|
#[cfg(feature = "rio-v2")]
|
|
const DARE_CIPHER_CHACHA20_POLY1305: u8 = 0x01;
|
|
#[cfg(feature = "rio-v2")]
|
|
const DARE_HEADER_SIZE: usize = 16;
|
|
#[cfg(feature = "rio-v2")]
|
|
const DARE_TAG_SIZE: usize = 16;
|
|
#[cfg(feature = "rio-v2")]
|
|
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 OBJECT_KEY_DERIVATION_CONTEXT: &[u8] = b"object-encryption-key generation";
|
|
|
|
use super::Error;
|
|
use super::get_bucket_sse_config;
|
|
use crate::error::ApiError;
|
|
use rustfs_utils::http::headers::{
|
|
AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY,
|
|
AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5, AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT,
|
|
};
|
|
use rustfs_utils::path::path_join_buf;
|
|
use s3s::dto::{SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, SSEKMSKeyId};
|
|
|
|
// ============================================================================
|
|
// High-Level SSE Configuration
|
|
// ============================================================================
|
|
|
|
const DEFAULT_SSE_ALGORITHM: &str = "AES256";
|
|
|
|
const SUPPORT_SSE_ALGORITHMS: &[&str] = &[DEFAULT_SSE_ALGORITHM];
|
|
|
|
// check sse type
|
|
#[allow(unused)]
|
|
pub fn get_sse_type(
|
|
server_side_encryption: Option<&ServerSideEncryption>,
|
|
customer_algorithm: Option<&SSECustomerAlgorithm>,
|
|
customer_key: Option<&SSECustomerKey>,
|
|
customer_key_md5: Option<&SSECustomerKeyMD5>,
|
|
) -> Option<SSEType> {
|
|
if customer_algorithm.is_some() && customer_key.is_some() && customer_key_md5.is_some() {
|
|
return Some(SSEType::SseC);
|
|
}
|
|
|
|
let sse = server_side_encryption?;
|
|
match sse.as_str() {
|
|
ServerSideEncryption::AES256 => Some(SSEType::SseS3),
|
|
ServerSideEncryption::AWS_KMS => Some(SSEType::SseKms),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// SSE configuration resolved from request and bucket defaults
|
|
#[derive(Debug)]
|
|
pub struct SseConfiguration {
|
|
/// Effective server-side encryption algorithm (after considering bucket defaults)
|
|
pub effective_sse: ServerSideEncryption,
|
|
/// Effective KMS key ID (after considering bucket defaults)
|
|
pub effective_kms_key_id: Option<SSEKMSKeyId>,
|
|
}
|
|
|
|
/// Prepare SSE configuration by resolving request parameters with bucket defaults
|
|
///
|
|
/// This function:
|
|
/// 1. Queries bucket default encryption configuration
|
|
/// 2. Resolves effective encryption (request overrides bucket default)
|
|
/// 3. Prepares metadata headers for managed SSE
|
|
///
|
|
/// # Arguments
|
|
/// * `bucket` - Bucket name
|
|
/// * `server_side_encryption` - SSE algorithm from request (SSE-S3 or SSE-KMS)
|
|
/// * `ssekms_key_id` - KMS key ID from request
|
|
/// * `sse_customer_algorithm` - SSE-C algorithm from request
|
|
///
|
|
/// # Returns
|
|
/// `SseConfiguration` with resolved encryption parameters and metadata headers
|
|
async fn prepare_sse_configuration(
|
|
bucket: &str,
|
|
server_side_encryption: Option<ServerSideEncryption>,
|
|
ssekms_key_id: Option<SSEKMSKeyId>,
|
|
) -> Result<Option<SseConfiguration>, ApiError> {
|
|
if let Some(server_side_encryption) = server_side_encryption.clone()
|
|
&& server_side_encryption.as_str() == ServerSideEncryption::AES256
|
|
{
|
|
return Ok(Some(SseConfiguration {
|
|
effective_sse: server_side_encryption,
|
|
effective_kms_key_id: None,
|
|
}));
|
|
}
|
|
|
|
if let Some(server_side_encryption) = server_side_encryption.clone()
|
|
&& let Some(ssekms_key_id) = ssekms_key_id
|
|
{
|
|
return Ok(Some(SseConfiguration {
|
|
effective_sse: server_side_encryption,
|
|
effective_kms_key_id: Some(ssekms_key_id),
|
|
}));
|
|
}
|
|
|
|
// Get bucket default encryption configuration.
|
|
let bucket_sse_config_result = get_bucket_sse_config(bucket).await;
|
|
debug!(
|
|
component = LOG_COMPONENT_STORAGE,
|
|
subsystem = LOG_SUBSYSTEM_SSE,
|
|
event = "bucket_sse_config_lookup",
|
|
bucket = %bucket,
|
|
found = bucket_sse_config_result.is_ok(),
|
|
"Bucket SSE configuration lookup completed"
|
|
);
|
|
|
|
if let Ok((bucket_sse_config, _timestamp)) = bucket_sse_config_result {
|
|
let effective_sse = server_side_encryption.clone().or_else(|| {
|
|
bucket_sse_config.rules.first().and_then(|rule| {
|
|
rule.apply_server_side_encryption_by_default.as_ref().map(|sse| {
|
|
debug!(
|
|
component = LOG_COMPONENT_STORAGE,
|
|
subsystem = LOG_SUBSYSTEM_SSE,
|
|
event = "bucket_sse_default_applied",
|
|
bucket = %bucket,
|
|
algorithm = sse.sse_algorithm.as_str(),
|
|
has_kms_key_id = sse.kms_master_key_id.is_some(),
|
|
"Bucket SSE default resolved"
|
|
);
|
|
match sse.sse_algorithm.as_str() {
|
|
"AES256" => ServerSideEncryption::from_static(ServerSideEncryption::AES256),
|
|
"aws:kms" => ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS),
|
|
_ => ServerSideEncryption::from_static(ServerSideEncryption::AES256), // fallback
|
|
}
|
|
})
|
|
})
|
|
});
|
|
let Some(effective_sse) = effective_sse else {
|
|
return Ok(None);
|
|
};
|
|
|
|
debug!(
|
|
component = LOG_COMPONENT_STORAGE,
|
|
subsystem = LOG_SUBSYSTEM_SSE,
|
|
event = "effective_sse_resolved",
|
|
bucket = %bucket,
|
|
requested = ?server_side_encryption,
|
|
effective = ?effective_sse,
|
|
"Resolved effective SSE configuration"
|
|
);
|
|
|
|
let effective_kms_key_id = resolve_effective_kms_key_id(Some(&effective_sse), ssekms_key_id, || {
|
|
bucket_sse_config.rules.first().and_then(|rule| {
|
|
rule.apply_server_side_encryption_by_default
|
|
.as_ref()
|
|
.and_then(|sse| sse.kms_master_key_id.clone())
|
|
})
|
|
});
|
|
|
|
Ok(Some(SseConfiguration {
|
|
effective_sse,
|
|
effective_kms_key_id,
|
|
}))
|
|
} else if let Err(e) = bucket_sse_config_result {
|
|
match e {
|
|
Error::ConfigNotFound => {
|
|
// The bucket has no SSE config. If the user explicitly requested
|
|
// aws:kms, we must honor that — return the explicit SSE header so
|
|
// downstream logic can try (and fail if KMS is unavailable).
|
|
if let Some(sse) = server_side_encryption {
|
|
Ok(Some(SseConfiguration {
|
|
effective_sse: sse,
|
|
effective_kms_key_id: ssekms_key_id,
|
|
}))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
_ => Err(ApiError::from(e)),
|
|
}
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
fn resolve_effective_kms_key_id<F>(
|
|
effective_sse: Option<&ServerSideEncryption>,
|
|
requested_kms_key_id: Option<SSEKMSKeyId>,
|
|
bucket_default_kms_key_id: F,
|
|
) -> Option<SSEKMSKeyId>
|
|
where
|
|
F: FnOnce() -> Option<SSEKMSKeyId>,
|
|
{
|
|
if effective_sse.is_none_or(|sse| sse.as_str() != ServerSideEncryption::AWS_KMS) {
|
|
return requested_kms_key_id;
|
|
}
|
|
|
|
requested_kms_key_id.or_else(bucket_default_kms_key_id)
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum SseTypeV2 {
|
|
SseS3(ServerSideEncryption),
|
|
SseKms(ServerSideEncryption, Option<SSEKMSKeyId>),
|
|
SseC(SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5),
|
|
}
|
|
|
|
impl SseTypeV2 {
|
|
#[allow(unused)]
|
|
pub fn to_metadata(&self) -> HashMap<String, String> {
|
|
sse_configuration_to_metadata(self)
|
|
}
|
|
}
|
|
|
|
pub async fn prepare_sse_configuration_v2(
|
|
bucket: &str,
|
|
server_side_encryption: Option<ServerSideEncryption>,
|
|
customer_algorithm: Option<SSECustomerAlgorithm>,
|
|
customer_key: Option<SSECustomerKey>,
|
|
customer_key_md5: Option<SSECustomerKeyMD5>,
|
|
ssekms_key_id: Option<SSEKMSKeyId>,
|
|
) -> Result<Option<SseTypeV2>, ApiError> {
|
|
if let Some(customer_algorithm) = customer_algorithm
|
|
&& let Some(customer_key_md5) = customer_key_md5
|
|
{
|
|
// if create_multipart_upload request, customer_key is not provided
|
|
let customer_key = customer_key.unwrap_or_default();
|
|
|
|
return Ok(Some(SseTypeV2::SseC(customer_algorithm, customer_key, customer_key_md5)));
|
|
}
|
|
|
|
let sse_config = prepare_sse_configuration(bucket, server_side_encryption, ssekms_key_id).await?;
|
|
|
|
if let Some(sse_config) = sse_config {
|
|
return match sse_config.effective_sse.as_str() {
|
|
ServerSideEncryption::AES256 => Ok(Some(SseTypeV2::SseS3(sse_config.effective_sse))),
|
|
ServerSideEncryption::AWS_KMS => {
|
|
Ok(Some(SseTypeV2::SseKms(sse_config.effective_sse.clone(), sse_config.effective_kms_key_id)))
|
|
}
|
|
_ => Ok(None),
|
|
};
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
#[allow(unused)]
|
|
pub fn sse_configuration_to_metadata(sse_configuration: &SseTypeV2) -> HashMap<String, String> {
|
|
let mut metadata = HashMap::new();
|
|
match sse_configuration {
|
|
SseTypeV2::SseS3(sse) => {
|
|
metadata.insert("x-amz-server-side-encryption".to_string(), sse.as_str().to_string());
|
|
}
|
|
SseTypeV2::SseKms(sse, kms_key_id) => {
|
|
metadata.insert("x-amz-server-side-encryption".to_string(), sse.as_str().to_string());
|
|
if let Some(kms_key_id) = kms_key_id {
|
|
metadata.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), kms_key_id.to_string());
|
|
}
|
|
}
|
|
SseTypeV2::SseC(algorithm, _key, key_md5) => {
|
|
metadata.insert("x-amz-server-side-encryption".to_string(), "AES256".to_string());
|
|
metadata.insert(
|
|
"x-amz-server-side-encryption-customer-algorithm".to_string(),
|
|
algorithm.as_str().to_string(),
|
|
);
|
|
metadata.insert("x-amz-server-side-encryption-customer-key-md5".to_string(), key_md5.to_string());
|
|
}
|
|
}
|
|
|
|
metadata
|
|
}
|
|
|
|
// ============================================================================
|
|
// Core Types - Unified Encryption/Decryption API
|
|
// ============================================================================
|
|
|
|
/// Request parameters for unified encryption
|
|
#[derive(Debug, Clone)]
|
|
pub struct EncryptionRequest<'a> {
|
|
/// Bucket name
|
|
pub bucket: &'a str,
|
|
/// Object key
|
|
pub key: &'a str,
|
|
/// Server-side encryption algorithm (SSE-S3 or SSE-KMS)
|
|
pub server_side_encryption: Option<ServerSideEncryption>,
|
|
/// KMS key ID (for SSE-KMS)
|
|
pub ssekms_key_id: Option<SSEKMSKeyId>,
|
|
/// Optional client-provided KMS context for SSE-KMS.
|
|
pub ssekms_context: Option<HashMap<String, String>>,
|
|
/// SSE-C algorithm (customer-provided key)
|
|
pub sse_customer_algorithm: Option<SSECustomerAlgorithm>,
|
|
/// SSE-C key (Base64-encoded)
|
|
pub sse_customer_key: Option<SSECustomerKey>,
|
|
/// SSE-C key MD5 (Base64-encoded)
|
|
pub sse_customer_key_md5: Option<SSECustomerKeyMD5>,
|
|
/// Content size (for metadata)
|
|
pub content_size: i64,
|
|
}
|
|
|
|
impl EncryptionRequest<'_> {
|
|
pub fn validate_multipart_ssec(&self, user_defined: &HashMap<String, String>) -> Result<(), ApiError> {
|
|
let stored_algorithm = user_defined.get("x-amz-server-side-encryption-customer-algorithm");
|
|
let stored_key_md5 = user_defined.get("x-amz-server-side-encryption-customer-key-md5");
|
|
let session_uses_ssec = stored_algorithm.is_some() || stored_key_md5.is_some();
|
|
let request_uses_ssec =
|
|
self.sse_customer_algorithm.is_some() || self.sse_customer_key.is_some() || self.sse_customer_key_md5.is_some();
|
|
|
|
if !session_uses_ssec {
|
|
return if request_uses_ssec {
|
|
Err(ssec_invalid_request(
|
|
"SSE-C parameters cannot be used for a multipart upload that was not initiated with SSE-C.",
|
|
))
|
|
} else {
|
|
Ok(())
|
|
};
|
|
}
|
|
|
|
let (Some(algorithm), Some(key), Some(key_md5)) = (
|
|
self.sse_customer_algorithm.as_ref(),
|
|
self.sse_customer_key.as_ref(),
|
|
self.sse_customer_key_md5.as_ref(),
|
|
) else {
|
|
return Err(ssec_invalid_request(
|
|
"Missing SSE-C parameters. Algorithm, customer key and customer key MD5 are all required.",
|
|
));
|
|
};
|
|
|
|
let validated = validate_ssec_params(SsecParams {
|
|
algorithm: algorithm.to_string(),
|
|
key: key.to_string(),
|
|
key_md5: key_md5.to_string(),
|
|
})?;
|
|
if stored_algorithm.map(String::as_str) != Some(validated.algorithm.as_str()) {
|
|
return Err(ssec_invalid_request(
|
|
"The provided encryption parameters did not match the multipart upload.",
|
|
));
|
|
}
|
|
verify_ssec_key_match(&validated.key_md5, stored_key_md5)
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
fn sse_invalid_argument(message: &str) -> ApiError {
|
|
ApiError {
|
|
code: S3ErrorCode::InvalidArgument,
|
|
message: message.to_string(),
|
|
source: None,
|
|
}
|
|
}
|
|
|
|
/// SSE-C parameters extracted from headers (algorithm, key, key MD5).
|
|
pub(crate) type SsecParamsFromHeaders = (Option<SSECustomerAlgorithm>, Option<SSECustomerKey>, Option<SSECustomerKeyMD5>);
|
|
|
|
/// Extract SSE-C parameters from request headers.
|
|
/// Used as fallback when the S3 layer does not populate them in the input struct.
|
|
///
|
|
/// Returns an error if an SSE-C header is present but cannot be parsed as valid UTF-8,
|
|
/// ensuring malformed headers do not bypass validation.
|
|
pub(crate) fn extract_ssec_params_from_headers(headers: &HeaderMap) -> Result<SsecParamsFromHeaders, ApiError> {
|
|
let algorithm = match headers.get("x-amz-server-side-encryption-customer-algorithm") {
|
|
None => Ok(None),
|
|
Some(v) => v
|
|
.to_str()
|
|
.map(|s| Some(SSECustomerAlgorithm::from(s.to_string())))
|
|
.map_err(|_| sse_invalid_argument("The x-amz-server-side-encryption-customer-algorithm header must be valid UTF-8.")),
|
|
}?;
|
|
|
|
let key = match headers.get("x-amz-server-side-encryption-customer-key") {
|
|
None => Ok(None),
|
|
Some(v) => v
|
|
.to_str()
|
|
.map(|s| Some(SSECustomerKey::from(s.to_string())))
|
|
.map_err(|_| sse_invalid_argument("The x-amz-server-side-encryption-customer-key header must be valid UTF-8.")),
|
|
}?;
|
|
|
|
let key_md5 = match headers.get("x-amz-server-side-encryption-customer-key-md5") {
|
|
None => Ok(None),
|
|
Some(v) => v
|
|
.to_str()
|
|
.map(|s| Some(SSECustomerKeyMD5::from(s.to_string())))
|
|
.map_err(|_| sse_invalid_argument("The x-amz-server-side-encryption-customer-key-md5 header must be valid UTF-8.")),
|
|
}?;
|
|
|
|
Ok((algorithm, key, key_md5))
|
|
}
|
|
|
|
/// Extract x-amz-server-side-encryption from request headers.
|
|
/// Used as fallback when the S3 layer does not populate it in the input struct.
|
|
///
|
|
/// Returns an error if the header is present but cannot be parsed as valid UTF-8,
|
|
/// ensuring malformed headers do not bypass validation.
|
|
pub(crate) fn extract_server_side_encryption_from_headers(headers: &HeaderMap) -> Result<Option<ServerSideEncryption>, ApiError> {
|
|
match headers.get("x-amz-server-side-encryption") {
|
|
None => Ok(None),
|
|
Some(v) => v
|
|
.to_str()
|
|
.map(|s| Some(ServerSideEncryption::from(s.to_string())))
|
|
.map_err(|_| sse_invalid_argument("The x-amz-server-side-encryption header must be valid UTF-8.")),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn extract_ssekms_context_from_headers(headers: &HeaderMap) -> Result<Option<HashMap<String, String>>, ApiError> {
|
|
let Some(v) = headers.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT) else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let value = v
|
|
.to_str()
|
|
.map_err(|_| sse_invalid_argument("The x-amz-server-side-encryption-context header must be valid UTF-8."))?;
|
|
let decoded = BASE64_STANDARD.decode(value).map_err(|_| {
|
|
sse_invalid_argument("The x-amz-server-side-encryption-context header must be valid base64-encoded JSON.")
|
|
})?;
|
|
|
|
serde_json::from_slice(&decoded).map(Some).map_err(|_| {
|
|
sse_invalid_argument("The x-amz-server-side-encryption-context header must be a base64-encoded JSON object.")
|
|
})
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn validate_sse_headers_for_write(
|
|
server_side_encryption: Option<&ServerSideEncryption>,
|
|
ssekms_key_id: Option<&SSEKMSKeyId>,
|
|
ssekms_context: Option<&HashMap<String, String>>,
|
|
sse_customer_algorithm: Option<&SSECustomerAlgorithm>,
|
|
sse_customer_key: Option<&SSECustomerKey>,
|
|
sse_customer_key_md5: Option<&SSECustomerKeyMD5>,
|
|
require_sse_customer_key: bool,
|
|
) -> Result<(), ApiError> {
|
|
if let Some(sse) = server_side_encryption {
|
|
let s = sse.as_str();
|
|
if s != ServerSideEncryption::AES256 && s != ServerSideEncryption::AWS_KMS {
|
|
return Err(sse_invalid_argument(
|
|
"The SSE algorithm specified is not supported. The valid values are AES256 or aws:kms.",
|
|
));
|
|
}
|
|
}
|
|
|
|
let has_ssec_headers = sse_customer_algorithm.is_some() || sse_customer_key.is_some() || sse_customer_key_md5.is_some();
|
|
let has_managed_headers = server_side_encryption.is_some() || ssekms_key_id.is_some() || ssekms_context.is_some();
|
|
|
|
if has_ssec_headers {
|
|
if has_managed_headers {
|
|
return Err(sse_invalid_argument(
|
|
"The SSE-C and managed server-side encryption headers cannot be used together.",
|
|
));
|
|
}
|
|
|
|
let has_valid_ssec_headers = if require_sse_customer_key {
|
|
matches!(
|
|
(sse_customer_algorithm, sse_customer_key, sse_customer_key_md5),
|
|
(Some(_), Some(_), Some(_))
|
|
)
|
|
} else {
|
|
matches!((sse_customer_algorithm, sse_customer_key_md5), (Some(_), Some(_)))
|
|
};
|
|
|
|
if !has_valid_ssec_headers {
|
|
let message = if require_sse_customer_key {
|
|
"Missing SSE-C parameters. Algorithm, customer key and customer key MD5 are all required."
|
|
} else {
|
|
"Missing SSE-C parameters. Algorithm and customer key MD5 are required."
|
|
};
|
|
|
|
return Err(ssec_invalid_request(message));
|
|
}
|
|
}
|
|
|
|
if ssekms_key_id.is_some() && server_side_encryption.is_none_or(|sse| sse.as_str() != ServerSideEncryption::AWS_KMS) {
|
|
return Err(sse_invalid_argument(
|
|
"The SSE-KMS key ID header can only be used when x-amz-server-side-encryption is set to aws:kms.",
|
|
));
|
|
}
|
|
|
|
if ssekms_context.is_some() && server_side_encryption.is_none_or(|sse| sse.as_str() != ServerSideEncryption::AWS_KMS) {
|
|
return Err(sse_invalid_argument(
|
|
"The SSE-KMS context header can only be used when x-amz-server-side-encryption is set to aws:kms.",
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn validate_sse_headers_for_read(metadata: &HashMap<String, String>, headers: &HeaderMap) -> Result<(), ApiError> {
|
|
let has_req_ssec = headers.contains_key("x-amz-server-side-encryption-customer-algorithm")
|
|
|| headers.contains_key("x-amz-server-side-encryption-customer-key")
|
|
|| headers.contains_key("x-amz-server-side-encryption-customer-key-md5");
|
|
|
|
let has_req_sse = headers.contains_key("x-amz-server-side-encryption")
|
|
|| headers.contains_key("x-amz-server-side-encryption-aws-kms-key-id")
|
|
|| headers.contains_key("x-amz-server-side-encryption-context");
|
|
|
|
let is_object_ssec = metadata.contains_key("x-amz-server-side-encryption-customer-algorithm");
|
|
let is_object_sse = metadata.contains_key("x-amz-server-side-encryption");
|
|
|
|
if is_object_ssec {
|
|
if has_req_sse {
|
|
return Err(sse_invalid_argument(
|
|
"Server-side encryption headers cannot be used with an object encrypted using SSE-C.",
|
|
));
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
if is_object_sse && has_req_ssec {
|
|
return Err(sse_invalid_argument(
|
|
"SSE-C headers cannot be used with an object encrypted using server-side managed encryption.",
|
|
));
|
|
}
|
|
|
|
if has_req_ssec {
|
|
return Err(ssec_invalid_request(
|
|
"The object was stored without SSE-C. The correct SSE-C parameters must not be provided.",
|
|
));
|
|
}
|
|
|
|
if has_req_sse {
|
|
return Err(sse_invalid_argument(
|
|
"The object is not encrypted with server-side encryption. Do not provide server-side encryption headers.",
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn map_get_object_reader_error(err: StorageError) -> ApiError {
|
|
if let Some(message) = map_ssec_get_object_reader_error_message(&err) {
|
|
return ApiError {
|
|
code: S3ErrorCode::InvalidRequest,
|
|
message,
|
|
source: Some(Box::new(err)),
|
|
};
|
|
}
|
|
|
|
ApiError::from(err)
|
|
}
|
|
|
|
fn map_ssec_get_object_reader_error_message(err: &StorageError) -> Option<String> {
|
|
let StorageError::Io(io_err) = err else {
|
|
return None;
|
|
};
|
|
|
|
let detail = io_err.to_string();
|
|
match detail.as_str() {
|
|
"missing SSE-C algorithm header"
|
|
| "invalid SSE-C algorithm header"
|
|
| "missing SSE-C key header"
|
|
| "invalid SSE-C key header"
|
|
| "missing SSE-C key md5 header"
|
|
| "invalid SSE-C key md5 header" => Some(
|
|
"The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object."
|
|
.to_string(),
|
|
),
|
|
"failed to decode SSE-C key" => Some("Invalid SSE-C key: not valid Base64.".to_string()),
|
|
"SSE-C key must be 32 bytes" => Some("SSE-C key must be exactly 32 bytes.".to_string()),
|
|
"SSE-C key MD5 mismatch" => {
|
|
Some("The calculated MD5 hash of the key did not match the hash that was provided.".to_string())
|
|
}
|
|
"missing stored SSE-C key md5" => Some("Object has no stored SSE-C key metadata.".to_string()),
|
|
"SSE-C key does not match object metadata" => Some(
|
|
"The provided encryption parameters did not match the ones used originally to encrypt the object.".to_string(),
|
|
),
|
|
_ => detail
|
|
.strip_prefix("unsupported SSE-C algorithm ")
|
|
.map(|_| format!("Unsupported SSE-C algorithm. Only {DEFAULT_SSE_ALGORITHM} is supported.")),
|
|
}
|
|
}
|
|
|
|
/// Request parameters for unified decryption
|
|
#[derive(Debug)]
|
|
pub struct DecryptionRequest<'a> {
|
|
/// Bucket name
|
|
pub bucket: &'a str,
|
|
/// Object key
|
|
pub key: &'a str,
|
|
/// Object metadata containing encryption headers
|
|
pub metadata: &'a HashMap<String, String>,
|
|
/// SSE-C key (Base64-encoded) - required if object was encrypted with SSE-C
|
|
pub sse_customer_key: Option<&'a SSECustomerKey>,
|
|
/// SSE-C key MD5 (Base64-encoded) - required if object was encrypted with SSE-C
|
|
pub sse_customer_key_md5: Option<&'a SSECustomerKeyMD5>,
|
|
}
|
|
|
|
/// Encryption material returned by `sse_encryption()` / `sse_prepare_encryption()`.
|
|
#[derive(Debug)]
|
|
pub struct EncryptionMaterial {
|
|
#[allow(unused)]
|
|
pub sse_type: SSEType,
|
|
pub server_side_encryption: ServerSideEncryption,
|
|
pub kms_key_id: Option<SSEKMSKeyId>,
|
|
|
|
#[allow(unused)]
|
|
pub algorithm: SSECustomerAlgorithm,
|
|
|
|
/// Encryption key bytes
|
|
pub key_bytes: [u8; 32],
|
|
/// Base nonce/IV used by rio to derive block/part nonces.
|
|
pub base_nonce: [u8; 12],
|
|
/// Encrypted DEK for managed SSE. Absent for SSE-C.
|
|
pub encrypted_data_key: Option<Vec<u8>>,
|
|
/// SSE-C key MD5 if customer-managed encryption is in use.
|
|
pub customer_key_md5: Option<SSECustomerKeyMD5>,
|
|
/// Original plaintext size when it should be persisted alongside metadata.
|
|
pub original_size: Option<i64>,
|
|
/// Indicates whether `key_bytes` is a direct stream key or a MinIO-style object key.
|
|
pub key_kind: EncryptionKeyKind,
|
|
/// Original client-provided SSE-KMS context, stored in MinIO-compatible metadata.
|
|
pub managed_kms_context: Option<HashMap<String, String>>,
|
|
/// MinIO-compatible sealed object-key metadata for managed SSE object-key mode.
|
|
#[cfg_attr(not(feature = "rio-v2"), allow(dead_code))]
|
|
pub managed_sealed_key: Option<ManagedSealedKey>,
|
|
}
|
|
|
|
/// Decryption material returned by `sse_decryption()`.
|
|
#[derive(Debug)]
|
|
pub struct DecryptionMaterial {
|
|
#[allow(unused)]
|
|
pub sse_type: SSEType,
|
|
pub server_side_encryption: ServerSideEncryption,
|
|
pub kms_key_id: Option<SSEKMSKeyId>,
|
|
pub algorithm: SSECustomerAlgorithm,
|
|
pub customer_key_md5: Option<SSECustomerKeyMD5>, // if use SSE-C, check key md5
|
|
|
|
/// Decryption key bytes
|
|
pub key_bytes: [u8; 32],
|
|
/// Base nonce/IV used by rio to derive block/part nonces.
|
|
pub base_nonce: [u8; 12],
|
|
/// Indicates whether `key_bytes` is a direct stream key or a MinIO-style object key.
|
|
pub key_kind: EncryptionKeyKind,
|
|
}
|
|
|
|
/// Type of encryption used
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SSEType {
|
|
/// SSE-S3 (AES256)
|
|
SseS3,
|
|
/// SSE-KMS (aws:kms)
|
|
SseKms,
|
|
/// SSE-C (customer-provided key)
|
|
SseC,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum EncryptionKeyKind {
|
|
Direct,
|
|
Object,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ManagedSealedKey {
|
|
#[cfg(feature = "rio-v2")]
|
|
pub iv: [u8; SEALED_KEY_IV_SIZE],
|
|
#[cfg(feature = "rio-v2")]
|
|
pub sealed_key: [u8; SEALED_KEY_SIZE],
|
|
}
|
|
|
|
impl EncryptionMaterial {
|
|
pub fn write_encryption(&self, multipart_part_number: Option<usize>) -> super::WriteEncryption {
|
|
match (self.key_kind, multipart_part_number) {
|
|
(EncryptionKeyKind::Object, Some(part_number)) => {
|
|
super::WriteEncryption::multipart_object_key(self.key_bytes, part_number as u32)
|
|
}
|
|
(EncryptionKeyKind::Object, None) => super::WriteEncryption::singlepart_object_key(self.key_bytes),
|
|
(EncryptionKeyKind::Direct, Some(part_number)) => {
|
|
super::WriteEncryption::multipart(self.key_bytes, self.base_nonce, part_number)
|
|
}
|
|
(EncryptionKeyKind::Direct, None) => super::WriteEncryption::singlepart(self.key_bytes, self.base_nonce),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn managed_sse_domain(sse_type: SSEType) -> &'static str {
|
|
match sse_type {
|
|
SSEType::SseS3 => "SSE-S3",
|
|
SSEType::SseKms => "SSE-KMS",
|
|
SSEType::SseC => "SSE-C",
|
|
}
|
|
}
|
|
|
|
fn canonical_kms_bucket_path(bucket: &str, key: &str) -> String {
|
|
path_join_buf(&[bucket, key])
|
|
}
|
|
|
|
fn build_kms_request_context(
|
|
bucket: &str,
|
|
key: &str,
|
|
provided_context: Option<&HashMap<String, String>>,
|
|
) -> HashMap<String, String> {
|
|
let mut context = provided_context.cloned().unwrap_or_default();
|
|
context
|
|
.entry(bucket.to_string())
|
|
.or_insert_with(|| canonical_kms_bucket_path(bucket, key));
|
|
context
|
|
}
|
|
|
|
fn build_object_encryption_context(
|
|
bucket: &str,
|
|
key: &str,
|
|
provided_context: Option<&HashMap<String, String>>,
|
|
) -> ObjectEncryptionContext {
|
|
let mut context = ObjectEncryptionContext::new(bucket.to_string(), key.to_string());
|
|
for (ctx_key, ctx_value) in build_kms_request_context(bucket, key, provided_context) {
|
|
context = context.with_encryption_context(ctx_key, ctx_value);
|
|
}
|
|
context
|
|
}
|
|
|
|
fn encode_minio_kms_context(context: &HashMap<String, String>) -> Result<String, ApiError> {
|
|
let encoded = serde_json::to_vec(context)
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to serialize KMS context: {e}"))))?;
|
|
Ok(BASE64_STANDARD.encode(encoded))
|
|
}
|
|
|
|
fn decode_minio_kms_context(metadata: &HashMap<String, String>) -> Result<Option<HashMap<String, String>>, ApiError> {
|
|
let Some(encoded) = metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER) else {
|
|
return Ok(None);
|
|
};
|
|
let decoded = BASE64_STANDARD
|
|
.decode(encoded)
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to decode MinIO KMS context: {e}"))))?;
|
|
serde_json::from_slice(&decoded)
|
|
.map(Some)
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to parse MinIO KMS context: {e}"))))
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn is_supported_sealed_object_key_cipher(cipher: u8) -> bool {
|
|
matches!(cipher, DARE_CIPHER_AES_256_GCM | DARE_CIPHER_CHACHA20_POLY1305)
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn decrypt_sealed_object_key_payload(sealing_key: [u8; 32], header: &[u8], sealed_key: &[u8]) -> Result<Vec<u8>, ApiError> {
|
|
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)
|
|
.map_err(|err| ApiError::from(StorageError::other(format!("Invalid AES-GCM sealing key: {err}"))))?;
|
|
let nonce = Nonce::try_from(nonce)
|
|
.map_err(|_| ApiError::from(StorageError::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)
|
|
.map_err(|err| ApiError::from(StorageError::other(format!("Invalid ChaCha20-Poly1305 sealing key: {err}"))))?;
|
|
let nonce = chacha20poly1305::Nonce::try_from(nonce)
|
|
.map_err(|_| ApiError::from(StorageError::other("Invalid sealed object-key package nonce")))?;
|
|
cipher.decrypt(&nonce, Payload { msg: ciphertext, aad })
|
|
}
|
|
_ => return Err(ApiError::from(StorageError::other("Unsupported sealed object-key DARE header"))),
|
|
}
|
|
.map_err(|err| ApiError::from(StorageError::other(format!("Failed to unseal object key: {err}"))))
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn canonical_sse_path(bucket: &str, object: &str) -> String {
|
|
let bucket = bucket.trim_matches('/');
|
|
let object = object.trim_matches('/');
|
|
if object.is_empty() {
|
|
bucket.to_string()
|
|
} else if bucket.is_empty() {
|
|
object.to_string()
|
|
} else {
|
|
format!("{bucket}/{object}")
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn derive_object_key(external_key: [u8; 32]) -> Result<[u8; 32], ApiError> {
|
|
let mut random = [0u8; 32];
|
|
rand::rng().fill(&mut random);
|
|
|
|
let mut mac = HmacSha256::new_from_slice(&external_key)
|
|
.map_err(|err| ApiError::from(StorageError::other(format!("Invalid HMAC key for object-key derivation: {err}"))))?;
|
|
mac.update(OBJECT_KEY_DERIVATION_CONTEXT);
|
|
mac.update(&random);
|
|
|
|
let mut object_key = [0u8; 32];
|
|
object_key.copy_from_slice(mac.finalize().into_bytes().as_slice());
|
|
Ok(object_key)
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn derive_sealing_key(
|
|
external_key: [u8; 32],
|
|
iv: [u8; SEALED_KEY_IV_SIZE],
|
|
domain: &str,
|
|
bucket: &str,
|
|
object: &str,
|
|
) -> Result<[u8; 32], ApiError> {
|
|
let mut mac = HmacSha256::new_from_slice(&external_key)
|
|
.map_err(|err| ApiError::from(StorageError::other(format!("Invalid HMAC key for sealing-key derivation: {err}"))))?;
|
|
mac.update(&iv);
|
|
mac.update(domain.as_bytes());
|
|
mac.update(MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.as_bytes());
|
|
mac.update(canonical_sse_path(bucket, object).as_bytes());
|
|
|
|
let mut sealing_key = [0u8; 32];
|
|
sealing_key.copy_from_slice(mac.finalize().into_bytes().as_slice());
|
|
Ok(sealing_key)
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn seal_object_key(
|
|
object_key: [u8; 32],
|
|
external_key: [u8; 32],
|
|
sse_type: SSEType,
|
|
bucket: &str,
|
|
object: &str,
|
|
) -> Result<ManagedSealedKey, ApiError> {
|
|
let mut iv = [0u8; SEALED_KEY_IV_SIZE];
|
|
rand::rng().fill(&mut iv);
|
|
let sealing_key = derive_sealing_key(external_key, iv, managed_sse_domain(sse_type), bucket, object)?;
|
|
|
|
let mut header = [0u8; DARE_HEADER_SIZE];
|
|
header[0] = DARE_VERSION_20;
|
|
header[1] = DARE_CIPHER_AES_256_GCM;
|
|
header[2..4].copy_from_slice(&(31u16).to_le_bytes());
|
|
let mut stream_nonce = [0u8; 12];
|
|
rand::rng().fill(&mut stream_nonce);
|
|
stream_nonce[0] |= 0x80;
|
|
header[4..16].copy_from_slice(&stream_nonce);
|
|
|
|
let cipher = Aes256Gcm::new_from_slice(&sealing_key)
|
|
.map_err(|err| ApiError::from(StorageError::other(format!("Invalid sealing key: {err}"))))?;
|
|
let nonce = Nonce::try_from(stream_nonce.as_slice())
|
|
.map_err(|_| ApiError::from(StorageError::other("Invalid sealed object-key stream nonce")))?;
|
|
let ciphertext = cipher
|
|
.encrypt(
|
|
&nonce,
|
|
aes_gcm::aead::Payload {
|
|
msg: &object_key,
|
|
aad: &header[..4],
|
|
},
|
|
)
|
|
.map_err(|err| ApiError::from(StorageError::other(format!("Failed to seal object key: {err}"))))?;
|
|
|
|
let mut sealed_key = [0u8; SEALED_KEY_SIZE];
|
|
sealed_key[..DARE_HEADER_SIZE].copy_from_slice(&header);
|
|
sealed_key[DARE_HEADER_SIZE..].copy_from_slice(&ciphertext);
|
|
|
|
Ok(ManagedSealedKey { iv, sealed_key })
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn unseal_object_key(
|
|
sealed: &ManagedSealedKey,
|
|
external_key: [u8; 32],
|
|
sse_type: SSEType,
|
|
bucket: &str,
|
|
object: &str,
|
|
) -> Result<[u8; 32], ApiError> {
|
|
let header = &sealed.sealed_key[..DARE_HEADER_SIZE];
|
|
if header[0] != DARE_VERSION_20 || !is_supported_sealed_object_key_cipher(header[1]) {
|
|
return Err(ApiError::from(StorageError::other("Unsupported sealed object-key DARE header")));
|
|
}
|
|
if u16::from_le_bytes([header[2], header[3]]) != 31 || header[4] & 0x80 == 0 {
|
|
return Err(ApiError::from(StorageError::other("Invalid sealed object-key payload header")));
|
|
}
|
|
|
|
let sealing_key = derive_sealing_key(external_key, sealed.iv, managed_sse_domain(sse_type), bucket, object)?;
|
|
let plaintext = decrypt_sealed_object_key_payload(sealing_key, header, &sealed.sealed_key)?;
|
|
|
|
let object_key: [u8; 32] = plaintext
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| ApiError::from(StorageError::other("Sealed object key must decrypt to 32 bytes")))?;
|
|
Ok(object_key)
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn try_decode_minio_sealed_key(bytes: &str) -> Result<Option<[u8; SEALED_KEY_SIZE]>, ApiError> {
|
|
let decoded = BASE64_STANDARD
|
|
.decode(bytes)
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to decode sealed object key: {e}"))))?;
|
|
match decoded.as_slice().try_into() {
|
|
Ok(sealed_key) => Ok(Some(sealed_key)),
|
|
Err(_) => Ok(None),
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn try_decode_minio_sealing_iv(bytes: &str) -> Result<Option<[u8; SEALED_KEY_IV_SIZE]>, ApiError> {
|
|
let decoded = BASE64_STANDARD
|
|
.decode(bytes)
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to decode sealing IV: {e}"))))?;
|
|
match decoded.as_slice().try_into() {
|
|
Ok(iv) => Ok(Some(iv)),
|
|
Err(_) => Ok(None),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn build_ssec_read_headers(
|
|
algorithm: Option<&SSECustomerAlgorithm>,
|
|
key: Option<&SSECustomerKey>,
|
|
key_md5: Option<&SSECustomerKeyMD5>,
|
|
) -> HeaderMap {
|
|
let mut headers = HeaderMap::new();
|
|
|
|
if let Some(algorithm) = algorithm
|
|
&& let Ok(value) = HeaderValue::from_str(algorithm.as_str())
|
|
{
|
|
headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, value);
|
|
}
|
|
|
|
if let Some(key) = key
|
|
&& let Ok(value) = HeaderValue::from_str(key.as_str())
|
|
{
|
|
headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY, value);
|
|
}
|
|
|
|
if let Some(key_md5) = key_md5
|
|
&& let Ok(value) = HeaderValue::from_str(key_md5.as_str())
|
|
{
|
|
headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5, value);
|
|
}
|
|
|
|
headers
|
|
}
|
|
|
|
pub fn encryption_material_to_metadata(material: &EncryptionMaterial) -> Result<HashMap<String, String>, ApiError> {
|
|
let mut metadata = HashMap::new();
|
|
|
|
match material.sse_type {
|
|
SSEType::SseC => {
|
|
metadata.insert(
|
|
"x-amz-server-side-encryption".to_string(),
|
|
material.server_side_encryption.as_str().to_string(),
|
|
);
|
|
metadata.insert(
|
|
"x-amz-server-side-encryption-customer-algorithm".to_string(),
|
|
material.algorithm.as_str().to_string(),
|
|
);
|
|
if let Some(customer_key_md5) = &material.customer_key_md5 {
|
|
metadata.insert("x-amz-server-side-encryption-customer-key-md5".to_string(), customer_key_md5.to_string());
|
|
}
|
|
if let Some(original_size) = material.original_size {
|
|
metadata.insert(SSEC_ORIGINAL_SIZE_HEADER.to_string(), original_size.to_string());
|
|
}
|
|
|
|
// Persist the random base nonce for the SSE-C Direct scheme (default,
|
|
// non-`rio-v2` build) so decrypt can read it back instead of recomputing a
|
|
// deterministic value. Written under both the RustFS and MinIO keys per repo
|
|
// convention. This covers single-PUT persistence and multipart-session
|
|
// persistence (CreateMultipartUpload builds session metadata here). Under
|
|
// `rio-v2` the SSE-C path uses `EncryptionKeyKind::Object` and the sealed-key
|
|
// block below, so this branch is not taken.
|
|
if material.key_kind == EncryptionKeyKind::Direct {
|
|
metadata.insert(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode(material.base_nonce));
|
|
metadata.insert(
|
|
MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(),
|
|
BASE64_STANDARD.encode(material.base_nonce),
|
|
);
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
if let Some(sealed) = &material.managed_sealed_key {
|
|
metadata.insert(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode(sealed.iv));
|
|
metadata.insert(
|
|
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
|
|
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
|
|
);
|
|
metadata.insert(
|
|
MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER.to_string(),
|
|
BASE64_STANDARD.encode(sealed.sealed_key),
|
|
);
|
|
}
|
|
}
|
|
SSEType::SseS3 | SSEType::SseKms => {
|
|
let encrypted_data_key = material
|
|
.encrypted_data_key
|
|
.as_deref()
|
|
.ok_or_else(|| ApiError::from(StorageError::other("managed SSE materials must carry an encrypted data key")))?;
|
|
metadata.insert(
|
|
"x-amz-server-side-encryption".to_string(),
|
|
material.server_side_encryption.as_str().to_string(),
|
|
);
|
|
|
|
let internal_key_id = material
|
|
.kms_key_id
|
|
.clone()
|
|
.unwrap_or_else(|| SSEKMSKeyId::from("default".to_string()));
|
|
metadata.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), internal_key_id.clone());
|
|
|
|
if matches!(material.sse_type, SSEType::SseKms) {
|
|
metadata.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), internal_key_id);
|
|
}
|
|
|
|
if let Some(original_size) = material.original_size {
|
|
metadata.insert(INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER.to_string(), original_size.to_string());
|
|
}
|
|
|
|
if let Some(kms_context) = &material.managed_kms_context
|
|
&& !kms_context.is_empty()
|
|
{
|
|
if let Ok(serialized) = serde_json::to_string(kms_context) {
|
|
metadata.insert("x-rustfs-encryption-context".to_string(), serialized);
|
|
}
|
|
if matches!(material.sse_type, SSEType::SseKms)
|
|
&& let Ok(encoded) = encode_minio_kms_context(kms_context)
|
|
{
|
|
metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER.to_string(), encoded);
|
|
}
|
|
}
|
|
|
|
if material.key_kind == EncryptionKeyKind::Direct {
|
|
metadata.insert(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), BASE64_STANDARD.encode(encrypted_data_key));
|
|
metadata.insert(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode(material.base_nonce));
|
|
metadata.insert(INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), material.algorithm.as_str().to_string());
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
if let Some(sealed) = &material.managed_sealed_key {
|
|
metadata.insert(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode(sealed.iv));
|
|
metadata.insert(
|
|
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
|
|
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
|
|
);
|
|
if let Some(kms_key_id) = &material.kms_key_id {
|
|
metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), kms_key_id.to_string());
|
|
}
|
|
match material.sse_type {
|
|
SSEType::SseS3 => {
|
|
metadata.insert(
|
|
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(),
|
|
BASE64_STANDARD.encode(sealed.sealed_key),
|
|
);
|
|
}
|
|
SSEType::SseKms => {
|
|
metadata.insert(
|
|
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER.to_string(),
|
|
BASE64_STANDARD.encode(sealed.sealed_key),
|
|
);
|
|
}
|
|
SSEType::SseC => {}
|
|
}
|
|
metadata.insert(
|
|
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(),
|
|
BASE64_STANDARD.encode(encrypted_data_key),
|
|
);
|
|
} else if cfg!(feature = "rio-v2") {
|
|
metadata.insert(
|
|
MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(),
|
|
BASE64_STANDARD.encode(material.base_nonce),
|
|
);
|
|
metadata.insert(
|
|
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
|
|
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
|
|
);
|
|
if let Some(kms_key_id) = &material.kms_key_id {
|
|
metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), kms_key_id.to_string());
|
|
}
|
|
let encoded_key = BASE64_STANDARD.encode(encrypted_data_key);
|
|
match material.sse_type {
|
|
SSEType::SseS3 => {
|
|
metadata.insert(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(), encoded_key);
|
|
}
|
|
SSEType::SseKms => {
|
|
metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER.to_string(), encoded_key.clone());
|
|
metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), encoded_key);
|
|
}
|
|
SSEType::SseC => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(metadata)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Core API - Unified Encryption/Decryption Entry Points
|
|
// ============================================================================
|
|
|
|
/// **Core API**: Apply encryption based on request parameters
|
|
///
|
|
/// This function automatically routes to the appropriate encryption method:
|
|
/// - SSE-C if customer key is provided
|
|
/// - SSE-S3/SSE-KMS if server-side encryption is requested
|
|
/// - None if no encryption is requested
|
|
///
|
|
/// # Arguments
|
|
/// * `request` - Encryption request with all possible encryption parameters
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(Some(material))` - Encryption should be applied with the returned material
|
|
/// * `Ok(None)` - No encryption requested
|
|
/// * `Err` - Encryption configuration error
|
|
///
|
|
/// # Example
|
|
/// ```rust,ignore
|
|
/// let request = EncryptionRequest {
|
|
/// bucket: &bucket,
|
|
/// key: &key,
|
|
/// server_side_encryption: effective_sse.as_ref(),
|
|
/// ssekms_key_id: effective_kms_key_id.as_deref(),
|
|
/// sse_customer_algorithm: sse_customer_algorithm.as_ref(),
|
|
/// sse_customer_key: sse_customer_key.as_deref(),
|
|
/// sse_customer_key_md5: sse_customer_key_md5.as_deref(),
|
|
/// content_size: actual_size,
|
|
/// part_number: None,
|
|
/// };
|
|
///
|
|
/// if let Some(material) = sse_encryption(request).await? {
|
|
/// reader = material.wrap_reader(reader);
|
|
/// metadata.extend(material.metadata);
|
|
/// }
|
|
/// ```
|
|
pub async fn sse_encryption(request: EncryptionRequest<'_>) -> Result<Option<EncryptionMaterial>, ApiError> {
|
|
validate_sse_headers_for_write(
|
|
request.server_side_encryption.as_ref(),
|
|
request.ssekms_key_id.as_ref(),
|
|
request.ssekms_context.as_ref(),
|
|
request.sse_customer_algorithm.as_ref(),
|
|
request.sse_customer_key.as_ref(),
|
|
request.sse_customer_key_md5.as_ref(),
|
|
true,
|
|
)?;
|
|
|
|
// Priority 1: SSE-C (customer-provided key)
|
|
if let (Some(algorithm), Some(key), Some(key_md5)) =
|
|
(request.sse_customer_algorithm, request.sse_customer_key, request.sse_customer_key_md5)
|
|
{
|
|
return apply_ssec_encryption_material(request.bucket, request.key, algorithm, key, key_md5, request.content_size)
|
|
.await
|
|
.map(Some);
|
|
}
|
|
|
|
// Priority 2: Managed SSE (SSE-S3 or SSE-KMS)
|
|
let sse_config = prepare_sse_configuration(request.bucket, request.server_side_encryption, request.ssekms_key_id).await?;
|
|
|
|
if let Some(sse_config) = sse_config
|
|
&& is_managed_sse(&sse_config.effective_sse)
|
|
{
|
|
return apply_managed_encryption_material(
|
|
request.bucket,
|
|
request.key,
|
|
sse_config.effective_sse,
|
|
sse_config.effective_kms_key_id,
|
|
request.ssekms_context,
|
|
request.content_size,
|
|
)
|
|
.await
|
|
.map(Some);
|
|
}
|
|
|
|
// No encryption requested
|
|
Ok(None)
|
|
}
|
|
|
|
/// **Core API**: Apply encryption based on request parameters
|
|
///
|
|
/// sse_prepare_encryption, support SSE-C, SSE-S3, SSE-KMS
|
|
pub struct PrepareEncryptionRequest<'a> {
|
|
/// Bucket name
|
|
pub bucket: &'a str,
|
|
/// Object key
|
|
pub key: &'a str,
|
|
/// Server-side encryption algorithm (SSE-S3 or SSE-KMS)
|
|
pub server_side_encryption: Option<ServerSideEncryption>,
|
|
/// KMS key ID (for SSE-KMS)
|
|
pub ssekms_key_id: Option<SSEKMSKeyId>,
|
|
/// Optional client-provided KMS context for SSE-KMS.
|
|
pub ssekms_context: Option<HashMap<String, String>>,
|
|
/// SSE-C algorithm (customer-provided key)
|
|
pub sse_customer_algorithm: Option<SSECustomerAlgorithm>,
|
|
/// SSE-C key (Base64-encoded)
|
|
pub sse_customer_key: Option<SSECustomerKey>,
|
|
/// SSE-C key MD5 (Base64-encoded)
|
|
pub sse_customer_key_md5: Option<SSECustomerKeyMD5>,
|
|
}
|
|
|
|
pub async fn sse_prepare_encryption(request: PrepareEncryptionRequest<'_>) -> Result<Option<EncryptionMaterial>, ApiError> {
|
|
validate_sse_headers_for_write(
|
|
request.server_side_encryption.as_ref(),
|
|
request.ssekms_key_id.as_ref(),
|
|
request.ssekms_context.as_ref(),
|
|
request.sse_customer_algorithm.as_ref(),
|
|
request.sse_customer_key.as_ref(),
|
|
request.sse_customer_key_md5.as_ref(),
|
|
true,
|
|
)?;
|
|
|
|
let sse_type = prepare_sse_configuration_v2(
|
|
request.bucket,
|
|
request.server_side_encryption,
|
|
request.sse_customer_algorithm,
|
|
request.sse_customer_key.clone(),
|
|
request.sse_customer_key_md5,
|
|
request.ssekms_key_id,
|
|
)
|
|
.await?;
|
|
|
|
// apply encryption material
|
|
let material = match sse_type {
|
|
Some(SseTypeV2::SseS3(sse)) => {
|
|
apply_managed_encryption_material(request.bucket, request.key, sse, None, request.ssekms_context, 0).await?
|
|
}
|
|
Some(SseTypeV2::SseKms(sse, kms_key_id)) => {
|
|
apply_managed_encryption_material(request.bucket, request.key, sse, kms_key_id, request.ssekms_context, 0).await?
|
|
}
|
|
Some(SseTypeV2::SseC(algorithm, _, key_md5)) => {
|
|
apply_ssec_prepare_encryption_material(request.bucket, request.key, algorithm, request.sse_customer_key, key_md5)
|
|
.await?
|
|
}
|
|
None => return Ok(None),
|
|
};
|
|
|
|
Ok(Some(material))
|
|
}
|
|
|
|
/// **Core API**: Apply decryption based on stored metadata
|
|
///
|
|
/// This function automatically detects the encryption type from metadata:
|
|
/// - SSE-C if customer key is provided
|
|
/// - SSE-S3/SSE-KMS if managed encryption metadata is found
|
|
/// - None if object is not encrypted
|
|
///
|
|
/// # Arguments
|
|
/// * `request` - Decryption request with metadata and optional customer key
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(Some(material))` - Decryption should be applied with the returned material
|
|
/// * `Ok(None)` - Object is not encrypted
|
|
/// * `Err` - Decryption configuration error or key mismatch
|
|
///
|
|
/// # Example
|
|
/// ```rust,ignore
|
|
/// let request = DecryptionRequest {
|
|
/// bucket: &bucket,
|
|
/// key: &key,
|
|
/// metadata: &metadata,
|
|
/// sse_customer_key: sse_customer_key.as_deref(),
|
|
/// sse_customer_key_md5: sse_customer_key_md5.as_deref(),
|
|
/// };
|
|
///
|
|
/// if let Some(material) = sse_decryption(request).await? {
|
|
/// content_size = material.original_size.unwrap_or(actual_size);
|
|
/// }
|
|
/// ```
|
|
pub async fn sse_decryption(request: DecryptionRequest<'_>) -> Result<Option<DecryptionMaterial>, ApiError> {
|
|
// Check for SSE-C encryption
|
|
if request
|
|
.metadata
|
|
.contains_key("x-amz-server-side-encryption-customer-algorithm")
|
|
{
|
|
let (key, key_md5) = match (request.sse_customer_key, request.sse_customer_key_md5) {
|
|
(Some(k), Some(md5)) => (k, md5),
|
|
_ => {
|
|
return Err(ssec_invalid_request(
|
|
"The object was stored using a form of Server Side Encryption. \
|
|
The correct parameters must be provided to retrieve the object.",
|
|
));
|
|
}
|
|
};
|
|
|
|
// Verify that the provided key MD5 matches the stored MD5 for security
|
|
let stored_md5 = request.metadata.get("x-amz-server-side-encryption-customer-key-md5");
|
|
verify_ssec_key_match(key_md5, stored_md5)?;
|
|
|
|
let mut material = apply_ssec_decryption_material(request.bucket, request.key, request.metadata, key, key_md5).await?;
|
|
material.customer_key_md5 = Some(key_md5.clone());
|
|
return Ok(Some(material));
|
|
}
|
|
|
|
// Check for managed SSE encryption
|
|
if contains_managed_encryption_metadata(request.metadata) {
|
|
return apply_managed_decryption_material(request.bucket, request.key, request.metadata).await;
|
|
}
|
|
|
|
// No encryption detected
|
|
Ok(None)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Internal Implementation - SSE-C
|
|
// ============================================================================
|
|
|
|
async fn apply_ssec_prepare_encryption_material(
|
|
bucket: &str,
|
|
key: &str,
|
|
algorithm: SSECustomerAlgorithm,
|
|
sse_key: Option<SSECustomerKey>,
|
|
sse_key_md5: SSECustomerKeyMD5,
|
|
) -> Result<EncryptionMaterial, ApiError> {
|
|
#[cfg(feature = "rio-v2")]
|
|
let (key_bytes, base_nonce, key_kind, managed_sealed_key) = if let Some(sse_key) = sse_key {
|
|
let validated = validate_ssec_params(SsecParams {
|
|
algorithm: algorithm.clone(),
|
|
key: sse_key,
|
|
key_md5: sse_key_md5.clone(),
|
|
})?;
|
|
let object_key = derive_object_key(validated.key_bytes)?;
|
|
let sealed_key = seal_object_key(object_key, validated.key_bytes, SSEType::SseC, bucket, key)?;
|
|
(object_key, [0; 12], EncryptionKeyKind::Object, Some(sealed_key))
|
|
} else {
|
|
([0; 32], [0; 12], EncryptionKeyKind::Direct, None)
|
|
};
|
|
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
let (key_bytes, base_nonce, key_kind, managed_sealed_key) = {
|
|
let _ = (bucket, key, sse_key);
|
|
// Generate a real random nonce for the multipart session and persist it (see
|
|
// `encryption_material_to_metadata`). Every part of this upload reads the same
|
|
// persisted nonce back via `apply_ssec_decryption_material`, so all parts share one
|
|
// nonce without reintroducing the deterministic (bucket, key) reuse hazard.
|
|
// key_bytes stays a placeholder here: it is neither used for encryption nor persisted;
|
|
// the real customer key is validated per part on upload.
|
|
let mut base_nonce = [0u8; 12];
|
|
rand::rng().fill_bytes(&mut base_nonce);
|
|
([0; 32], base_nonce, EncryptionKeyKind::Direct, None)
|
|
};
|
|
|
|
Ok(EncryptionMaterial {
|
|
sse_type: SSEType::SseC,
|
|
server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AES256),
|
|
kms_key_id: None,
|
|
algorithm,
|
|
key_bytes,
|
|
base_nonce,
|
|
encrypted_data_key: None,
|
|
customer_key_md5: Some(sse_key_md5),
|
|
original_size: None,
|
|
key_kind,
|
|
managed_kms_context: None,
|
|
managed_sealed_key,
|
|
})
|
|
}
|
|
|
|
async fn apply_ssec_encryption_material(
|
|
bucket: &str,
|
|
key: &str,
|
|
algorithm: SSECustomerAlgorithm,
|
|
sse_key: SSECustomerKey,
|
|
sse_key_md5: SSECustomerKeyMD5,
|
|
content_size: i64,
|
|
) -> Result<EncryptionMaterial, ApiError> {
|
|
let params = SsecParams {
|
|
algorithm,
|
|
key: sse_key,
|
|
key_md5: sse_key_md5,
|
|
};
|
|
|
|
let validated = validate_ssec_params(params)?;
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
let (key_bytes, base_nonce, key_kind, managed_sealed_key) = {
|
|
let object_key = derive_object_key(validated.key_bytes)?;
|
|
let sealed_key = seal_object_key(object_key, validated.key_bytes, SSEType::SseC, bucket, key)?;
|
|
(object_key, [0; 12], EncryptionKeyKind::Object, Some(sealed_key))
|
|
};
|
|
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
let (key_bytes, base_nonce, key_kind, managed_sealed_key) = {
|
|
// Use a fresh random nonce per encryption. A deterministic nonce derived from
|
|
// (bucket, key) would repeat whenever the same object is overwritten under the same
|
|
// SSE-C key, reusing an identical (key, nonce) pair and catastrophically breaking
|
|
// AES-256-GCM. The nonce is persisted (see `encryption_material_to_metadata`) and read
|
|
// back on decrypt (see `apply_ssec_decryption_material`).
|
|
let _ = (bucket, key);
|
|
let mut base_nonce = [0u8; 12];
|
|
rand::rng().fill_bytes(&mut base_nonce);
|
|
(validated.key_bytes, base_nonce, EncryptionKeyKind::Direct, None)
|
|
};
|
|
|
|
// Build metadata
|
|
Ok(EncryptionMaterial {
|
|
sse_type: SSEType::SseC,
|
|
server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AES256),
|
|
kms_key_id: None,
|
|
algorithm: validated.algorithm,
|
|
key_bytes,
|
|
base_nonce,
|
|
encrypted_data_key: None,
|
|
customer_key_md5: Some(validated.key_md5),
|
|
original_size: Some(content_size),
|
|
key_kind,
|
|
managed_kms_context: None,
|
|
managed_sealed_key,
|
|
})
|
|
}
|
|
|
|
/// Resolve the SSE-C Direct base nonce for decryption.
|
|
///
|
|
/// New objects persist a random nonce at encryption time (see
|
|
/// `encryption_material_to_metadata`) under `INTERNAL_ENCRYPTION_IV_HEADER` (preferred) or,
|
|
/// for MinIO interop, `MINIO_INTERNAL_ENCRYPTION_IV_HEADER`. Legacy objects written before
|
|
/// random nonces were persisted carry no stored IV; for those we fall back to the
|
|
/// deterministic `generate_ssec_nonce(bucket, key)` value they were originally encrypted
|
|
/// with, so previously stored objects still decrypt correctly.
|
|
fn read_stored_ssec_nonce(metadata: &HashMap<String, String>, bucket: &str, key: &str) -> [u8; 12] {
|
|
metadata
|
|
.get(INTERNAL_ENCRYPTION_IV_HEADER)
|
|
.or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER))
|
|
.and_then(|encoded| BASE64_STANDARD.decode(encoded).ok())
|
|
.and_then(|bytes| <[u8; 12]>::try_from(bytes.as_slice()).ok())
|
|
.unwrap_or_else(|| generate_ssec_nonce(bucket, key))
|
|
}
|
|
|
|
async fn apply_ssec_decryption_material(
|
|
bucket: &str,
|
|
key: &str,
|
|
metadata: &HashMap<String, String>,
|
|
sse_key: &str,
|
|
sse_key_md5: &str,
|
|
) -> Result<DecryptionMaterial, ApiError> {
|
|
// Validate provided key
|
|
let algorithm = metadata
|
|
.get("x-amz-server-side-encryption-customer-algorithm")
|
|
.map(|s| s.as_str())
|
|
.unwrap_or("AES256");
|
|
|
|
let params = SsecParams {
|
|
algorithm: algorithm.to_string(),
|
|
key: sse_key.to_string(),
|
|
key_md5: sse_key_md5.to_string(),
|
|
};
|
|
|
|
let validated = validate_ssec_params(params)?;
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
let (key_bytes, base_nonce, key_kind) = if let Some(sealed_key) = parse_minio_managed_sealed_key(metadata, SSEType::SseC)? {
|
|
(
|
|
unseal_object_key(&sealed_key, validated.key_bytes, SSEType::SseC, bucket, key)?,
|
|
[0; 12],
|
|
EncryptionKeyKind::Object,
|
|
)
|
|
} else {
|
|
let base_nonce = read_stored_ssec_nonce(metadata, bucket, key);
|
|
(validated.key_bytes, base_nonce, EncryptionKeyKind::Direct)
|
|
};
|
|
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
let (key_bytes, base_nonce, key_kind) = {
|
|
let base_nonce = read_stored_ssec_nonce(metadata, bucket, key);
|
|
(validated.key_bytes, base_nonce, EncryptionKeyKind::Direct)
|
|
};
|
|
|
|
Ok(DecryptionMaterial {
|
|
sse_type: SSEType::SseC,
|
|
server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AES256), // const
|
|
kms_key_id: None,
|
|
algorithm: SSECustomerAlgorithm::from(algorithm),
|
|
|
|
customer_key_md5: None,
|
|
key_bytes,
|
|
base_nonce,
|
|
key_kind,
|
|
})
|
|
}
|
|
|
|
// ============================================================================
|
|
// Internal Implementation - Managed SSE (SSE-S3 / SSE-KMS)
|
|
// ============================================================================
|
|
|
|
async fn apply_managed_encryption_material(
|
|
bucket: &str,
|
|
key: &str,
|
|
server_side_encryption: ServerSideEncryption,
|
|
kms_key_id: Option<SSEKMSKeyId>,
|
|
ssekms_context: Option<HashMap<String, String>>,
|
|
content_size: i64,
|
|
) -> Result<EncryptionMaterial, ApiError> {
|
|
if !is_managed_sse(&server_side_encryption) {
|
|
return Err(ApiError::from(StorageError::other(format!(
|
|
"Unsupported server-side encryption: {}",
|
|
server_side_encryption.as_str()
|
|
))));
|
|
}
|
|
|
|
let encryption_type = match server_side_encryption.as_str() {
|
|
"AES256" => SSEType::SseS3,
|
|
"aws:kms" => SSEType::SseKms,
|
|
_ => SSEType::SseS3,
|
|
};
|
|
|
|
// Determine KMS key ID to use for internal key wrapping.
|
|
let mut kms_key_candidate = kms_key_id.clone();
|
|
if kms_key_candidate.is_none() {
|
|
// Try to get default key from KMS service (if available)
|
|
if let Some(service) = runtime_sources::current_encryption_service().await {
|
|
kms_key_candidate = service.get_default_key_id().cloned();
|
|
tracing::debug!(
|
|
default_key_id = ?kms_key_candidate,
|
|
"SSE-S3: KMS service available, default_key_id from config"
|
|
);
|
|
} else {
|
|
tracing::debug!("SSE-S3: KMS encryption service not available");
|
|
}
|
|
}
|
|
|
|
let kms_key_to_use = match (encryption_type, kms_key_candidate.clone()) {
|
|
(SSEType::SseS3, Some(kms_key_id)) => kms_key_id,
|
|
(SSEType::SseS3, None) => {
|
|
tracing::debug!("SSE-S3: no KMS key configured, falling back to \"default\" key ID");
|
|
"default".to_string()
|
|
}
|
|
(SSEType::SseKms, Some(kms_key_id)) => kms_key_id,
|
|
(SSEType::SseKms, None) => {
|
|
return Err(ApiError::from(StorageError::other(
|
|
"No KMS key available for managed server-side encryption (required for SSE-KMS)",
|
|
)));
|
|
}
|
|
_ => unreachable!("managed SSE branch only supports SSE-S3 or SSE-KMS"),
|
|
};
|
|
|
|
let provider = get_sse_dek_provider().await?;
|
|
let object_context = build_object_encryption_context(bucket, key, ssekms_context.as_ref());
|
|
let (data_key, encrypted_data_key) = provider.generate_sse_dek(&object_context, &kms_key_to_use).await?;
|
|
|
|
let algorithm = server_side_encryption.as_str().to_string();
|
|
#[cfg(feature = "rio-v2")]
|
|
let (key_bytes, base_nonce, key_kind, managed_sealed_key) = {
|
|
let object_key = derive_object_key(data_key.plaintext_key)?;
|
|
let sealed_key = seal_object_key(object_key, data_key.plaintext_key, encryption_type, bucket, key)?;
|
|
(object_key, [0u8; 12], EncryptionKeyKind::Object, Some(sealed_key))
|
|
};
|
|
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
let (key_bytes, base_nonce, key_kind, managed_sealed_key) =
|
|
(data_key.plaintext_key, data_key.nonce, EncryptionKeyKind::Direct, None);
|
|
|
|
Ok(EncryptionMaterial {
|
|
sse_type: encryption_type,
|
|
server_side_encryption,
|
|
kms_key_id: Some(kms_key_to_use),
|
|
algorithm,
|
|
key_bytes,
|
|
base_nonce,
|
|
encrypted_data_key: Some(encrypted_data_key),
|
|
customer_key_md5: None,
|
|
original_size: Some(content_size),
|
|
key_kind,
|
|
managed_kms_context: matches!(encryption_type, SSEType::SseKms).then_some(ssekms_context.unwrap_or_default()),
|
|
managed_sealed_key,
|
|
})
|
|
}
|
|
|
|
async fn apply_managed_decryption_material(
|
|
bucket: &str,
|
|
key: &str,
|
|
metadata: &HashMap<String, String>,
|
|
) -> Result<Option<DecryptionMaterial>, ApiError> {
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
let _ = (bucket, key);
|
|
if !contains_managed_encryption_metadata(metadata) || !metadata.contains_key("x-amz-server-side-encryption") {
|
|
return Ok(None);
|
|
}
|
|
|
|
// Safe: presence is guaranteed by the contains_key check above.
|
|
let server_side_encryption = metadata.get("x-amz-server-side-encryption").cloned().unwrap_or_default();
|
|
let normalized_metadata = normalize_managed_metadata(metadata);
|
|
|
|
let encryption_type = match server_side_encryption.as_str() {
|
|
ServerSideEncryption::AES256 => SSEType::SseS3,
|
|
ServerSideEncryption::AWS_KMS => SSEType::SseKms,
|
|
_ => SSEType::SseS3,
|
|
};
|
|
#[cfg(feature = "rio-v2")]
|
|
let minio_sealed_key = parse_minio_managed_sealed_key(metadata, encryption_type)?;
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
let minio_sealed_key: Option<ManagedSealedKey> = None;
|
|
|
|
let (encrypted_data_key, iv, algorithm) = if minio_sealed_key.is_some() {
|
|
let encrypted_key_b64 = normalized_metadata
|
|
.get(INTERNAL_ENCRYPTION_KEY_HEADER)
|
|
.or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER))
|
|
.ok_or_else(|| ApiError::from(StorageError::other("Missing encrypted key in metadata")))?;
|
|
let encrypted_data_key = BASE64_STANDARD
|
|
.decode(encrypted_key_b64)
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to decode encrypted key: {e}"))))?;
|
|
(
|
|
encrypted_data_key,
|
|
Vec::new(),
|
|
normalized_metadata
|
|
.get(INTERNAL_ENCRYPTION_ALGORITHM_HEADER)
|
|
.cloned()
|
|
.unwrap_or_else(|| "AES256".to_string()),
|
|
)
|
|
} else if let Some(service) = runtime_sources::current_encryption_service().await {
|
|
// Production mode: use service for metadata parsing
|
|
let parsed = service
|
|
.headers_to_metadata(&normalized_metadata)
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to parse encryption metadata: {e}"))))?;
|
|
|
|
if parsed.iv.len() != 12 {
|
|
return Err(ApiError::from(StorageError::other("Invalid encryption nonce length; expected 12 bytes")));
|
|
}
|
|
|
|
(parsed.encrypted_data_key, parsed.iv, parsed.algorithm)
|
|
} else {
|
|
// Test mode: parse metadata manually
|
|
let encrypted_key_b64 = normalized_metadata
|
|
.get(INTERNAL_ENCRYPTION_KEY_HEADER)
|
|
.ok_or_else(|| ApiError::from(StorageError::other("Missing encrypted key in metadata")))?;
|
|
let encrypted_data_key = BASE64_STANDARD
|
|
.decode(encrypted_key_b64)
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to decode encrypted key: {e}"))))?;
|
|
|
|
let iv_b64 = normalized_metadata
|
|
.get(INTERNAL_ENCRYPTION_IV_HEADER)
|
|
.ok_or_else(|| ApiError::from(StorageError::other("Missing IV in metadata")))?;
|
|
let iv = BASE64_STANDARD
|
|
.decode(iv_b64)
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to decode IV: {e}"))))?;
|
|
|
|
if iv.len() != 12 {
|
|
return Err(ApiError::from(StorageError::other("Invalid encryption nonce length; expected 12 bytes")));
|
|
}
|
|
|
|
let algorithm = normalized_metadata
|
|
.get(INTERNAL_ENCRYPTION_ALGORITHM_HEADER)
|
|
.cloned()
|
|
.unwrap_or_else(|| "AES256".to_string());
|
|
|
|
(encrypted_data_key, iv, algorithm)
|
|
};
|
|
|
|
// Extract KMS key ID from metadata (optional, used for provider context)
|
|
let kms_key_id = normalized_metadata
|
|
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
|
|
.or_else(|| metadata.get("x-amz-server-side-encryption-aws-kms-key-id"))
|
|
.cloned()
|
|
.unwrap_or_else(|| "default".to_string());
|
|
let kms_context = if matches!(encryption_type, SSEType::SseKms) {
|
|
decode_minio_kms_context(metadata)?
|
|
} else {
|
|
None
|
|
};
|
|
let object_context = build_object_encryption_context(bucket, key, kms_context.as_ref());
|
|
|
|
// Persisted wrapping format is the read-side source of truth. The
|
|
// advertised SSE scheme and current KMS availability are write policy
|
|
// and runtime state, neither of which identifies the historical provider.
|
|
let provider: Arc<dyn SseDekProvider> = if is_data_key_envelope(&encrypted_data_key) {
|
|
// When a test-injected provider is registered via set_sse_dek_provider_for_test,
|
|
// use it instead of creating a fresh KmsSseDekProvider which cannot resolve
|
|
// a KMS service without an AppContext.
|
|
//
|
|
// Reads GLOBAL_KMS_DEK_PROVIDER (populated only by set_sse_dek_provider_for_test),
|
|
// never GLOBAL_SSE_DEK_PROVIDER, so a local provider cached by a prior
|
|
// get_local_sse_dek_provider call cannot be selected to unwrap a KMS envelope.
|
|
if let Some(cached) = GLOBAL_KMS_DEK_PROVIDER.read().ok().and_then(|guard| guard.as_ref().cloned()) {
|
|
cached
|
|
} else {
|
|
Arc::new(KmsSseDekProvider::new().await?)
|
|
}
|
|
} else {
|
|
get_local_sse_dek_provider().await?
|
|
};
|
|
#[cfg(feature = "rio-v2")]
|
|
let decrypted_data_key = if is_legacy_rustfs_managed_metadata(&normalized_metadata) {
|
|
provider
|
|
.decrypt_legacy_sse_dek(&encrypted_data_key, &kms_key_id, &object_context)
|
|
.await
|
|
} else {
|
|
provider
|
|
.decrypt_sse_dek(&encrypted_data_key, &kms_key_id, &object_context)
|
|
.await
|
|
};
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
let decrypted_data_key = provider
|
|
.decrypt_sse_dek(&encrypted_data_key, &kms_key_id, &object_context)
|
|
.await;
|
|
let decrypted_data_key = decrypted_data_key?;
|
|
#[cfg(feature = "rio-v2")]
|
|
let (key_bytes, base_nonce, key_kind) = if let Some(sealed_key) = minio_sealed_key {
|
|
(
|
|
unseal_object_key(&sealed_key, decrypted_data_key, encryption_type, bucket, key)?,
|
|
[0u8; 12],
|
|
EncryptionKeyKind::Object,
|
|
)
|
|
} else {
|
|
let mut base_nonce = [0u8; 12];
|
|
base_nonce.copy_from_slice(&iv[..12]);
|
|
(decrypted_data_key, base_nonce, EncryptionKeyKind::Direct)
|
|
};
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
let (key_bytes, base_nonce, key_kind) = {
|
|
let mut base_nonce = [0u8; 12];
|
|
base_nonce.copy_from_slice(&iv[..12]);
|
|
(decrypted_data_key, base_nonce, EncryptionKeyKind::Direct)
|
|
};
|
|
|
|
Ok(Some(DecryptionMaterial {
|
|
sse_type: encryption_type,
|
|
server_side_encryption: ServerSideEncryption::from(server_side_encryption),
|
|
kms_key_id: Some(SSEKMSKeyId::from(kms_key_id)),
|
|
algorithm,
|
|
customer_key_md5: None,
|
|
|
|
key_bytes,
|
|
base_nonce,
|
|
key_kind,
|
|
}))
|
|
}
|
|
|
|
// ============================================================================
|
|
// Legacy Types (for backward compatibility)
|
|
// ============================================================================
|
|
|
|
/// Validated SSE-C parameters
|
|
#[derive(Debug, Clone)]
|
|
pub struct ValidatedSsecParams {
|
|
/// Encryption algorithm (always "AES256" for SSE-C)
|
|
pub algorithm: SSECustomerAlgorithm,
|
|
/// Decoded encryption key bytes (32 bytes for AES-256)
|
|
pub key_bytes: [u8; 32],
|
|
/// Base64-encoded MD5 of the key
|
|
pub key_md5: SSECustomerKeyMD5,
|
|
}
|
|
|
|
/// SSE-C parameters from client request
|
|
#[derive(Debug, Clone)]
|
|
pub struct SsecParams {
|
|
/// Encryption algorithm
|
|
pub algorithm: SSECustomerAlgorithm,
|
|
/// Base64-encoded encryption key
|
|
pub key: SSECustomerKey,
|
|
/// Base64-encoded MD5 of the key
|
|
pub key_md5: SSECustomerKeyMD5,
|
|
}
|
|
|
|
// ============================================================================
|
|
// SSE DEK Provider Abstraction (Factory Pattern)
|
|
// ============================================================================
|
|
|
|
/// Trait for SSE data encryption key management
|
|
/// Abstracts the source of encryption keys (KMS, test provider, etc.)
|
|
#[async_trait]
|
|
pub trait SseDekProvider: Send + Sync {
|
|
/// Generate an SSE data encryption key
|
|
async fn generate_sse_dek(&self, context: &ObjectEncryptionContext, kms_key_id: &str)
|
|
-> Result<(DataKey, Vec<u8>), ApiError>;
|
|
|
|
/// Decrypt an SSE data encryption key (returns only plaintext key, nonce should be read from metadata)
|
|
async fn decrypt_sse_dek(
|
|
&self,
|
|
encrypted_dek: &[u8],
|
|
kms_key_id: &str,
|
|
context: &ObjectEncryptionContext,
|
|
) -> Result<[u8; 32], ApiError>;
|
|
|
|
/// Decrypt a DEK from positively identified legacy managed metadata.
|
|
#[cfg(feature = "rio-v2")]
|
|
async fn decrypt_legacy_sse_dek(
|
|
&self,
|
|
encrypted_dek: &[u8],
|
|
kms_key_id: &str,
|
|
context: &ObjectEncryptionContext,
|
|
) -> Result<[u8; 32], ApiError> {
|
|
self.decrypt_sse_dek(encrypted_dek, kms_key_id, context).await
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Production KMS-backed DEK Provider
|
|
// ============================================================================
|
|
|
|
/// Production KMS-backed DEK provider
|
|
/// Resolves the latest ObjectEncryptionService on each call.
|
|
struct KmsSseDekProvider {
|
|
#[cfg(test)]
|
|
service_manager: Option<Arc<rustfs_kms::KmsServiceManager>>,
|
|
}
|
|
|
|
fn kms_operation_error(error: rustfs_kms::KmsError) -> ApiError {
|
|
ApiError::from(StorageError::other(error))
|
|
}
|
|
|
|
impl KmsSseDekProvider {
|
|
/// Create a new KMS-backed provider
|
|
pub async fn new() -> Result<Self, ApiError> {
|
|
let provider = Self {
|
|
#[cfg(test)]
|
|
service_manager: None,
|
|
};
|
|
provider
|
|
.current_service()
|
|
.await
|
|
.ok_or_else(|| ApiError::from(StorageError::other(KmsUnavailableError)))?;
|
|
Ok(provider)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn new_with_service_manager(service_manager: Arc<rustfs_kms::KmsServiceManager>) -> Result<Self, ApiError> {
|
|
let provider = Self {
|
|
service_manager: Some(service_manager),
|
|
};
|
|
provider
|
|
.current_service()
|
|
.await
|
|
.ok_or_else(|| ApiError::from(StorageError::other(KmsUnavailableError)))?;
|
|
Ok(provider)
|
|
}
|
|
|
|
async fn current_service(&self) -> Option<Arc<rustfs_kms::service::ObjectEncryptionService>> {
|
|
#[cfg(test)]
|
|
if let Some(service_manager) = &self.service_manager {
|
|
return service_manager.get_encryption_service().await;
|
|
}
|
|
|
|
runtime_sources::current_encryption_service().await
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SseDekProvider for KmsSseDekProvider {
|
|
async fn generate_sse_dek(
|
|
&self,
|
|
context: &ObjectEncryptionContext,
|
|
kms_key_id: &str,
|
|
) -> Result<(DataKey, Vec<u8>), ApiError> {
|
|
let kms_key_option = Some(kms_key_id.to_string());
|
|
let service = self
|
|
.current_service()
|
|
.await
|
|
.ok_or_else(|| ApiError::from(StorageError::other(KmsUnavailableError)))?;
|
|
let (data_key, encrypted_data_key) = service
|
|
.create_data_key(&kms_key_option, context)
|
|
.await
|
|
.map_err(kms_operation_error)?;
|
|
|
|
Ok((data_key, encrypted_data_key))
|
|
}
|
|
|
|
async fn decrypt_sse_dek(
|
|
&self,
|
|
encrypted_dek: &[u8],
|
|
_kms_key_id: &str,
|
|
context: &ObjectEncryptionContext,
|
|
) -> Result<[u8; 32], ApiError> {
|
|
let service = self
|
|
.current_service()
|
|
.await
|
|
.ok_or_else(|| ApiError::from(StorageError::other(KmsUnavailableError)))?;
|
|
let data_key = service
|
|
.decrypt_data_key(encrypted_dek, context)
|
|
.await
|
|
.map_err(kms_operation_error)?;
|
|
|
|
Ok(data_key.plaintext_key)
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
async fn decrypt_legacy_sse_dek(
|
|
&self,
|
|
encrypted_dek: &[u8],
|
|
_kms_key_id: &str,
|
|
_context: &ObjectEncryptionContext,
|
|
) -> Result<[u8; 32], ApiError> {
|
|
let service = self
|
|
.current_service()
|
|
.await
|
|
.ok_or_else(|| ApiError::from(StorageError::other(KmsUnavailableError)))?;
|
|
let data_key = service
|
|
.decrypt_legacy_data_key(encrypted_dek)
|
|
.await
|
|
.map_err(kms_operation_error)?;
|
|
|
|
Ok(data_key.plaintext_key)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test/Simple DEK Provider
|
|
// ============================================================================
|
|
|
|
/// Local SSE DEK provider for deployments without a KMS.
|
|
///
|
|
/// Uses `RUSTFS_SSE_S3_MASTER_KEY` (base64-encoded 32-byte key) to wrap
|
|
/// data-encryption keys with AES-256-GCM. This is the production fallback
|
|
/// when no KMS service is configured.
|
|
///
|
|
/// # Environment Variable
|
|
///
|
|
/// ```text
|
|
/// RUSTFS_SSE_S3_MASTER_KEY=<base64_encoded_32_byte_key>
|
|
/// ```
|
|
pub(crate) struct LocalSseDekProvider {
|
|
master_key: [u8; 32],
|
|
}
|
|
|
|
const LOCAL_SSE_DEK_FORMAT_VERSION: u8 = 1;
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct LocalSseDekEnvelope<'a> {
|
|
version: u8,
|
|
nonce: &'a str,
|
|
ciphertext: &'a str,
|
|
}
|
|
|
|
/// Test-only alias so existing test code that references `TestSseDekProvider`
|
|
/// continues to compile without changes.
|
|
#[cfg(test)]
|
|
#[allow(non_camel_case_types)]
|
|
pub(crate) type TestSseDekProvider = LocalSseDekProvider;
|
|
|
|
/// Parse the base64-encoded 32-byte master key from `__RUSTFS_SSE_SIMPLE_CMK`.
|
|
///
|
|
/// Returns an error (never crashes) for a missing, non-base64, wrong-length, or
|
|
/// all-zero key so callers on the request path can fail the request instead of
|
|
/// taking the whole server down (backlog#806).
|
|
#[cfg(test)]
|
|
fn parse_simple_sse_cmk(cmk_value: &str) -> Result<[u8; 32], ApiError> {
|
|
let trimmed = cmk_value.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(ApiError::from(StorageError::other(
|
|
"SSE simple mode requires __RUSTFS_SSE_SIMPLE_CMK to be set to a base64-encoded 32-byte key",
|
|
)));
|
|
}
|
|
let decoded = BASE64_STANDARD
|
|
.decode(trimmed)
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("__RUSTFS_SSE_SIMPLE_CMK must be valid base64: {e}"))))?;
|
|
let master_key: [u8; 32] = decoded.try_into().map_err(|v: Vec<u8>| {
|
|
ApiError::from(StorageError::other(format!(
|
|
"__RUSTFS_SSE_SIMPLE_CMK must decode to exactly 32 bytes, got {} bytes",
|
|
v.len()
|
|
)))
|
|
})?;
|
|
if master_key == [0u8; 32] {
|
|
return Err(ApiError::from(StorageError::other("__RUSTFS_SSE_SIMPLE_CMK must not be an all-zero key")));
|
|
}
|
|
Ok(master_key)
|
|
}
|
|
|
|
impl LocalSseDekProvider {
|
|
/// Create a LocalSseDekProvider with a predefined key (for testing)
|
|
#[cfg(test)]
|
|
pub fn new_with_key(master_key: [u8; 32]) -> Self {
|
|
Self { master_key }
|
|
}
|
|
|
|
/// Create a TestSseDekProvider from `__RUSTFS_SSE_SIMPLE_CMK` (test-only).
|
|
#[cfg(test)]
|
|
pub fn new() -> Result<Self, ApiError> {
|
|
let cmk_value = std::env::var("__RUSTFS_SSE_SIMPLE_CMK").unwrap_or_default();
|
|
// A missing/invalid key must surface as a request error, never crash the
|
|
// whole server: `LocalSseDekProvider::new` is reached from the SSE request
|
|
// path (get_sse_dek_provider), so `process::exit(1)` here turned a bad
|
|
// `__RUSTFS_SSE_SIMPLE_CMK` into a process crash-loop DoS (backlog#806).
|
|
let master_key = parse_simple_sse_cmk(&cmk_value)?;
|
|
tracing::info!("Successfully loaded SSE master key (32 bytes) from __RUSTFS_SSE_SIMPLE_CMK");
|
|
Ok(Self { master_key })
|
|
}
|
|
|
|
/// Create a local SSE DEK provider for SSE-S3 when KMS is not configured.
|
|
/// Requires RUSTFS_SSE_S3_MASTER_KEY to be a valid base64-encoded 32-byte key.
|
|
///
|
|
/// The failures here are server configuration problems, not internal
|
|
/// faults: surface them as `InvalidRequest` (HTTP 400) so a managed-SSE
|
|
/// request against an unconfigured server does not report 500 (rustfs#4844).
|
|
pub fn new_from_env() -> Result<Self, ApiError> {
|
|
fn sse_not_configured(message: impl Into<String>) -> ApiError {
|
|
ApiError {
|
|
code: S3ErrorCode::InvalidRequest,
|
|
message: message.into(),
|
|
source: None,
|
|
}
|
|
}
|
|
|
|
let Some(raw_value) = get_env_opt_str("RUSTFS_SSE_S3_MASTER_KEY").filter(|value| !value.trim().is_empty()) else {
|
|
return Err(sse_not_configured(
|
|
"SSE-S3 requires RUSTFS_SSE_S3_MASTER_KEY to be set to a base64-encoded 32-byte key when KMS is not configured",
|
|
));
|
|
};
|
|
|
|
let decoded = BASE64_STANDARD.decode(raw_value.trim()).map_err(|err| {
|
|
sse_not_configured(format!(
|
|
"RUSTFS_SSE_S3_MASTER_KEY must be valid base64 for SSE-S3 when KMS is not configured: {err}"
|
|
))
|
|
})?;
|
|
let master_key: [u8; 32] = decoded.try_into().map_err(|_| {
|
|
sse_not_configured("RUSTFS_SSE_S3_MASTER_KEY must decode to exactly 32 bytes for SSE-S3 when KMS is not configured")
|
|
})?;
|
|
|
|
tracing::info!("Using RUSTFS_SSE_S3_MASTER_KEY for SSE-S3 (KMS not configured)");
|
|
Ok(Self { master_key })
|
|
}
|
|
|
|
// Simple encryption of DEK
|
|
pub(crate) fn encrypt_dek(dek: [u8; 32], cmk_value: [u8; 32]) -> Result<String, ApiError> {
|
|
// Use AES-256-GCM to encrypt DEK
|
|
let key = Key::<Aes256Gcm>::from(cmk_value);
|
|
|
|
let cipher = Aes256Gcm::new(&key);
|
|
let mut nonce_bytes = [0u8; 12];
|
|
rand::rng().fill_bytes(&mut nonce_bytes);
|
|
let nonce = Nonce::from(nonce_bytes);
|
|
let ciphertext = cipher
|
|
.encrypt(&nonce, dek.as_slice())
|
|
.map_err(|_| ApiError::from(StorageError::other("Failed to encrypt DEK")))?;
|
|
|
|
let nonce = BASE64_STANDARD.encode(nonce);
|
|
let ciphertext = BASE64_STANDARD.encode(ciphertext);
|
|
serde_json::to_string(&LocalSseDekEnvelope {
|
|
version: LOCAL_SSE_DEK_FORMAT_VERSION,
|
|
nonce: &nonce,
|
|
ciphertext: &ciphertext,
|
|
})
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to serialize encrypted DEK: {e}"))))
|
|
}
|
|
|
|
// Simple decryption of DEK
|
|
pub(crate) fn decrypt_dek(encrypted_dek: &str, cmk_value: [u8; 32]) -> Result<[u8; 32], ApiError> {
|
|
let envelope = serde_json::from_str::<LocalSseDekEnvelope<'_>>(encrypted_dek);
|
|
let (nonce, ciphertext) = match envelope {
|
|
Ok(envelope) => {
|
|
if envelope.version != LOCAL_SSE_DEK_FORMAT_VERSION {
|
|
return Err(ApiError::from(StorageError::other(format!(
|
|
"Unsupported encrypted DEK format version: {}",
|
|
envelope.version
|
|
))));
|
|
}
|
|
(envelope.nonce, envelope.ciphertext)
|
|
}
|
|
Err(json_error) if encrypted_dek.trim_start().starts_with('{') => {
|
|
return Err(ApiError::from(StorageError::other(format!(
|
|
"Invalid encrypted DEK JSON format: {json_error}"
|
|
))));
|
|
}
|
|
Err(_) => {
|
|
// DEPRECATED: read-only compatibility for persisted colon-delimited DEKs.
|
|
// RUSTFS_COMPAT_TODO(sse-local-dek-json-v1): Remove after all supported upgrades have rewritten legacy DEKs.
|
|
let Some((nonce, ciphertext)) = encrypted_dek.split_once(':') else {
|
|
return Err(ApiError::from(StorageError::other("Invalid encrypted DEK format")));
|
|
};
|
|
if ciphertext.contains(':') {
|
|
return Err(ApiError::from(StorageError::other("Invalid encrypted DEK format")));
|
|
}
|
|
(nonce, ciphertext)
|
|
}
|
|
};
|
|
let nonce_vec = BASE64_STANDARD
|
|
.decode(nonce)
|
|
.map_err(|_| ApiError::from(StorageError::other("Invalid nonce format")))?;
|
|
let ciphertext = BASE64_STANDARD
|
|
.decode(ciphertext)
|
|
.map_err(|_| ApiError::from(StorageError::other("Invalid ciphertext format")))?;
|
|
|
|
let key = Key::<Aes256Gcm>::from(cmk_value);
|
|
let cipher = Aes256Gcm::new(&key);
|
|
|
|
let nonce_array: [u8; 12] = nonce_vec
|
|
.try_into()
|
|
.map_err(|_| ApiError::from(StorageError::other("Invalid nonce length")))?;
|
|
let nonce = Nonce::from(nonce_array);
|
|
|
|
let plaintext = cipher
|
|
.decrypt(&nonce, ciphertext.as_slice())
|
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to decrypt DEK: {e}"))))?;
|
|
|
|
let dek: [u8; 32] = plaintext
|
|
.try_into()
|
|
.map_err(|_| ApiError::from(StorageError::other("Decrypted DEK has invalid length")))?;
|
|
|
|
Ok(dek)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SseDekProvider for LocalSseDekProvider {
|
|
async fn generate_sse_dek(
|
|
&self,
|
|
_context: &ObjectEncryptionContext,
|
|
_kms_key_id: &str,
|
|
) -> Result<(DataKey, Vec<u8>), ApiError> {
|
|
// Generate a 32-byte array as data key
|
|
let mut dek = [0u8; 32];
|
|
rand::rng().fill_bytes(&mut dek);
|
|
|
|
// Generate a 12-byte array as IV
|
|
let mut nonce = [0u8; 12];
|
|
rand::rng().fill_bytes(&mut nonce);
|
|
|
|
// Encrypt data key with master key
|
|
let encrypted_dek = Self::encrypt_dek(dek, self.master_key)?;
|
|
|
|
// Return data key and IV
|
|
Ok((
|
|
DataKey {
|
|
plaintext_key: dek,
|
|
nonce,
|
|
},
|
|
encrypted_dek.into_bytes(),
|
|
))
|
|
}
|
|
|
|
async fn decrypt_sse_dek(
|
|
&self,
|
|
encrypted_dek: &[u8],
|
|
_kms_key_id: &str,
|
|
_context: &ObjectEncryptionContext,
|
|
) -> Result<[u8; 32], ApiError> {
|
|
// Decrypt data key with master key
|
|
let encrypted_dek_str = std::str::from_utf8(encrypted_dek)
|
|
.map_err(|_| ApiError::from(StorageError::other("Invalid UTF-8 in encrypted DEK")))?;
|
|
let dek = Self::decrypt_dek(encrypted_dek_str, self.master_key)?;
|
|
Ok(dek)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Factory Function for SSE DEK Provider
|
|
// ============================================================================
|
|
|
|
/// Global SSE DEK provider cache for local / test providers.
|
|
///
|
|
/// Populated by `get_local_sse_dek_provider` and (for backward-compat in tests)
|
|
/// `set_sse_dek_provider_for_test`. Read by `get_local_sse_dek_provider` only —
|
|
/// the KMS-envelope decrypt path uses `GLOBAL_KMS_DEK_PROVIDER` so that a cached
|
|
/// local provider can never be selected for a KMS-wrapped data-key.
|
|
static GLOBAL_SSE_DEK_PROVIDER: LazyLock<RwLock<Option<Arc<dyn SseDekProvider>>>> = LazyLock::new(|| RwLock::new(None));
|
|
|
|
/// Global KMS DEK provider cache for test-injected KMS providers.
|
|
///
|
|
/// Populated **only** by `set_sse_dek_provider_for_test` (test-only).
|
|
/// Read **only** by the KMS-envelope branch of `apply_managed_decryption_material`.
|
|
/// Separation from `GLOBAL_SSE_DEK_PROVIDER` prevents a previously-cached local
|
|
/// provider from being selected to unwrap a KMS data-key envelope.
|
|
static GLOBAL_KMS_DEK_PROVIDER: LazyLock<RwLock<Option<Arc<dyn SseDekProvider>>>> = LazyLock::new(|| RwLock::new(None));
|
|
|
|
/// Get or initialize the global SSE DEK provider
|
|
///
|
|
/// Factory function that automatically selects the appropriate provider:
|
|
/// - If `__RUSTFS_SSE_SIMPLE_CMK` environment variable exists: use SimpleSseDekProvider (test mode)
|
|
/// - Otherwise: use KmsSseDekProvider (production mode with real KMS)
|
|
///
|
|
/// # Returns
|
|
/// Arc to the global SSE DEK provider instance
|
|
///
|
|
/// # Example
|
|
/// ```rust,ignore
|
|
/// let provider = get_sse_dek_provider().await?;
|
|
/// let (data_key, encrypted_dek) = provider
|
|
/// .generate_sse_dek("bucket", "key", "kms-key-id")
|
|
/// .await?;
|
|
/// ```
|
|
pub async fn get_sse_dek_provider() -> Result<Arc<dyn SseDekProvider>, ApiError> {
|
|
if runtime_sources::current_encryption_service().await.is_some() {
|
|
debug!("Using KmsSseDekProvider (KMS configured)");
|
|
return Ok(Arc::new(KmsSseDekProvider::new().await?));
|
|
}
|
|
|
|
get_local_sse_dek_provider().await
|
|
}
|
|
|
|
async fn get_local_sse_dek_provider() -> Result<Arc<dyn SseDekProvider>, ApiError> {
|
|
// An explicitly injected test provider overrides both the cache and the
|
|
// environment-based selection below.
|
|
#[cfg(test)]
|
|
if let Some(injected) = test_injected_sse_dek_provider() {
|
|
return Ok(injected);
|
|
}
|
|
|
|
// Check if already initialized
|
|
if let Some(provider) = GLOBAL_SSE_DEK_PROVIDER
|
|
.read()
|
|
.map_err(|_| ApiError::from(StorageError::other("Failed to read global SSE DEK provider cache")))?
|
|
.as_ref()
|
|
.cloned()
|
|
{
|
|
return Ok(provider);
|
|
}
|
|
|
|
// In test mode, prefer the simple CMK provider when the env var is set.
|
|
#[cfg(test)]
|
|
{
|
|
if std::env::var("__RUSTFS_SSE_SIMPLE_CMK").is_ok() {
|
|
debug!("Using LocalSseDekProvider (test mode) based on __RUSTFS_SSE_SIMPLE_CMK");
|
|
let provider: Arc<dyn SseDekProvider> = Arc::new(LocalSseDekProvider::new()?);
|
|
let mut slot = GLOBAL_SSE_DEK_PROVIDER
|
|
.write()
|
|
.map_err(|_| ApiError::from(StorageError::other("Failed to update global SSE DEK provider cache")))?;
|
|
if let Some(existing) = slot.as_ref() {
|
|
return Ok(existing.clone());
|
|
}
|
|
*slot = Some(provider.clone());
|
|
return Ok(provider);
|
|
}
|
|
}
|
|
|
|
// Production fallback: local SSE-S3 provider (no KMS configured).
|
|
debug!("Using local SSE-S3 provider (KMS not configured)");
|
|
let provider: Arc<dyn SseDekProvider> = Arc::new(LocalSseDekProvider::new_from_env()?);
|
|
|
|
let mut slot = GLOBAL_SSE_DEK_PROVIDER
|
|
.write()
|
|
.map_err(|_| ApiError::from(StorageError::other("Failed to update global SSE DEK provider cache")))?;
|
|
if let Some(existing) = slot.as_ref() {
|
|
return Ok(existing.clone());
|
|
}
|
|
*slot = Some(provider.clone());
|
|
|
|
Ok(provider)
|
|
}
|
|
|
|
/// Reset both global SSE DEK provider caches (for testing only)
|
|
///
|
|
/// Clears GLOBAL_SSE_DEK_PROVIDER (local/test providers) and
|
|
/// GLOBAL_KMS_DEK_PROVIDER (test-injected KMS providers).
|
|
#[cfg(test)]
|
|
#[allow(dead_code)]
|
|
pub fn reset_sse_dek_provider() {
|
|
if let Ok(mut slot) = GLOBAL_SSE_DEK_PROVIDER.write() {
|
|
*slot = None;
|
|
}
|
|
if let Ok(mut slot) = GLOBAL_KMS_DEK_PROVIDER.write() {
|
|
*slot = None;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(dead_code)]
|
|
pub fn set_sse_dek_provider_for_test(provider: Arc<dyn SseDekProvider>) {
|
|
if let Ok(mut slot) = GLOBAL_KMS_DEK_PROVIDER.write() {
|
|
*slot = Some(provider.clone());
|
|
}
|
|
if let Ok(mut slot) = GLOBAL_SSE_DEK_PROVIDER.write() {
|
|
*slot = Some(provider);
|
|
}
|
|
}
|
|
|
|
/// Provider explicitly injected via `set_sse_dek_provider_for_test`, if any.
|
|
///
|
|
/// Reads `GLOBAL_KMS_DEK_PROVIDER` because that slot is populated *only* by the
|
|
/// test setter, so a hit here always means an explicit test injection.
|
|
#[cfg(test)]
|
|
fn test_injected_sse_dek_provider() -> Option<Arc<dyn SseDekProvider>> {
|
|
GLOBAL_KMS_DEK_PROVIDER.read().ok().and_then(|guard| guard.as_ref().cloned())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Legacy Functions (SSE-S3 / SSE-KMS)
|
|
// ============================================================================
|
|
|
|
/// Check if the server_side_encryption is a managed SSE type (SSE-S3 or SSE-KMS)
|
|
#[inline]
|
|
pub fn is_managed_sse(server_side_encryption: &ServerSideEncryption) -> bool {
|
|
matches!(server_side_encryption.as_str(), "AES256" | "aws:kms")
|
|
}
|
|
|
|
/// Strip source encryption metadata before constructing metadata for a copy destination.
|
|
///
|
|
/// Encryption metadata describes the physical source representation and must never be
|
|
/// inherited by a plaintext destination or by a destination using a different key.
|
|
pub fn strip_managed_encryption_metadata(metadata: &mut HashMap<String, String>) {
|
|
const KEYS: [&str; 19] = [
|
|
"x-amz-server-side-encryption",
|
|
"x-amz-server-side-encryption-aws-kms-key-id",
|
|
"x-amz-server-side-encryption-customer-algorithm",
|
|
"x-amz-server-side-encryption-customer-key-md5",
|
|
SSEC_ORIGINAL_SIZE_HEADER,
|
|
INTERNAL_ENCRYPTION_IV_HEADER,
|
|
"x-rustfs-encryption-tag",
|
|
INTERNAL_ENCRYPTION_KEY_HEADER,
|
|
"x-rustfs-encryption-context",
|
|
INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_IV_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER,
|
|
];
|
|
|
|
for key in KEYS.iter() {
|
|
metadata.remove(*key);
|
|
}
|
|
}
|
|
|
|
pub fn mark_encrypted_multipart_metadata(metadata: &mut HashMap<String, String>) {
|
|
metadata.insert(MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER.to_string(), String::new());
|
|
}
|
|
|
|
pub(crate) fn contains_managed_encryption_metadata(metadata: &HashMap<String, String>) -> bool {
|
|
metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER)
|
|
|| metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)
|
|
|| metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)
|
|
|| metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)
|
|
|| metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER)
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn is_legacy_rustfs_managed_metadata(metadata: &HashMap<String, String>) -> bool {
|
|
metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER)
|
|
&& metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER)
|
|
&& !metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)
|
|
&& !metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
fn parse_minio_managed_sealed_key(
|
|
metadata: &HashMap<String, String>,
|
|
sse_type: SSEType,
|
|
) -> Result<Option<ManagedSealedKey>, ApiError> {
|
|
let algorithm = match metadata.get(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER) {
|
|
Some(algorithm) => algorithm,
|
|
None => return Ok(None),
|
|
};
|
|
if algorithm != MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM {
|
|
return Ok(None);
|
|
}
|
|
|
|
let iv = match metadata.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER) {
|
|
Some(iv) => match try_decode_minio_sealing_iv(iv)? {
|
|
Some(iv) => iv,
|
|
None => return Ok(None),
|
|
},
|
|
None => return Ok(None),
|
|
};
|
|
|
|
let header_key = match sse_type {
|
|
SSEType::SseS3 => MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
|
|
SSEType::SseKms => MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER,
|
|
SSEType::SseC => MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER,
|
|
};
|
|
let sealed_key = match metadata.get(header_key) {
|
|
Some(value) => match try_decode_minio_sealed_key(value)? {
|
|
Some(sealed_key) => sealed_key,
|
|
None => return Ok(None),
|
|
},
|
|
None => return Ok(None),
|
|
};
|
|
|
|
Ok(Some(ManagedSealedKey { iv, sealed_key }))
|
|
}
|
|
|
|
fn normalize_managed_metadata(metadata: &HashMap<String, String>) -> HashMap<String, String> {
|
|
let mut normalized = metadata.clone();
|
|
|
|
if !normalized.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER)
|
|
&& let Some(value) = metadata
|
|
.get(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)
|
|
.or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER))
|
|
.or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER))
|
|
.or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER))
|
|
{
|
|
normalized.insert(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), value.clone());
|
|
}
|
|
|
|
if !normalized.contains_key(INTERNAL_ENCRYPTION_IV_HEADER)
|
|
&& let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER)
|
|
{
|
|
normalized.insert(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), value.clone());
|
|
}
|
|
|
|
if !normalized.contains_key(INTERNAL_ENCRYPTION_ALGORITHM_HEADER)
|
|
&& let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER)
|
|
{
|
|
normalized.insert(INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), value.clone());
|
|
}
|
|
|
|
if !normalized.contains_key(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
|
|
&& let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)
|
|
{
|
|
normalized.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), value.clone());
|
|
}
|
|
|
|
if !normalized.contains_key("x-rustfs-encryption-context")
|
|
&& let Some(value) = metadata.get(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("x-rustfs-encryption-context".to_string(), encoded);
|
|
}
|
|
|
|
normalized
|
|
}
|
|
|
|
// ============================================================================
|
|
// SSE-C Functions
|
|
// ============================================================================
|
|
|
|
/// Validate SSE-C parameters from client request
|
|
///
|
|
/// Validates:
|
|
/// 1. Algorithm is "AES256"
|
|
/// 2. Key is valid Base64 and exactly 32 bytes
|
|
/// 3. MD5 hash matches the key
|
|
///
|
|
/// # Returns
|
|
/// `ValidatedSsecParams` with decoded key bytes
|
|
pub fn validate_ssec_params(params: SsecParams) -> Result<ValidatedSsecParams, ApiError> {
|
|
if !SUPPORT_SSE_ALGORITHMS.contains(¶ms.algorithm.as_str()) {
|
|
return Err(ssec_invalid_request(&format!(
|
|
"Unsupported SSE-C algorithm. Only {DEFAULT_SSE_ALGORITHM} is supported."
|
|
)));
|
|
}
|
|
|
|
let key_bytes = BASE64_STANDARD.decode(¶ms.key).map_err(|e| {
|
|
error!("Failed to decode SSE-C key: {}", e);
|
|
ssec_invalid_request("Invalid SSE-C key: not valid Base64.")
|
|
})?;
|
|
|
|
if key_bytes.len() != 32 {
|
|
return Err(ssec_invalid_request(&format!(
|
|
"SSE-C key must be 32 bytes (256 bits), got {} bytes.",
|
|
key_bytes.len()
|
|
)));
|
|
}
|
|
|
|
let computed_md5 = BASE64_STANDARD.encode(md5::compute(&key_bytes).0);
|
|
if computed_md5 != params.key_md5 {
|
|
return Err(ssec_invalid_request(
|
|
"The calculated MD5 hash of the key did not match the hash that was provided.",
|
|
));
|
|
}
|
|
|
|
let key_array: [u8; 32] = key_bytes
|
|
.try_into()
|
|
.map_err(|_| ssec_invalid_request("SSE-C key must be exactly 32 bytes."))?;
|
|
|
|
Ok(ValidatedSsecParams {
|
|
algorithm: params.algorithm,
|
|
key_bytes: key_array,
|
|
key_md5: params.key_md5,
|
|
})
|
|
}
|
|
|
|
/// Generate deterministic nonce for SSE-C encryption
|
|
///
|
|
/// The nonce is derived from the bucket and key to ensure:
|
|
/// 1. Same object always gets the same nonce (required for SSE-C)
|
|
/// 2. Different objects get different nonces
|
|
pub fn generate_ssec_nonce(bucket: &str, key: &str) -> [u8; 12] {
|
|
let nonce_source = format!("{bucket}-{key}");
|
|
let nonce_hash = md5::compute(nonce_source.as_bytes());
|
|
let mut nonce = [0u8; 12];
|
|
nonce.copy_from_slice(&nonce_hash.0[..12]);
|
|
nonce
|
|
}
|
|
|
|
/// Verify SSE-C key matches the stored metadata.
|
|
///
|
|
/// Used during GetObject/HeadObject to ensure the client provided the correct key.
|
|
/// Returns 400 InvalidRequest on mismatch, consistent with AWS S3 behavior.
|
|
pub fn verify_ssec_key_match(provided_md5: &str, stored_md5: Option<&String>) -> Result<(), ApiError> {
|
|
match stored_md5 {
|
|
Some(stored) if stored == provided_md5 => Ok(()),
|
|
Some(_) => Err(ssec_invalid_request(
|
|
"The provided encryption parameters did not match the ones used originally to encrypt the object.",
|
|
)),
|
|
None => Err(ssec_invalid_request("Object has no stored SSE-C key metadata.")),
|
|
}
|
|
}
|
|
|
|
/// Validate that the SSE-C headers required for reading an SSE-C encrypted object
|
|
/// are present in the request. This is used by HeadObject which does not decrypt
|
|
/// the data but still must verify the caller holds the correct key.
|
|
///
|
|
/// Performs full validation: decodes the customer key, recomputes its MD5,
|
|
/// verifies the client-provided MD5 header matches the key, then compares
|
|
/// the computed MD5 against the stored metadata. This prevents a client from
|
|
/// bypassing validation by guessing/obtaining only the stored MD5 without
|
|
/// possessing the actual encryption key.
|
|
///
|
|
/// Returns `Ok(())` if either the object is not SSE-C encrypted, or valid SSE-C
|
|
/// headers are provided and the key matches. Returns 400 InvalidRequest otherwise.
|
|
pub fn validate_ssec_for_read(
|
|
metadata: &HashMap<String, String>,
|
|
sse_customer_key: Option<&SSECustomerKey>,
|
|
sse_customer_key_md5: Option<&SSECustomerKeyMD5>,
|
|
) -> Result<(), ApiError> {
|
|
let stored_algorithm = metadata.get("x-amz-server-side-encryption-customer-algorithm");
|
|
if stored_algorithm.is_none() {
|
|
return Ok(());
|
|
}
|
|
|
|
let (key, key_md5) = match (sse_customer_key, sse_customer_key_md5) {
|
|
(Some(k), Some(md5)) => (k, md5),
|
|
_ => {
|
|
return Err(ssec_invalid_request(
|
|
"The object was stored using a form of Server Side Encryption. \
|
|
The correct parameters must be provided to retrieve the object.",
|
|
));
|
|
}
|
|
};
|
|
|
|
// Full param validation: decode key, verify 32 bytes, recompute MD5
|
|
// from actual key bytes and compare to the client-provided MD5 header.
|
|
let algorithm = stored_algorithm.cloned().unwrap_or_else(|| DEFAULT_SSE_ALGORITHM.to_string());
|
|
let validated = validate_ssec_params(SsecParams {
|
|
algorithm,
|
|
key: key.to_string(),
|
|
key_md5: key_md5.clone(),
|
|
})?;
|
|
|
|
let stored_md5 = metadata.get("x-amz-server-side-encryption-customer-key-md5");
|
|
verify_ssec_key_match(&validated.key_md5, stored_md5)
|
|
}
|
|
|
|
/// Build an `ApiError` with `InvalidRequest` (HTTP 400) for SSE-C related errors.
|
|
fn ssec_invalid_request(message: &str) -> ApiError {
|
|
ApiError {
|
|
code: S3ErrorCode::InvalidRequest,
|
|
message: message.to_string(),
|
|
source: None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(unused_imports)]
|
|
mod tests {
|
|
use super::{
|
|
ApiError, DataKey, DecryptionRequest, EncryptionKeyKind, EncryptionMaterial, EncryptionRequest,
|
|
INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER, INTERNAL_ENCRYPTION_KEY_HEADER,
|
|
INTERNAL_ENCRYPTION_KEY_ID_HEADER, KmsSseDekProvider, KmsUnavailableError, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_IV_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
|
|
MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, PrepareEncryptionRequest, SSEC_ORIGINAL_SIZE_HEADER, SSEType,
|
|
SseDekProvider, SsecParams, StorageError, TestSseDekProvider, apply_managed_decryption_material,
|
|
apply_managed_encryption_material, encryption_material_to_metadata, extract_server_side_encryption_from_headers,
|
|
extract_ssec_params_from_headers, extract_ssekms_context_from_headers, generate_ssec_nonce, is_managed_sse,
|
|
kms_operation_error, map_get_object_reader_error, mark_encrypted_multipart_metadata, normalize_managed_metadata,
|
|
reset_sse_dek_provider, resolve_effective_kms_key_id, sse_decryption, sse_encryption, sse_prepare_encryption,
|
|
strip_managed_encryption_metadata, validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read,
|
|
validate_ssec_params, verify_ssec_key_match,
|
|
};
|
|
#[cfg(feature = "rio-v2")]
|
|
use super::{
|
|
DARE_CIPHER_AES_256_GCM, DARE_CIPHER_CHACHA20_POLY1305, MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM, SEALED_KEY_IV_SIZE,
|
|
SEALED_KEY_SIZE, is_legacy_rustfs_managed_metadata, is_supported_sealed_object_key_cipher,
|
|
};
|
|
|
|
#[test]
|
|
fn parse_simple_sse_cmk_rejects_bad_keys_without_crashing() {
|
|
// Empty / whitespace-only.
|
|
assert!(super::parse_simple_sse_cmk("").is_err());
|
|
assert!(super::parse_simple_sse_cmk(" ").is_err());
|
|
// Not valid base64.
|
|
assert!(super::parse_simple_sse_cmk("@@@not-base64@@@").is_err());
|
|
// Valid base64 but wrong length (16 bytes).
|
|
let short = BASE64_STANDARD.encode([1u8; 16]);
|
|
assert!(super::parse_simple_sse_cmk(&short).is_err());
|
|
// All-zero 32-byte key is rejected.
|
|
let zero = BASE64_STANDARD.encode([0u8; 32]);
|
|
assert!(super::parse_simple_sse_cmk(&zero).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn kms_operation_errors_preserve_retryability_classification() {
|
|
let unavailable = kms_operation_error(rustfs_kms::KmsError::backend_error("connection refused"));
|
|
let corrupt = kms_operation_error(rustfs_kms::KmsError::cryptographic_error("decrypt", "authentication failed"));
|
|
|
|
assert_eq!(unavailable.code, S3ErrorCode::ServiceUnavailable);
|
|
assert_eq!(corrupt.code, S3ErrorCode::InternalError);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_simple_sse_cmk_accepts_valid_32_byte_key() {
|
|
let mut key = [0u8; 32];
|
|
key[0] = 7;
|
|
let encoded = BASE64_STANDARD.encode(key);
|
|
let got = super::parse_simple_sse_cmk(&encoded).expect("valid 32-byte key must parse");
|
|
assert_eq!(got, key);
|
|
}
|
|
use aes_gcm::aead::{Aead, KeyInit};
|
|
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
|
use http::{HeaderMap, HeaderValue};
|
|
use rustfs_kms::types::ObjectEncryptionContext;
|
|
use rustfs_rio::{DecryptReader, EncryptReader};
|
|
use rustfs_utils::http::headers::AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT;
|
|
use s3s::S3ErrorCode;
|
|
use s3s::dto::{SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, ServerSideEncryption};
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, OnceLock};
|
|
use temp_env::async_with_vars;
|
|
use tokio::sync::Mutex;
|
|
|
|
static SSE_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
async fn lock_sse_test_state() -> tokio::sync::MutexGuard<'static, ()> {
|
|
SSE_TEST_LOCK.get_or_init(|| Mutex::new(())).lock().await
|
|
}
|
|
|
|
struct UnavailableSseDekProvider;
|
|
|
|
#[async_trait::async_trait]
|
|
impl SseDekProvider for UnavailableSseDekProvider {
|
|
async fn generate_sse_dek(
|
|
&self,
|
|
_context: &ObjectEncryptionContext,
|
|
_kms_key_id: &str,
|
|
) -> Result<(DataKey, Vec<u8>), ApiError> {
|
|
Err(ApiError::from(StorageError::other(KmsUnavailableError)))
|
|
}
|
|
|
|
async fn decrypt_sse_dek(
|
|
&self,
|
|
_encrypted_dek: &[u8],
|
|
_kms_key_id: &str,
|
|
_context: &ObjectEncryptionContext,
|
|
) -> Result<[u8; 32], ApiError> {
|
|
Err(ApiError::from(StorageError::other(KmsUnavailableError)))
|
|
}
|
|
}
|
|
|
|
fn local_sse_master_key_b64() -> String {
|
|
BASE64_STANDARD.encode([0x24u8; 32])
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_ssec_params_from_headers() {
|
|
let mut headers = http::HeaderMap::new();
|
|
let (algo, key, md5) = extract_ssec_params_from_headers(&headers).unwrap();
|
|
assert!(algo.is_none());
|
|
assert!(key.is_none());
|
|
assert!(md5.is_none());
|
|
|
|
headers.insert("x-amz-server-side-encryption-customer-algorithm", HeaderValue::from_static("AES256"));
|
|
let (algo, key, md5) = extract_ssec_params_from_headers(&headers).unwrap();
|
|
assert_eq!(algo.as_deref(), Some("AES256"));
|
|
assert!(key.is_none());
|
|
assert!(md5.is_none());
|
|
|
|
headers.insert(
|
|
"x-amz-server-side-encryption-customer-key",
|
|
HeaderValue::from_static("pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs="),
|
|
);
|
|
headers.insert(
|
|
"x-amz-server-side-encryption-customer-key-md5",
|
|
HeaderValue::from_static("DWygnHRtgiJ77HCm+1rvHw=="),
|
|
);
|
|
let (algo, key, md5) = extract_ssec_params_from_headers(&headers).unwrap();
|
|
assert_eq!(algo.as_deref(), Some("AES256"));
|
|
assert!(key.is_some());
|
|
assert!(md5.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_ssec_params_from_headers_rejects_invalid_utf8() {
|
|
let mut headers = http::HeaderMap::new();
|
|
// Header value with invalid UTF-8; to_str() will fail
|
|
let invalid_utf8 = HeaderValue::from_bytes(b"invalid-\x80-utf8").unwrap();
|
|
headers.insert("x-amz-server-side-encryption-customer-algorithm", invalid_utf8);
|
|
let result = extract_ssec_params_from_headers(&headers);
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_server_side_encryption_from_headers_rejects_invalid_utf8() {
|
|
let mut headers = http::HeaderMap::new();
|
|
let invalid_utf8 = HeaderValue::from_bytes(b"aes:kms-\x80-invalid").unwrap();
|
|
headers.insert("x-amz-server-side-encryption", invalid_utf8);
|
|
let result = extract_server_side_encryption_from_headers(&headers);
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_ssekms_context_from_headers_decodes_base64_json() {
|
|
let mut headers = http::HeaderMap::new();
|
|
let encoded =
|
|
BASE64_STANDARD.encode(serde_json::to_vec(&HashMap::from([("tenant".to_string(), "alpha".to_string())])).unwrap());
|
|
headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_KMS_CONTEXT, HeaderValue::from_str(&encoded).unwrap());
|
|
|
|
let context = extract_ssekms_context_from_headers(&headers)
|
|
.expect("kms context header should parse")
|
|
.expect("kms context should exist");
|
|
assert_eq!(context.get("tenant").map(String::as_str), Some("alpha"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_sse_headers_for_write_rejects_algorithm_without_key() {
|
|
let algorithm = SSECustomerAlgorithm::from("AES256".to_string());
|
|
let result = validate_sse_headers_for_write(
|
|
None,
|
|
None,
|
|
None,
|
|
Some(&algorithm),
|
|
None,
|
|
None,
|
|
true, // PutObject requires all three
|
|
);
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_sse_headers_for_write_rejects_algorithm_and_key_without_md5() {
|
|
let algorithm = SSECustomerAlgorithm::from("AES256".to_string());
|
|
let key = SSECustomerKey::from("pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=".to_string());
|
|
let result = validate_sse_headers_for_write(
|
|
None,
|
|
None,
|
|
None,
|
|
Some(&algorithm),
|
|
Some(&key),
|
|
None,
|
|
true, // PutObject requires all three
|
|
);
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_sse_headers_for_write_rejects_invalid_sse_algorithm() {
|
|
let bad_sse = ServerSideEncryption::from_static("aes:kms");
|
|
let result = validate_sse_headers_for_write(Some(&bad_sse), None, None, None, None, None, true);
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_sse_headers_for_write_rejects_ssec_with_managed_sse() {
|
|
let algorithm = SSECustomerAlgorithm::from("AES256".to_string());
|
|
let key = SSECustomerKey::from("pO3upElrwuEXSoFwCfnZPdSsmt/xWeFa0N9KgDijwVs=".to_string());
|
|
let key_md5 = SSECustomerKeyMD5::from("DWygnHRtgiJ77HCm+1rvHw==".to_string());
|
|
let server_side_encryption = ServerSideEncryption::from_static(ServerSideEncryption::AES256);
|
|
let result = validate_sse_headers_for_write(
|
|
Some(&server_side_encryption),
|
|
None,
|
|
None,
|
|
Some(&algorithm),
|
|
Some(&key),
|
|
Some(&key_md5),
|
|
true,
|
|
);
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_managed_sse() {
|
|
assert!(is_managed_sse(&ServerSideEncryption::from_static("AES256")));
|
|
assert!(is_managed_sse(&ServerSideEncryption::from_static("aws:kms")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_ssec_nonce() {
|
|
let nonce1 = generate_ssec_nonce("bucket1", "key1");
|
|
let nonce2 = generate_ssec_nonce("bucket1", "key1");
|
|
let nonce3 = generate_ssec_nonce("bucket1", "key2");
|
|
|
|
// Same inputs should produce same nonce
|
|
assert_eq!(nonce1, nonce2);
|
|
|
|
// Different inputs should produce different nonce
|
|
assert_ne!(nonce1, nonce3);
|
|
|
|
// Nonce should be exactly 12 bytes
|
|
assert_eq!(nonce1.len(), 12);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_ssec_params_success() {
|
|
let key = BASE64_STANDARD.encode([42u8; 32]);
|
|
let key_md5 = BASE64_STANDARD.encode(md5::compute([42u8; 32]).0);
|
|
|
|
let params = SsecParams {
|
|
algorithm: "AES256".to_string(),
|
|
key,
|
|
key_md5,
|
|
};
|
|
|
|
let result = validate_ssec_params(params);
|
|
assert!(result.is_ok());
|
|
let validated = result.unwrap();
|
|
assert_eq!(validated.key_bytes, [42u8; 32]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_ssec_params_wrong_algorithm() {
|
|
let key = BASE64_STANDARD.encode([42u8; 32]);
|
|
let key_md5 = BASE64_STANDARD.encode(md5::compute([42u8; 32]).0);
|
|
|
|
let params = SsecParams {
|
|
algorithm: "AES128".to_string(), // Wrong algorithm
|
|
key,
|
|
key_md5,
|
|
};
|
|
|
|
let result = validate_ssec_params(params);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_ssec_params_wrong_key_length() {
|
|
let key = BASE64_STANDARD.encode([42u8; 16]); // Only 16 bytes
|
|
let key_md5 = BASE64_STANDARD.encode(md5::compute([42u8; 16]).0);
|
|
|
|
let params = SsecParams {
|
|
algorithm: "AES256".to_string(),
|
|
key,
|
|
key_md5,
|
|
};
|
|
|
|
let result = validate_ssec_params(params);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_ssec_params_wrong_md5() {
|
|
let key = BASE64_STANDARD.encode([42u8; 32]);
|
|
let key_md5 = BASE64_STANDARD.encode([99u8; 16]); // Wrong MD5
|
|
|
|
let params = SsecParams {
|
|
algorithm: "AES256".to_string(),
|
|
key,
|
|
key_md5,
|
|
};
|
|
|
|
let result = validate_ssec_params(params);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sse_encryption_rejects_partial_ssec_headers() {
|
|
let bucket = "test-bucket";
|
|
let key = "test-key";
|
|
let sse_key = BASE64_STANDARD.encode([42u8; 32]);
|
|
let sse_key_md5 = BASE64_STANDARD.encode(md5::compute([42u8; 32]).0);
|
|
let content_size = 1024;
|
|
|
|
let request_missing_md5 = EncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: Some("AES256".to_string()),
|
|
sse_customer_key: Some(sse_key.clone()),
|
|
sse_customer_key_md5: None,
|
|
content_size,
|
|
};
|
|
|
|
let err = sse_encryption(request_missing_md5).await.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
|
|
let request_missing_key = EncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: Some("AES256".to_string()),
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: Some(sse_key_md5.clone()),
|
|
content_size,
|
|
};
|
|
|
|
let err = sse_encryption(request_missing_key).await.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
|
|
let request_missing_algorithm = EncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: Some(sse_key),
|
|
sse_customer_key_md5: Some(sse_key_md5),
|
|
content_size,
|
|
};
|
|
|
|
let err = sse_encryption(request_missing_algorithm).await.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sse_prepare_encryption_rejects_partial_ssec_headers() {
|
|
let bucket = "test-bucket";
|
|
let key = "test-key";
|
|
let sse_key_md5 = BASE64_STANDARD.encode(md5::compute([42u8; 32]).0);
|
|
|
|
let request_missing_algorithm = PrepareEncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: Some(sse_key_md5),
|
|
};
|
|
|
|
let err = sse_prepare_encryption(request_missing_algorithm).await.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_sse_headers_for_write_allows_aws_kms_without_key_id() {
|
|
let server_side_encryption: ServerSideEncryption = "aws:kms".to_string().into();
|
|
|
|
let result = validate_sse_headers_for_write(Some(&server_side_encryption), None, None, None, None, None, true);
|
|
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sse_prepare_encryption_rejects_ssec_headers_without_customer_key() {
|
|
let bucket = "test-bucket";
|
|
let key = "test-key";
|
|
let sse_key_md5 = BASE64_STANDARD.encode(md5::compute([42u8; 32]).0);
|
|
|
|
let request = PrepareEncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: Some("AES256".to_string()),
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: Some(sse_key_md5),
|
|
};
|
|
|
|
let error = sse_prepare_encryption(request)
|
|
.await
|
|
.expect_err("multipart preparation must require possession of the customer key");
|
|
assert_eq!(error.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
// ------------------------------------------------------------------------
|
|
// SSE-C Direct (default, non-`rio-v2` build) random-nonce persistence
|
|
// ------------------------------------------------------------------------
|
|
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
async fn ssec_direct_put_metadata(
|
|
bucket: &str,
|
|
key: &str,
|
|
customer_key: &str,
|
|
customer_key_md5: &str,
|
|
) -> HashMap<String, String> {
|
|
let material = sse_encryption(EncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: Some("AES256".to_string()),
|
|
sse_customer_key: Some(customer_key.to_string()),
|
|
sse_customer_key_md5: Some(customer_key_md5.to_string()),
|
|
content_size: 128,
|
|
})
|
|
.await
|
|
.expect("sse-c encryption")
|
|
.expect("sse-c material");
|
|
assert_eq!(material.key_kind, EncryptionKeyKind::Direct);
|
|
encryption_material_to_metadata(&material).expect("sse-c metadata should serialize")
|
|
}
|
|
|
|
// (a) Overwriting the same bucket/key under the same SSE-C key must persist a DIFFERENT
|
|
// random IV each time, and each stored IV must be read back on decrypt. This is the core
|
|
// of the fix: no (key, nonce) reuse across overwrites.
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
#[tokio::test]
|
|
async fn test_ssec_direct_random_nonce_is_unique_per_put_and_read_back() {
|
|
let bucket = "bucket";
|
|
let key = "object";
|
|
let customer_key_bytes = [0x24u8; 32];
|
|
let customer_key = BASE64_STANDARD.encode(customer_key_bytes);
|
|
let customer_key_md5 = BASE64_STANDARD.encode(md5::compute(customer_key_bytes).0);
|
|
|
|
let metadata_one = ssec_direct_put_metadata(bucket, key, &customer_key, &customer_key_md5).await;
|
|
let metadata_two = ssec_direct_put_metadata(bucket, key, &customer_key, &customer_key_md5).await;
|
|
|
|
let iv_one = metadata_one
|
|
.get(INTERNAL_ENCRYPTION_IV_HEADER)
|
|
.expect("first put persists a random IV");
|
|
let iv_two = metadata_two
|
|
.get(INTERNAL_ENCRYPTION_IV_HEADER)
|
|
.expect("second put persists a random IV");
|
|
|
|
assert_ne!(iv_one, iv_two, "overwriting the same object must not reuse the nonce");
|
|
|
|
// Dual-key persistence for MinIO interop: both headers carry the same value.
|
|
assert_eq!(metadata_one.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER), Some(iv_one));
|
|
assert_eq!(metadata_two.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER), Some(iv_two));
|
|
|
|
for (metadata, iv) in [(&metadata_one, iv_one), (&metadata_two, iv_two)] {
|
|
let decrypted = super::apply_ssec_decryption_material(bucket, key, metadata, &customer_key, &customer_key_md5)
|
|
.await
|
|
.expect("sse-c decryption material");
|
|
assert_eq!(
|
|
BASE64_STANDARD.encode(decrypted.base_nonce),
|
|
*iv,
|
|
"decrypt must read the persisted random nonce back"
|
|
);
|
|
assert_eq!(decrypted.key_bytes, customer_key_bytes);
|
|
}
|
|
}
|
|
|
|
// (b) Legacy compatibility: objects encrypted before this change carry no IV header. Decrypt
|
|
// must fall back to the deterministic `generate_ssec_nonce(bucket, key)` they were encrypted
|
|
// with, so previously stored objects keep decrypting.
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
#[tokio::test]
|
|
async fn test_ssec_direct_legacy_object_without_iv_falls_back_to_deterministic_nonce() {
|
|
let bucket = "bucket";
|
|
let key = "object";
|
|
let customer_key_bytes = [0x24u8; 32];
|
|
let customer_key = BASE64_STANDARD.encode(customer_key_bytes);
|
|
let customer_key_md5 = BASE64_STANDARD.encode(md5::compute(customer_key_bytes).0);
|
|
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string());
|
|
assert!(!metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER));
|
|
assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_IV_HEADER));
|
|
|
|
let decrypted = super::apply_ssec_decryption_material(bucket, key, &metadata, &customer_key, &customer_key_md5)
|
|
.await
|
|
.expect("sse-c decryption material");
|
|
|
|
assert_eq!(
|
|
decrypted.base_nonce,
|
|
generate_ssec_nonce(bucket, key),
|
|
"legacy objects must decrypt via the deterministic nonce fallback"
|
|
);
|
|
}
|
|
|
|
// (c) Byte-level round trip: data encrypted with the material's key + persisted nonce must
|
|
// decrypt with the key + nonce resolved from the stored metadata.
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
#[tokio::test]
|
|
async fn test_ssec_direct_persisted_nonce_round_trips_plaintext() {
|
|
use aes_gcm::{
|
|
Aes256Gcm, Nonce,
|
|
aead::{Aead, KeyInit},
|
|
};
|
|
|
|
let bucket = "bucket";
|
|
let key = "object";
|
|
let customer_key_bytes = [0x51u8; 32];
|
|
let customer_key = BASE64_STANDARD.encode(customer_key_bytes);
|
|
let customer_key_md5 = BASE64_STANDARD.encode(md5::compute(customer_key_bytes).0);
|
|
let plaintext = b"attack at dawn - sse-c round trip".to_vec();
|
|
|
|
let metadata = ssec_direct_put_metadata(bucket, key, &customer_key, &customer_key_md5).await;
|
|
|
|
// Encrypt with the key + nonce that were persisted at PUT time.
|
|
let enc_iv = BASE64_STANDARD
|
|
.decode(metadata.get(INTERNAL_ENCRYPTION_IV_HEADER).expect("persisted IV"))
|
|
.expect("valid base64 IV");
|
|
let cipher = Aes256Gcm::new_from_slice(&customer_key_bytes).expect("cipher");
|
|
let ciphertext = cipher
|
|
.encrypt(&Nonce::try_from(enc_iv.as_slice()).expect("nonce"), plaintext.as_ref())
|
|
.expect("encrypt");
|
|
|
|
// Resolve key + nonce purely from stored metadata (as decrypt does in production).
|
|
let decrypted_material = super::apply_ssec_decryption_material(bucket, key, &metadata, &customer_key, &customer_key_md5)
|
|
.await
|
|
.expect("sse-c decryption material");
|
|
let dec_cipher = Aes256Gcm::new_from_slice(&decrypted_material.key_bytes).expect("cipher");
|
|
let recovered = dec_cipher
|
|
.decrypt(
|
|
&Nonce::try_from(decrypted_material.base_nonce.as_slice()).expect("nonce"),
|
|
ciphertext.as_ref(),
|
|
)
|
|
.expect("decrypt with resolved key + nonce");
|
|
|
|
assert_eq!(recovered, plaintext);
|
|
}
|
|
|
|
// (d) Multipart: the random nonce generated at CreateMultipartUpload is persisted in the
|
|
// session metadata, and every part resolves the SAME nonce via `sse_decryption` on that
|
|
// session metadata. Parts must never diverge, and the value must not be deterministic.
|
|
#[cfg(not(feature = "rio-v2"))]
|
|
#[tokio::test]
|
|
async fn test_ssec_direct_multipart_all_parts_share_one_persisted_nonce() {
|
|
let bucket = "bucket";
|
|
let key = "object";
|
|
let customer_key_bytes = [0x33u8; 32];
|
|
let customer_key = BASE64_STANDARD.encode(customer_key_bytes);
|
|
let customer_key_md5 = BASE64_STANDARD.encode(md5::compute(customer_key_bytes).0);
|
|
|
|
let material = sse_prepare_encryption(PrepareEncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: Some("AES256".to_string()),
|
|
sse_customer_key: Some(customer_key.clone()),
|
|
sse_customer_key_md5: Some(customer_key_md5.clone()),
|
|
})
|
|
.await
|
|
.expect("prepare ssec")
|
|
.expect("prepare material");
|
|
assert_eq!(material.key_kind, EncryptionKeyKind::Direct);
|
|
|
|
let session_metadata = encryption_material_to_metadata(&material).expect("session metadata should serialize");
|
|
let session_iv = session_metadata
|
|
.get(INTERNAL_ENCRYPTION_IV_HEADER)
|
|
.expect("multipart session persists a random IV")
|
|
.clone();
|
|
// Random, not the deterministic bucket/key derivation.
|
|
assert_ne!(
|
|
BASE64_STANDARD.decode(&session_iv).expect("valid IV")[..],
|
|
generate_ssec_nonce(bucket, key)[..]
|
|
);
|
|
|
|
let resolve_part_nonce = |part_number: usize| {
|
|
let session_metadata = session_metadata.clone();
|
|
let customer_key = customer_key.clone();
|
|
let customer_key_md5 = customer_key_md5.clone();
|
|
async move {
|
|
let _ = part_number;
|
|
sse_decryption(DecryptionRequest {
|
|
bucket,
|
|
key,
|
|
metadata: &session_metadata,
|
|
sse_customer_key: Some(&customer_key),
|
|
sse_customer_key_md5: Some(&customer_key_md5),
|
|
})
|
|
.await
|
|
.expect("part decryption")
|
|
.expect("part material")
|
|
.base_nonce
|
|
}
|
|
};
|
|
|
|
let part_one_nonce = resolve_part_nonce(1).await;
|
|
let part_two_nonce = resolve_part_nonce(2).await;
|
|
|
|
assert_eq!(part_one_nonce, part_two_nonce, "all parts of one upload must share the persisted nonce");
|
|
assert_eq!(BASE64_STANDARD.encode(part_one_nonce), session_iv);
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
#[tokio::test]
|
|
async fn test_sse_prepare_encryption_ssec_with_customer_key_stores_sealed_key_metadata() {
|
|
let bucket = "test-bucket";
|
|
let key = "test-key";
|
|
let customer_key_bytes = [0x24u8; 32];
|
|
let customer_key = BASE64_STANDARD.encode(customer_key_bytes);
|
|
let sse_key_md5 = BASE64_STANDARD.encode(md5::compute(customer_key_bytes).0);
|
|
|
|
let request = PrepareEncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: Some("AES256".to_string()),
|
|
sse_customer_key: Some(customer_key),
|
|
sse_customer_key_md5: Some(sse_key_md5),
|
|
};
|
|
|
|
let material = sse_prepare_encryption(request)
|
|
.await
|
|
.expect("prepare should accept full ssec headers")
|
|
.expect("ssec metadata should be generated");
|
|
assert_eq!(material.key_kind, EncryptionKeyKind::Object);
|
|
|
|
let metadata = encryption_material_to_metadata(&material).expect("ssec metadata should serialize");
|
|
assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_IV_HEADER));
|
|
assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER));
|
|
}
|
|
|
|
#[test]
|
|
fn test_encryption_material_to_metadata_persists_ssec_original_size() {
|
|
let metadata = encryption_material_to_metadata(&EncryptionMaterial {
|
|
sse_type: SSEType::SseC,
|
|
server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AES256),
|
|
kms_key_id: None,
|
|
algorithm: SSECustomerAlgorithm::from("AES256".to_string()),
|
|
key_bytes: [0u8; 32],
|
|
base_nonce: [0u8; 12],
|
|
encrypted_data_key: None,
|
|
customer_key_md5: Some("d41d8cd98f00b204e9800998ecf8427e".to_string()),
|
|
original_size: Some(1024),
|
|
key_kind: EncryptionKeyKind::Direct,
|
|
managed_kms_context: None,
|
|
managed_sealed_key: None,
|
|
})
|
|
.expect("ssec original-size metadata should serialize");
|
|
|
|
assert_eq!(metadata.get(SSEC_ORIGINAL_SIZE_HEADER).map(String::as_str), Some("1024"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sse_encryption_rejects_kms_key_with_invalid_algorithm() {
|
|
let bucket = "test-bucket";
|
|
let key = "test-key";
|
|
let content_size = 1024;
|
|
|
|
let request = EncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: Some("AES256".to_string().into()),
|
|
ssekms_key_id: Some("test-key".to_string()),
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
content_size,
|
|
};
|
|
|
|
let err = sse_encryption(request).await.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sse_encryption_rejects_kms_key_without_algorithm() {
|
|
let bucket = "test-bucket";
|
|
let key = "test-key";
|
|
let content_size = 1024;
|
|
|
|
let request = EncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: None,
|
|
ssekms_key_id: Some("test-key".to_string()),
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
content_size,
|
|
};
|
|
|
|
let err = sse_encryption(request).await.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sse_encryption_rejects_conflict_between_kms_and_ssec() {
|
|
let bucket = "test-bucket";
|
|
let key = "test-key";
|
|
let content_size = 1024;
|
|
let sse_key = BASE64_STANDARD.encode([42u8; 32]);
|
|
let sse_key_md5 = BASE64_STANDARD.encode(md5::compute([42u8; 32]).0);
|
|
|
|
let request = EncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: Some("aws:kms".to_string().into()),
|
|
ssekms_key_id: Some("test-key".to_string()),
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: Some("AES256".to_string()),
|
|
sse_customer_key: Some(sse_key),
|
|
sse_customer_key_md5: Some(sse_key_md5),
|
|
content_size,
|
|
};
|
|
|
|
let err = sse_encryption(request).await.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_effective_kms_key_id_ignores_bucket_default_for_explicit_sse_s3() {
|
|
let effective_sse = ServerSideEncryption::from_static(ServerSideEncryption::AES256);
|
|
|
|
let kms_key_id = resolve_effective_kms_key_id(Some(&effective_sse), None, || Some("bucket-default".to_string()));
|
|
|
|
assert_eq!(kms_key_id, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_effective_kms_key_id_uses_bucket_default_for_sse_kms() {
|
|
let effective_sse = ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS);
|
|
|
|
let kms_key_id = resolve_effective_kms_key_id(Some(&effective_sse), None, || Some("bucket-default".to_string()));
|
|
|
|
assert_eq!(kms_key_id.as_deref(), Some("bucket-default"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sse_encryption_persists_aws_kms_header_for_kms_objects() {
|
|
let metadata = encryption_material_to_metadata(&EncryptionMaterial {
|
|
sse_type: SSEType::SseKms,
|
|
server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS),
|
|
kms_key_id: Some("test-key".to_string()),
|
|
algorithm: SSECustomerAlgorithm::from(ServerSideEncryption::AWS_KMS.to_string()),
|
|
key_bytes: [7u8; 32],
|
|
base_nonce: [9u8; 12],
|
|
encrypted_data_key: Some(vec![1, 2, 3, 4]),
|
|
customer_key_md5: None,
|
|
original_size: Some(1024),
|
|
key_kind: EncryptionKeyKind::Direct,
|
|
managed_kms_context: None,
|
|
managed_sealed_key: None,
|
|
})
|
|
.expect("managed SSE metadata should serialize");
|
|
|
|
assert_eq!(metadata.get("x-amz-server-side-encryption").map(String::as_str), Some("aws:kms"));
|
|
assert_eq!(
|
|
metadata
|
|
.get("x-amz-server-side-encryption-aws-kms-key-id")
|
|
.map(String::as_str),
|
|
Some("test-key")
|
|
);
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
#[tokio::test]
|
|
async fn test_sse_kms_roundtrip_persists_and_uses_minio_context() {
|
|
use rustfs_kms::config::KmsConfig;
|
|
use rustfs_kms::types::{CreateKeyRequest, KeyUsage};
|
|
use tempfile::TempDir;
|
|
let _guard = lock_sse_test_state().await;
|
|
|
|
reset_sse_dek_provider();
|
|
let manager = rustfs_kms::init_global_kms_service_manager();
|
|
let temp_dir = TempDir::new().expect("temp dir");
|
|
manager
|
|
.reconfigure(KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults())
|
|
.await
|
|
.expect("kms reconfigure should succeed");
|
|
manager
|
|
.get_encryption_service()
|
|
.await
|
|
.expect("encryption service should exist")
|
|
.create_key(CreateKeyRequest {
|
|
key_name: Some("kms-test".to_string()),
|
|
key_usage: KeyUsage::EncryptDecrypt,
|
|
description: None,
|
|
policy: None,
|
|
tags: HashMap::new(),
|
|
origin: None,
|
|
})
|
|
.await
|
|
.expect("kms test key should be created");
|
|
|
|
let provider = KmsSseDekProvider::new_with_service_manager(manager.clone())
|
|
.await
|
|
.expect("kms provider should initialize from the configured test manager");
|
|
super::set_sse_dek_provider_for_test(Arc::new(provider));
|
|
|
|
let client_context = HashMap::from([("tenant".to_string(), "alpha".to_string())]);
|
|
let request = EncryptionRequest {
|
|
bucket: "bucket",
|
|
key: "dir/object",
|
|
server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS)),
|
|
ssekms_key_id: Some("kms-test".to_string()),
|
|
ssekms_context: Some(client_context.clone()),
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
content_size: 4096,
|
|
};
|
|
|
|
let material = sse_encryption(request)
|
|
.await
|
|
.expect("sse-kms encryption")
|
|
.expect("managed sse-kms material");
|
|
let metadata = encryption_material_to_metadata(&material).expect("kms metadata should serialize");
|
|
let encoded_context = metadata
|
|
.get(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER)
|
|
.expect("minio kms context header should exist");
|
|
let decoded_context: HashMap<String, String> =
|
|
serde_json::from_slice(&BASE64_STANDARD.decode(encoded_context).expect("decode base64 context"))
|
|
.expect("decode json context");
|
|
assert_eq!(decoded_context, client_context);
|
|
|
|
let decrypted = sse_decryption(DecryptionRequest {
|
|
bucket: "bucket",
|
|
key: "dir/object",
|
|
metadata: &metadata,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
})
|
|
.await
|
|
.expect("sse-kms decryption should succeed")
|
|
.expect("sse-kms material should exist");
|
|
assert_eq!(decrypted.key_bytes, material.key_bytes);
|
|
|
|
let mut wrong_metadata = metadata.clone();
|
|
wrong_metadata.insert(
|
|
MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER.to_string(),
|
|
BASE64_STANDARD.encode(serde_json::to_vec(&HashMap::from([("tenant".to_string(), "beta".to_string())])).unwrap()),
|
|
);
|
|
let err = sse_decryption(DecryptionRequest {
|
|
bucket: "bucket",
|
|
key: "dir/object",
|
|
metadata: &wrong_metadata,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
})
|
|
.await
|
|
.expect_err("mismatched kms context should fail");
|
|
assert!(
|
|
err.message.contains("context") || err.message.contains("Context"),
|
|
"unexpected error for mismatched kms context: {}",
|
|
err.message
|
|
);
|
|
|
|
manager.stop().await.expect("kms service should stop cleanly");
|
|
reset_sse_dek_provider();
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
#[test]
|
|
fn test_encryption_material_to_metadata_persists_minio_managed_headers() {
|
|
let encoded_nonce = BASE64_STANDARD.encode([9u8; 12]);
|
|
let encoded_key = BASE64_STANDARD.encode([1u8, 2, 3, 4]);
|
|
let metadata = encryption_material_to_metadata(&EncryptionMaterial {
|
|
sse_type: SSEType::SseKms,
|
|
server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS),
|
|
kms_key_id: Some("test-key".to_string()),
|
|
algorithm: SSECustomerAlgorithm::from(ServerSideEncryption::AWS_KMS.to_string()),
|
|
key_bytes: [7u8; 32],
|
|
base_nonce: [9u8; 12],
|
|
encrypted_data_key: Some(vec![1, 2, 3, 4]),
|
|
customer_key_md5: None,
|
|
original_size: Some(1024),
|
|
key_kind: EncryptionKeyKind::Direct,
|
|
managed_kms_context: None,
|
|
managed_sealed_key: None,
|
|
})
|
|
.expect("managed SSE metadata should serialize");
|
|
|
|
assert_eq!(
|
|
metadata.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER).map(String::as_str),
|
|
Some(encoded_nonce.as_str())
|
|
);
|
|
assert_eq!(
|
|
metadata
|
|
.get(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)
|
|
.map(String::as_str),
|
|
Some(encoded_key.as_str())
|
|
);
|
|
assert_eq!(
|
|
metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER).map(String::as_str),
|
|
Some("test-key")
|
|
);
|
|
assert_eq!(
|
|
metadata.get(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER).map(String::as_str),
|
|
Some(MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sse_encryption_omits_kms_header_for_sse_s3_objects() {
|
|
let _guard = lock_sse_test_state().await;
|
|
reset_sse_dek_provider();
|
|
let local_sse_master_key = local_sse_master_key_b64();
|
|
|
|
async_with_vars(
|
|
[
|
|
("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>),
|
|
("RUSTFS_SSE_S3_MASTER_KEY", Some(local_sse_master_key.as_str())),
|
|
],
|
|
async {
|
|
let request = EncryptionRequest {
|
|
bucket: "test-bucket",
|
|
key: "test-key",
|
|
server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)),
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
content_size: 1024,
|
|
};
|
|
|
|
let material = sse_encryption(request).await.expect("sse-s3 encryption should succeed");
|
|
let material = material.expect("managed sse-s3 encryption should return material");
|
|
let metadata = encryption_material_to_metadata(&material).expect("managed SSE-S3 metadata should serialize");
|
|
|
|
assert_eq!(material.kms_key_id.as_deref(), Some("default"));
|
|
assert_eq!(metadata.get("x-amz-server-side-encryption").map(String::as_str), Some("AES256"));
|
|
assert!(!metadata.contains_key("x-amz-server-side-encryption-aws-kms-key-id"));
|
|
assert_eq!(metadata.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER).map(String::as_str), Some("default"));
|
|
},
|
|
)
|
|
.await;
|
|
|
|
reset_sse_dek_provider();
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_managed_encryption_metadata() {
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("x-amz-server-side-encryption".to_string(), "aws:kms".to_string());
|
|
metadata.insert("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string());
|
|
metadata.insert("x-amz-server-side-encryption-customer-key-md5".to_string(), "source-key-md5".to_string());
|
|
metadata.insert(SSEC_ORIGINAL_SIZE_HEADER.to_string(), "123".to_string());
|
|
metadata.insert("x-rustfs-encryption-key".to_string(), "encrypted_key".to_string());
|
|
metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER.to_string(), "sealed".to_string());
|
|
metadata.insert("content-type".to_string(), "text/plain".to_string());
|
|
|
|
strip_managed_encryption_metadata(&mut metadata);
|
|
|
|
assert!(!metadata.contains_key("x-amz-server-side-encryption"));
|
|
assert!(!metadata.contains_key("x-amz-server-side-encryption-customer-algorithm"));
|
|
assert!(!metadata.contains_key("x-amz-server-side-encryption-customer-key-md5"));
|
|
assert!(!metadata.contains_key(SSEC_ORIGINAL_SIZE_HEADER));
|
|
assert!(!metadata.contains_key("x-rustfs-encryption-key"));
|
|
assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER));
|
|
assert!(metadata.contains_key("content-type"));
|
|
}
|
|
|
|
#[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));
|
|
|
|
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));
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
#[test]
|
|
fn test_normalize_managed_metadata_accepts_minio_only_headers() {
|
|
let metadata = HashMap::from([
|
|
(
|
|
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(),
|
|
BASE64_STANDARD.encode(b"encrypted-key"),
|
|
),
|
|
(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([0x11u8; 12])),
|
|
(
|
|
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()),
|
|
]);
|
|
|
|
let normalized = normalize_managed_metadata(&metadata);
|
|
|
|
assert_eq!(
|
|
normalized.get(INTERNAL_ENCRYPTION_KEY_HEADER),
|
|
Some(&BASE64_STANDARD.encode(b"encrypted-key"))
|
|
);
|
|
assert_eq!(normalized.get(INTERNAL_ENCRYPTION_IV_HEADER), Some(&BASE64_STANDARD.encode([0x11u8; 12])));
|
|
assert_eq!(
|
|
normalized.get(INTERNAL_ENCRYPTION_ALGORITHM_HEADER),
|
|
Some(&MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string())
|
|
);
|
|
assert_eq!(normalized.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER), Some(&"default".to_string()));
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
#[tokio::test]
|
|
async fn test_managed_sse_rio_v2_uses_object_key_metadata_roundtrip() {
|
|
let _guard = lock_sse_test_state().await;
|
|
reset_sse_dek_provider();
|
|
if let Some(manager) = rustfs_kms::get_global_kms_service_manager() {
|
|
let _ = manager.stop().await;
|
|
}
|
|
let local_sse_master_key = local_sse_master_key_b64();
|
|
|
|
async_with_vars(
|
|
[
|
|
("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>),
|
|
("RUSTFS_SSE_S3_MASTER_KEY", Some(local_sse_master_key.as_str())),
|
|
],
|
|
async {
|
|
let request = EncryptionRequest {
|
|
bucket: "bucket",
|
|
key: "object",
|
|
server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)),
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
content_size: 4096,
|
|
};
|
|
|
|
let material = sse_encryption(request)
|
|
.await
|
|
.expect("managed sse encryption")
|
|
.expect("managed sse material");
|
|
assert_eq!(material.key_kind, EncryptionKeyKind::Object);
|
|
|
|
let metadata = encryption_material_to_metadata(&material).expect("managed SSE metadata should serialize");
|
|
assert!(!metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER));
|
|
assert!(!metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER));
|
|
|
|
let sealing_iv = metadata
|
|
.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER)
|
|
.expect("minio sealing iv should be stored");
|
|
let sealed_key = metadata
|
|
.get(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)
|
|
.expect("minio sealed key should be stored");
|
|
assert_eq!(BASE64_STANDARD.decode(sealing_iv).expect("decode iv").len(), SEALED_KEY_IV_SIZE);
|
|
assert_eq!(BASE64_STANDARD.decode(sealed_key).expect("decode sealed key").len(), SEALED_KEY_SIZE);
|
|
|
|
let decrypted = sse_decryption(DecryptionRequest {
|
|
bucket: "bucket",
|
|
key: "object",
|
|
metadata: &metadata,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
})
|
|
.await
|
|
.expect("managed sse decryption")
|
|
.expect("managed decryption material");
|
|
|
|
assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object);
|
|
assert_eq!(decrypted.key_bytes, material.key_bytes);
|
|
},
|
|
)
|
|
.await;
|
|
|
|
reset_sse_dek_provider();
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
#[test]
|
|
fn test_supported_sealed_object_key_cipher_accepts_current_minio_fixture_value() {
|
|
assert!(is_supported_sealed_object_key_cipher(DARE_CIPHER_AES_256_GCM));
|
|
assert!(is_supported_sealed_object_key_cipher(DARE_CIPHER_CHACHA20_POLY1305));
|
|
assert!(!is_supported_sealed_object_key_cipher(0x02));
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
#[tokio::test]
|
|
async fn test_ssec_rio_v2_uses_sealed_object_key_metadata_roundtrip() {
|
|
let customer_key_bytes = [0x42u8; 32];
|
|
let customer_key = BASE64_STANDARD.encode(customer_key_bytes);
|
|
let customer_key_md5 = BASE64_STANDARD.encode(md5::compute(customer_key_bytes).0);
|
|
|
|
let material = sse_encryption(EncryptionRequest {
|
|
bucket: "bucket",
|
|
key: "object",
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: Some("AES256".to_string()),
|
|
sse_customer_key: Some(customer_key.clone()),
|
|
sse_customer_key_md5: Some(customer_key_md5.clone()),
|
|
content_size: 4096,
|
|
})
|
|
.await
|
|
.expect("sse-c encryption")
|
|
.expect("sse-c material");
|
|
|
|
assert_eq!(material.key_kind, EncryptionKeyKind::Object);
|
|
|
|
let metadata = encryption_material_to_metadata(&material).expect("sse-c metadata should serialize");
|
|
assert_eq!(
|
|
metadata.get(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER).map(String::as_str),
|
|
Some(MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM)
|
|
);
|
|
assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_IV_HEADER));
|
|
assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER));
|
|
|
|
let decrypted = sse_decryption(DecryptionRequest {
|
|
bucket: "bucket",
|
|
key: "object",
|
|
metadata: &metadata,
|
|
sse_customer_key: Some(&customer_key),
|
|
sse_customer_key_md5: Some(&customer_key_md5),
|
|
})
|
|
.await
|
|
.expect("sse-c decryption")
|
|
.expect("sse-c decryption material");
|
|
|
|
assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object);
|
|
assert_eq!(decrypted.key_bytes, material.key_bytes);
|
|
}
|
|
|
|
#[cfg(feature = "rio-v2")]
|
|
#[test]
|
|
fn test_mark_encrypted_multipart_metadata_sets_minio_marker() {
|
|
let mut metadata = HashMap::new();
|
|
mark_encrypted_multipart_metadata(&mut metadata);
|
|
assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER));
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_ssec_key_match_success() {
|
|
let md5 = "test_md5".to_string();
|
|
let result = verify_ssec_key_match("test_md5", Some(&md5));
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_ssec_key_match_mismatch() {
|
|
let md5 = "stored_md5".to_string();
|
|
let result = verify_ssec_key_match("provided_md5", Some(&md5));
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_ssec_key_match_no_stored() {
|
|
let result = verify_ssec_key_match("provided_md5", None);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
fn multipart_ssec_request(key_byte: u8) -> EncryptionRequest<'static> {
|
|
let key_bytes = [key_byte; 32];
|
|
EncryptionRequest {
|
|
bucket: "bucket",
|
|
key: "object",
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: Some("AES256".to_string()),
|
|
sse_customer_key: Some(BASE64_STANDARD.encode(key_bytes)),
|
|
sse_customer_key_md5: Some(BASE64_STANDARD.encode(md5::compute(key_bytes).0)),
|
|
content_size: 1,
|
|
}
|
|
}
|
|
|
|
fn multipart_ssec_metadata(key_byte: u8) -> HashMap<String, String> {
|
|
let key_bytes = [key_byte; 32];
|
|
HashMap::from([
|
|
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
|
|
(
|
|
"x-amz-server-side-encryption-customer-key-md5".to_string(),
|
|
BASE64_STANDARD.encode(md5::compute(key_bytes).0),
|
|
),
|
|
])
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_multipart_ssec_exact_match() {
|
|
assert!(
|
|
multipart_ssec_request(42)
|
|
.validate_multipart_ssec(&multipart_ssec_metadata(42))
|
|
.is_ok()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_multipart_ssec_rejects_wrong_key_without_leaking_it() {
|
|
let request = multipart_ssec_request(43);
|
|
let encoded_key = request.sse_customer_key.clone().expect("fixture key");
|
|
let encoded_md5 = request.sse_customer_key_md5.clone().expect("fixture MD5");
|
|
|
|
let error = request
|
|
.validate_multipart_ssec(&multipart_ssec_metadata(42))
|
|
.expect_err("wrong key must fail");
|
|
|
|
assert_eq!(error.code, S3ErrorCode::InvalidRequest);
|
|
assert!(!error.message.contains(&encoded_key));
|
|
assert!(!error.message.contains(&encoded_md5));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_multipart_ssec_rejects_missing_or_unexpected_parameters() {
|
|
let no_ssec = EncryptionRequest {
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
..multipart_ssec_request(42)
|
|
};
|
|
assert_eq!(
|
|
no_ssec
|
|
.validate_multipart_ssec(&multipart_ssec_metadata(42))
|
|
.expect_err("SSE-C session requires all parameters")
|
|
.code,
|
|
S3ErrorCode::InvalidRequest
|
|
);
|
|
assert_eq!(
|
|
multipart_ssec_request(42)
|
|
.validate_multipart_ssec(&HashMap::new())
|
|
.expect_err("plaintext session rejects SSE-C parameters")
|
|
.code,
|
|
S3ErrorCode::InvalidRequest
|
|
);
|
|
let incomplete_session = HashMap::from([(
|
|
"x-amz-server-side-encryption-customer-key-md5".to_string(),
|
|
multipart_ssec_request(42).sse_customer_key_md5.expect("fixture MD5"),
|
|
)]);
|
|
assert_eq!(
|
|
multipart_ssec_request(42)
|
|
.validate_multipart_ssec(&incomplete_session)
|
|
.expect_err("incomplete stored SSE-C metadata must fail closed")
|
|
.code,
|
|
S3ErrorCode::InvalidRequest
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration Tests - Encrypt/Decrypt with SimpleSseDekProvider
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_simple_sse_dek_provider_encrypt_decrypt() {
|
|
use std::io::Cursor;
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
// 1. Setup: Create SimpleSseDekProvider with test master key
|
|
let provider = TestSseDekProvider::new_with_key([42u8; 32]);
|
|
|
|
// 2. Generate a data encryption key
|
|
let bucket = "test-bucket";
|
|
let key = "test-key";
|
|
let kms_key_id = "default"; // Key ID is ignored in simple provider
|
|
let context = ObjectEncryptionContext::new(bucket.to_string(), key.to_string());
|
|
|
|
let (data_key, _encrypted_dek) = provider
|
|
.generate_sse_dek(&context, kms_key_id)
|
|
.await
|
|
.expect("Failed to generate DEK");
|
|
|
|
// 3. Prepare test data (plaintext)
|
|
let plaintext = b"Hello, World! This is a test message for encryption and decryption.";
|
|
println!("Original plaintext: {:?}", String::from_utf8_lossy(plaintext));
|
|
println!("Plaintext length: {} bytes", plaintext.len());
|
|
|
|
// 4. Encrypt with EncryptReader.
|
|
let plaintext_reader = Cursor::new(plaintext.to_vec());
|
|
let mut encrypt_reader = EncryptReader::new(plaintext_reader, data_key.plaintext_key, data_key.nonce);
|
|
|
|
// Read encrypted data
|
|
let mut encrypted_data = Vec::new();
|
|
encrypt_reader
|
|
.read_to_end(&mut encrypted_data)
|
|
.await
|
|
.expect("Failed to read encrypted data");
|
|
|
|
println!("Encrypted data length: {} bytes", encrypted_data.len());
|
|
println!(
|
|
"First 16 bytes of encrypted data: {:02x?}",
|
|
&encrypted_data[..16.min(encrypted_data.len())]
|
|
);
|
|
|
|
// Verify encrypted data is different from plaintext
|
|
assert_ne!(
|
|
&encrypted_data[..plaintext.len()],
|
|
plaintext,
|
|
"Encrypted data should be different from plaintext"
|
|
);
|
|
|
|
// 5. Decrypt with DecryptReader.
|
|
let encrypted_reader = Cursor::new(encrypted_data);
|
|
let mut decrypt_reader = DecryptReader::new(encrypted_reader, data_key.plaintext_key, data_key.nonce);
|
|
|
|
// Read decrypted data
|
|
let mut decrypted_data = Vec::new();
|
|
decrypt_reader
|
|
.read_to_end(&mut decrypted_data)
|
|
.await
|
|
.expect("Failed to read decrypted data");
|
|
|
|
println!("Decrypted data: {:?}", String::from_utf8_lossy(&decrypted_data));
|
|
println!("Decrypted length: {} bytes", decrypted_data.len());
|
|
|
|
// 6. Verify decrypted data matches original plaintext
|
|
assert_eq!(decrypted_data, plaintext, "Decrypted data should match original plaintext");
|
|
|
|
println!("✅ Encryption/Decryption test passed!");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_simple_sse_dek_provider_encrypt_decrypt_large_data() {
|
|
use std::io::Cursor;
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
// 1. Setup: Create SimpleSseDekProvider with test master key
|
|
let provider = TestSseDekProvider::new_with_key([42u8; 32]);
|
|
|
|
let bucket = "test-bucket";
|
|
let key = "test-key-large";
|
|
let kms_key_id = "default";
|
|
let context = ObjectEncryptionContext::new(bucket.to_string(), key.to_string());
|
|
|
|
let (data_key, _encrypted_dek) = provider
|
|
.generate_sse_dek(&context, kms_key_id)
|
|
.await
|
|
.expect("Failed to generate DEK");
|
|
|
|
// Create 1MB of test data
|
|
let plaintext_size = 1024 * 1024; // 1MB
|
|
let plaintext: Vec<u8> = (0..plaintext_size).map(|i| (i % 256) as u8).collect();
|
|
println!("Testing with {} bytes of data", plaintext.len());
|
|
|
|
// Encrypt.
|
|
let plaintext_reader = Cursor::new(plaintext.clone());
|
|
let mut encrypt_reader = EncryptReader::new(plaintext_reader, data_key.plaintext_key, data_key.nonce);
|
|
|
|
let mut encrypted_data = Vec::new();
|
|
encrypt_reader
|
|
.read_to_end(&mut encrypted_data)
|
|
.await
|
|
.expect("Failed to encrypt large data");
|
|
|
|
println!("Encrypted {} bytes to {} bytes", plaintext.len(), encrypted_data.len());
|
|
|
|
// Decrypt.
|
|
let encrypted_reader = Cursor::new(encrypted_data);
|
|
let mut decrypt_reader = DecryptReader::new(encrypted_reader, data_key.plaintext_key, data_key.nonce);
|
|
|
|
let mut decrypted_data = Vec::new();
|
|
decrypt_reader
|
|
.read_to_end(&mut decrypted_data)
|
|
.await
|
|
.expect("Failed to decrypt large data");
|
|
|
|
// Verify
|
|
assert_eq!(decrypted_data.len(), plaintext.len(), "Decrypted size should match original");
|
|
assert_eq!(decrypted_data, plaintext, "Decrypted data should match original plaintext");
|
|
|
|
println!("✅ Large data encryption/decryption test passed!");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_simple_sse_dek_provider_different_nonces() {
|
|
use std::io::Cursor;
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
// 1. Setup: Create SimpleSseDekProvider with test master key
|
|
let provider = TestSseDekProvider::new_with_key([42u8; 32]);
|
|
|
|
let bucket = "test-bucket";
|
|
let key = "test-key";
|
|
let kms_key_id = "default";
|
|
let context = ObjectEncryptionContext::new(bucket.to_string(), key.to_string());
|
|
|
|
// Generate two different keys (with different nonces)
|
|
let (data_key1, _) = provider
|
|
.generate_sse_dek(&context, kms_key_id)
|
|
.await
|
|
.expect("Failed to generate DEK 1");
|
|
|
|
let (data_key2, _) = provider
|
|
.generate_sse_dek(&context, kms_key_id)
|
|
.await
|
|
.expect("Failed to generate DEK 2");
|
|
|
|
// Verify nonces are different
|
|
assert_ne!(data_key1.nonce, data_key2.nonce, "Different keys should have different nonces");
|
|
|
|
// Same plaintext
|
|
let plaintext = b"Same plaintext";
|
|
|
|
// Encrypt with first key.
|
|
let reader1 = Cursor::new(plaintext.to_vec());
|
|
let mut encrypt_reader1 = EncryptReader::new(reader1, data_key1.plaintext_key, data_key1.nonce);
|
|
let mut encrypted1 = Vec::new();
|
|
encrypt_reader1.read_to_end(&mut encrypted1).await.unwrap();
|
|
|
|
// Encrypt with second key.
|
|
let reader2 = Cursor::new(plaintext.to_vec());
|
|
let mut encrypt_reader2 = EncryptReader::new(reader2, data_key2.plaintext_key, data_key2.nonce);
|
|
let mut encrypted2 = Vec::new();
|
|
encrypt_reader2.read_to_end(&mut encrypted2).await.unwrap();
|
|
|
|
// Verify ciphertexts are different (due to different nonces/keys)
|
|
assert_ne!(
|
|
encrypted1, encrypted2,
|
|
"Same plaintext with different nonces should produce different ciphertext"
|
|
);
|
|
|
|
println!("✅ Different nonces produce different ciphertext - test passed!");
|
|
}
|
|
|
|
#[test]
|
|
fn test_encrypt_dek_writes_json_with_random_nonces() {
|
|
let dek = [0x11u8; 32];
|
|
let cmk = [0x22u8; 32];
|
|
|
|
let encrypted_a = TestSseDekProvider::encrypt_dek(dek, cmk).expect("first DEK wrap should succeed");
|
|
let encrypted_b = TestSseDekProvider::encrypt_dek(dek, cmk).expect("second DEK wrap should succeed");
|
|
|
|
let envelope_a: super::LocalSseDekEnvelope =
|
|
serde_json::from_str(&encrypted_a).expect("first wrapped DEK should be a JSON envelope");
|
|
let envelope_b: super::LocalSseDekEnvelope =
|
|
serde_json::from_str(&encrypted_b).expect("second wrapped DEK should be a JSON envelope");
|
|
|
|
assert_eq!(envelope_a.version, super::LOCAL_SSE_DEK_FORMAT_VERSION);
|
|
assert_eq!(envelope_b.version, super::LOCAL_SSE_DEK_FORMAT_VERSION);
|
|
assert_ne!(envelope_a.nonce, envelope_b.nonce, "each DEK wrap should use a distinct nonce");
|
|
assert_eq!(
|
|
TestSseDekProvider::decrypt_dek(&encrypted_a, cmk).expect("first JSON envelope should decrypt"),
|
|
dek
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decrypt_dek_accepts_legacy_zero_nonce_payload() {
|
|
let dek = [0x33u8; 32];
|
|
let cmk = [0x44u8; 32];
|
|
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from(cmk));
|
|
let legacy_nonce = Nonce::from([0u8; 12]);
|
|
let ciphertext = cipher
|
|
.encrypt(&legacy_nonce, dek.as_slice())
|
|
.expect("legacy wrap should succeed");
|
|
let legacy_payload = format!("{}:{}", BASE64_STANDARD.encode(legacy_nonce), BASE64_STANDARD.encode(ciphertext));
|
|
|
|
let decrypted = TestSseDekProvider::decrypt_dek(&legacy_payload, cmk).expect("legacy payload should remain decryptable");
|
|
assert_eq!(decrypted, dek);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decrypt_dek_rejects_unknown_json_version() {
|
|
let envelope = serde_json::json!({
|
|
"version": super::LOCAL_SSE_DEK_FORMAT_VERSION + 1,
|
|
"nonce": BASE64_STANDARD.encode([0u8; 12]),
|
|
"ciphertext": BASE64_STANDARD.encode([0u8; 48]),
|
|
})
|
|
.to_string();
|
|
|
|
let error = TestSseDekProvider::decrypt_dek(&envelope, [0x55u8; 32])
|
|
.expect_err("unknown JSON envelope versions must fail closed");
|
|
assert!(error.message.contains("Unsupported encrypted DEK format version"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sse_encryption_fails_closed_without_local_sse_master_key() {
|
|
let _guard = lock_sse_test_state().await;
|
|
reset_sse_dek_provider();
|
|
async_with_vars(
|
|
[
|
|
("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>),
|
|
("RUSTFS_SSE_S3_MASTER_KEY", None::<&str>),
|
|
],
|
|
async {
|
|
let err = sse_encryption(EncryptionRequest {
|
|
bucket: "test-bucket",
|
|
key: "test-key",
|
|
server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)),
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
content_size: 1024,
|
|
})
|
|
.await
|
|
.expect_err("SSE-S3 should fail closed without a configured local master key");
|
|
|
|
assert!(err.message.contains("RUSTFS_SSE_S3_MASTER_KEY"));
|
|
assert_eq!(
|
|
err.code,
|
|
S3ErrorCode::InvalidRequest,
|
|
"missing local SSE master key is a configuration error, not a 500 (rustfs#4844)"
|
|
);
|
|
},
|
|
)
|
|
.await;
|
|
reset_sse_dek_provider();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sse_encryption_fails_closed_with_invalid_local_sse_master_key() {
|
|
let _guard = lock_sse_test_state().await;
|
|
reset_sse_dek_provider();
|
|
async_with_vars(
|
|
[
|
|
("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>),
|
|
("RUSTFS_SSE_S3_MASTER_KEY", Some("not-base64")),
|
|
],
|
|
async {
|
|
let err = sse_encryption(EncryptionRequest {
|
|
bucket: "test-bucket",
|
|
key: "test-key",
|
|
server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)),
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
content_size: 1024,
|
|
})
|
|
.await
|
|
.expect_err("SSE-S3 should fail closed with an invalid local master key");
|
|
|
|
assert!(err.message.contains("valid base64"));
|
|
assert_eq!(
|
|
err.code,
|
|
S3ErrorCode::InvalidRequest,
|
|
"invalid local SSE master key is a configuration error, not a 500 (rustfs#4844)"
|
|
);
|
|
},
|
|
)
|
|
.await;
|
|
reset_sse_dek_provider();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_simple_sse_dek_provider_decrypt_with_encrypted_dek() {
|
|
use std::io::Cursor;
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
// 1. Setup: Create SimpleSseDekProvider with test master key
|
|
let provider = TestSseDekProvider::new_with_key([42u8; 32]);
|
|
|
|
let bucket = "test-bucket";
|
|
let key = "test-key";
|
|
let kms_key_id = "default";
|
|
let context = ObjectEncryptionContext::new(bucket.to_string(), key.to_string());
|
|
|
|
// 1. Generate DEK and get encrypted DEK
|
|
let (data_key, encrypted_dek) = provider
|
|
.generate_sse_dek(&context, kms_key_id)
|
|
.await
|
|
.expect("Failed to generate DEK");
|
|
|
|
let original_plaintext_key = data_key.plaintext_key;
|
|
let original_nonce = data_key.nonce;
|
|
|
|
// 2. Simulate storing encrypted_dek and nonce in metadata
|
|
// In real scenario, nonce would be stored separately in metadata
|
|
|
|
// 3. Later, decrypt the DEK
|
|
let decrypted_plaintext_key = provider
|
|
.decrypt_sse_dek(&encrypted_dek, kms_key_id, &context)
|
|
.await
|
|
.expect("Failed to decrypt DEK");
|
|
|
|
// 4. Verify decrypted key matches original
|
|
assert_eq!(
|
|
decrypted_plaintext_key, original_plaintext_key,
|
|
"Decrypted DEK should match original plaintext key"
|
|
);
|
|
|
|
// 5. Use decrypted key to encrypt/decrypt data
|
|
let plaintext = b"Test data with decrypted DEK";
|
|
|
|
// Encrypt with original key.
|
|
let reader = Cursor::new(plaintext.to_vec());
|
|
let mut encrypt_reader = EncryptReader::new(reader, original_plaintext_key, original_nonce);
|
|
let mut encrypted_data = Vec::new();
|
|
encrypt_reader.read_to_end(&mut encrypted_data).await.unwrap();
|
|
|
|
// Decrypt with recovered key (simulating GET operation).
|
|
let reader = Cursor::new(encrypted_data);
|
|
let mut decrypt_reader = DecryptReader::new(
|
|
reader,
|
|
decrypted_plaintext_key,
|
|
original_nonce, // In real scenario, read from metadata
|
|
);
|
|
let mut decrypted_data = Vec::new();
|
|
decrypt_reader.read_to_end(&mut decrypted_data).await.unwrap();
|
|
|
|
// Verify
|
|
assert_eq!(decrypted_data, plaintext, "Data decrypted with recovered key should match original");
|
|
|
|
println!("✅ Full cycle (generate -> encrypt DEK -> decrypt DEK -> decrypt data) test passed!");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_managed_decryption_selects_provider_from_persisted_dek() {
|
|
use rustfs_kms::config::KmsConfig;
|
|
use tempfile::TempDir;
|
|
|
|
let _guard = lock_sse_test_state().await;
|
|
reset_sse_dek_provider();
|
|
let manager = rustfs_kms::init_global_kms_service_manager();
|
|
let key_dir = TempDir::new().expect("create KMS key directory");
|
|
manager
|
|
.reconfigure(KmsConfig::local(key_dir.path().to_path_buf()).with_insecure_development_defaults())
|
|
.await
|
|
.expect("start test KMS service");
|
|
|
|
let local_master_key = [7u8; 32];
|
|
let local_provider = TestSseDekProvider::new_with_key(local_master_key);
|
|
let context = ObjectEncryptionContext::new("bucket".to_string(), "object".to_string());
|
|
let (data_key, encrypted_dek) = local_provider
|
|
.generate_sse_dek(&context, "legacy-local-key")
|
|
.await
|
|
.expect("generate beta.5 local DEK");
|
|
|
|
async_with_vars(
|
|
[
|
|
("__RUSTFS_SSE_SIMPLE_CMK", None::<String>),
|
|
("RUSTFS_SSE_S3_MASTER_KEY", Some(BASE64_STANDARD.encode(local_master_key))),
|
|
],
|
|
async {
|
|
let metadata = HashMap::from([
|
|
("x-amz-server-side-encryption".to_string(), ServerSideEncryption::AWS_KMS.to_string()),
|
|
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), BASE64_STANDARD.encode(encrypted_dek)),
|
|
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode(data_key.nonce)),
|
|
(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), "legacy-local-key".to_string()),
|
|
]);
|
|
|
|
let material = apply_managed_decryption_material("bucket", "object", &metadata)
|
|
.await
|
|
.expect("legacy local DEK should not be routed to the running KMS")
|
|
.expect("managed metadata should produce decryption material");
|
|
assert_eq!(material.key_bytes, data_key.plaintext_key);
|
|
assert_eq!(material.sse_type, SSEType::SseKms);
|
|
},
|
|
)
|
|
.await;
|
|
|
|
manager.stop().await.expect("stop test KMS service");
|
|
reset_sse_dek_provider();
|
|
|
|
let kms_envelope = br#"{
|
|
"key_id": "test-key-id",
|
|
"master_key_id": "master-key-id",
|
|
"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"
|
|
}"#;
|
|
let metadata = HashMap::from([
|
|
("x-amz-server-side-encryption".to_string(), ServerSideEncryption::AES256.to_string()),
|
|
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), BASE64_STANDARD.encode(kms_envelope)),
|
|
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([0x14; 12])),
|
|
(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), "test-key-id".to_string()),
|
|
]);
|
|
let error = match apply_managed_decryption_material("bucket", "object", &metadata).await {
|
|
Ok(_) => panic!("KMS envelope must not fall back to the local provider"),
|
|
Err(error) => error,
|
|
};
|
|
assert_eq!(error.code, S3ErrorCode::ServiceUnavailable);
|
|
|
|
reset_sse_dek_provider();
|
|
}
|
|
|
|
/// Regression test for "local provider cached → dynamically enable KMS → decrypt KMS envelope".
|
|
///
|
|
/// Verifies that a `TestSseDekProvider` previously cached in `GLOBAL_SSE_DEK_PROVIDER`
|
|
/// is NEVER selected to unwrap a KMS data-key envelope. The KMS-envelope branch must
|
|
/// read `GLOBAL_KMS_DEK_PROVIDER` (or fall back to `KmsSseDekProvider::new()`), not
|
|
/// the local-provider cache.
|
|
#[tokio::test]
|
|
async fn test_kms_envelope_never_routes_to_cached_local_provider() {
|
|
use rustfs_kms::config::KmsConfig;
|
|
use tempfile::TempDir;
|
|
|
|
let _guard = lock_sse_test_state().await;
|
|
reset_sse_dek_provider();
|
|
|
|
// 1. Populate GLOBAL_SSE_DEK_PROVIDER with a local provider — the kind
|
|
// that `get_local_sse_dek_provider` would cache when KMS is absent.
|
|
let local_master_key = [0xAAu8; 32];
|
|
*super::GLOBAL_SSE_DEK_PROVIDER
|
|
.write()
|
|
.expect("write local provider into local cache") = Some(Arc::new(TestSseDekProvider::new_with_key(local_master_key)));
|
|
|
|
// 2. Start a KMS service (dynamic enable).
|
|
let manager = rustfs_kms::init_global_kms_service_manager();
|
|
let key_dir = TempDir::new().expect("create KMS key directory");
|
|
manager
|
|
.reconfigure(KmsConfig::local(key_dir.path().to_path_buf()).with_insecure_development_defaults())
|
|
.await
|
|
.expect("start test KMS service");
|
|
|
|
// 3. Construct a KMS JSON envelope — the persisted format of a KMS-wrapped DEK.
|
|
// is_data_key_envelope() will return true for this payload.
|
|
let kms_envelope = br#"{
|
|
"key_id": "envelope-key",
|
|
"master_key_id": "master-key-id",
|
|
"key_spec": "AES_256",
|
|
"encrypted_key": [10, 20, 30, 40],
|
|
"nonce": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
|
"encryption_context": {},
|
|
"created_at": "2024-01-01T00:00:00+00:00"
|
|
}"#;
|
|
let metadata = HashMap::from([
|
|
("x-amz-server-side-encryption".to_string(), ServerSideEncryption::AES256.to_string()),
|
|
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), BASE64_STANDARD.encode(kms_envelope)),
|
|
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([0x14; 12])),
|
|
(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), "envelope-key".to_string()),
|
|
]);
|
|
|
|
// 4. Decrypt the KMS envelope. Before the fix, this would pick up the cached
|
|
// TestSseDekProvider from GLOBAL_SSE_DEK_PROVIDER and either panic (wrong
|
|
// format) or produce garbage. After the fix, it routes to KmsSseDekProvider,
|
|
// which fails because the envelope contains dummy encrypted bytes that the
|
|
// test KMS cannot decrypt — but crucially the error code is NOT a local-
|
|
// provider error.
|
|
let error = match apply_managed_decryption_material("bucket", "object", &metadata).await {
|
|
Ok(_) => panic!("dummy KMS envelope must not produce valid decryption material"),
|
|
Err(error) => error,
|
|
};
|
|
// The KMS service was reached (ServiceUnavailable or the KMS's own decrypt
|
|
// failure), not a local-provider format error.
|
|
assert!(
|
|
error.code == S3ErrorCode::ServiceUnavailable || error.code == S3ErrorCode::InternalError,
|
|
"KMS envelope must be routed to KMS provider, not local; got code {:?} msg '{}'",
|
|
error.code,
|
|
error.message,
|
|
);
|
|
|
|
manager.stop().await.expect("stop test KMS service");
|
|
reset_sse_dek_provider();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_managed_encryption_preserves_provider_unavailable_error() {
|
|
let _guard = lock_sse_test_state().await;
|
|
let manager = rustfs_kms::init_global_kms_service_manager();
|
|
manager.stop().await.expect("stop global KMS service");
|
|
reset_sse_dek_provider();
|
|
*super::GLOBAL_SSE_DEK_PROVIDER.write().expect("update SSE DEK provider cache") =
|
|
Some(Arc::new(UnavailableSseDekProvider));
|
|
|
|
let error = match apply_managed_encryption_material(
|
|
"bucket",
|
|
"object",
|
|
ServerSideEncryption::from_static(ServerSideEncryption::AES256),
|
|
None,
|
|
None,
|
|
0,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => panic!("provider unavailability must fail managed encryption"),
|
|
Err(error) => error,
|
|
};
|
|
assert_eq!(error.code, S3ErrorCode::ServiceUnavailable);
|
|
|
|
let metadata = HashMap::from([
|
|
("x-amz-server-side-encryption".to_string(), ServerSideEncryption::AES256.to_string()),
|
|
(
|
|
INTERNAL_ENCRYPTION_KEY_HEADER.to_string(),
|
|
BASE64_STANDARD.encode(b"local-provider-format"),
|
|
),
|
|
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([0x14; 12])),
|
|
(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), "test-key-id".to_string()),
|
|
]);
|
|
let error = match apply_managed_decryption_material("bucket", "object", &metadata).await {
|
|
Ok(_) => panic!("provider unavailability must fail managed decryption"),
|
|
Err(error) => error,
|
|
};
|
|
assert_eq!(error.code, S3ErrorCode::ServiceUnavailable);
|
|
|
|
reset_sse_dek_provider();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_kms_sse_dek_provider_uses_latest_reconfigured_service() {
|
|
use rustfs_kms::config::KmsConfig;
|
|
use rustfs_kms::types::{CreateKeyRequest, KeyUsage};
|
|
use tempfile::TempDir;
|
|
let _guard = lock_sse_test_state().await;
|
|
|
|
let manager = rustfs_kms::init_global_kms_service_manager();
|
|
|
|
let first_dir = TempDir::new().expect("first temp dir");
|
|
manager
|
|
.reconfigure(KmsConfig::local(first_dir.path().to_path_buf()).with_insecure_development_defaults())
|
|
.await
|
|
.expect("first KMS reconfigure should succeed");
|
|
manager
|
|
.get_encryption_service()
|
|
.await
|
|
.expect("first encryption service should exist")
|
|
.create_key(CreateKeyRequest {
|
|
key_name: Some("first-key".to_string()),
|
|
key_usage: KeyUsage::EncryptDecrypt,
|
|
description: None,
|
|
policy: None,
|
|
tags: HashMap::new(),
|
|
origin: None,
|
|
})
|
|
.await
|
|
.expect("first key should be created");
|
|
|
|
let provider = KmsSseDekProvider::new_with_service_manager(manager.clone())
|
|
.await
|
|
.expect("provider should initialize");
|
|
let context = ObjectEncryptionContext::new("bucket".to_string(), "object".to_string());
|
|
provider
|
|
.generate_sse_dek(&context, "first-key")
|
|
.await
|
|
.expect("provider should use the initial service");
|
|
|
|
let second_dir = TempDir::new().expect("second temp dir");
|
|
manager
|
|
.reconfigure(KmsConfig::local(second_dir.path().to_path_buf()).with_insecure_development_defaults())
|
|
.await
|
|
.expect("second KMS reconfigure should succeed");
|
|
manager
|
|
.get_encryption_service()
|
|
.await
|
|
.expect("second encryption service should exist")
|
|
.create_key(CreateKeyRequest {
|
|
key_name: Some("second-key".to_string()),
|
|
key_usage: KeyUsage::EncryptDecrypt,
|
|
description: None,
|
|
policy: None,
|
|
tags: HashMap::new(),
|
|
origin: None,
|
|
})
|
|
.await
|
|
.expect("second key should be created");
|
|
|
|
provider
|
|
.generate_sse_dek(&context, "second-key")
|
|
.await
|
|
.expect("provider should resolve the latest reconfigured service");
|
|
|
|
manager.stop().await.expect("kms service should stop cleanly");
|
|
let generate_error = provider
|
|
.generate_sse_dek(&context, "second-key")
|
|
.await
|
|
.expect_err("stopped KMS must reject data-key generation");
|
|
assert_eq!(generate_error.code, S3ErrorCode::ServiceUnavailable);
|
|
let decrypt_error = provider
|
|
.decrypt_sse_dek(b"{}", "second-key", &context)
|
|
.await
|
|
.expect_err("stopped KMS must reject data-key decryption");
|
|
assert_eq!(decrypt_error.code, S3ErrorCode::ServiceUnavailable);
|
|
#[cfg(feature = "rio-v2")]
|
|
{
|
|
let legacy_error = provider
|
|
.decrypt_legacy_sse_dek(b"{}", "second-key", &context)
|
|
.await
|
|
.expect_err("stopped KMS must reject legacy data-key decryption");
|
|
assert_eq!(legacy_error.code, S3ErrorCode::ServiceUnavailable);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_encryption_type_enum() {
|
|
// Test EncryptionType enum
|
|
assert_eq!(SSEType::SseS3, SSEType::SseS3);
|
|
assert_eq!(SSEType::SseKms, SSEType::SseKms);
|
|
assert_eq!(SSEType::SseC, SSEType::SseC);
|
|
assert_ne!(SSEType::SseS3, SSEType::SseKms);
|
|
|
|
// Test Debug format
|
|
let debug_str = format!("{:?}", SSEType::SseKms);
|
|
assert!(debug_str.contains("SseKms"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_ssec_key_match_returns_invalid_request() {
|
|
let stored = "stored_md5".to_string();
|
|
let err = verify_ssec_key_match("wrong_md5", Some(&stored)).unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_ssec_key_match_no_stored_returns_invalid_request() {
|
|
let err = verify_ssec_key_match("any_md5", None).unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_ssec_for_read_non_encrypted_object() {
|
|
let metadata = HashMap::new();
|
|
let result = validate_ssec_for_read(&metadata, None, None);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_ssec_for_read_missing_customer_key() {
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string());
|
|
metadata.insert(
|
|
"x-amz-server-side-encryption-customer-key-md5".to_string(),
|
|
"DWygnHRtgiJ77HCm+1rvHw==".to_string(),
|
|
);
|
|
|
|
let err = validate_ssec_for_read(&metadata, None, None).unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_ssec_for_read_wrong_key() {
|
|
// Key A is used to "encrypt" the object (stored MD5 is from key A).
|
|
let key_a = [42u8; 32];
|
|
let stored_md5 = BASE64_STANDARD.encode(md5::compute(key_a).0);
|
|
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string());
|
|
metadata.insert("x-amz-server-side-encryption-customer-key-md5".to_string(), stored_md5);
|
|
|
|
// Key B is a different key; its MD5 won't match stored MD5.
|
|
let key_b = [99u8; 32];
|
|
let key_b_b64 = BASE64_STANDARD.encode(key_b);
|
|
let key_b_md5 = BASE64_STANDARD.encode(md5::compute(key_b).0);
|
|
|
|
let err = validate_ssec_for_read(&metadata, Some(&key_b_b64), Some(&key_b_md5)).unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_ssec_for_read_correct_key() {
|
|
let key_bytes = [42u8; 32];
|
|
let key_b64 = BASE64_STANDARD.encode(key_bytes);
|
|
let key_md5 = BASE64_STANDARD.encode(md5::compute(key_bytes).0);
|
|
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string());
|
|
metadata.insert("x-amz-server-side-encryption-customer-key-md5".to_string(), key_md5.clone());
|
|
|
|
let result = validate_ssec_for_read(&metadata, Some(&key_b64), Some(&key_md5));
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_ssec_for_read_spoofed_md5() {
|
|
// A client provides the correct stored MD5 in the header but with a
|
|
// DIFFERENT key. The server must recompute MD5 from the key bytes and
|
|
// reject the request because the recomputed MD5 won't match the header.
|
|
let real_key = [42u8; 32];
|
|
let stored_md5 = BASE64_STANDARD.encode(md5::compute(real_key).0);
|
|
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string());
|
|
metadata.insert("x-amz-server-side-encryption-customer-key-md5".to_string(), stored_md5.clone());
|
|
|
|
// Attacker has a different key but tries to pass the stored MD5 as their header
|
|
let fake_key = [99u8; 32];
|
|
let fake_key_b64 = BASE64_STANDARD.encode(fake_key);
|
|
|
|
let err = validate_ssec_for_read(&metadata, Some(&fake_key_b64), Some(&stored_md5)).unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_sse_headers_for_read_rejects_kms_on_plain_object() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-amz-server-side-encryption", http::HeaderValue::from_static("aws:kms"));
|
|
headers.insert("x-amz-server-side-encryption-aws-kms-key-id", http::HeaderValue::from_static("test-key"));
|
|
|
|
let metadata = HashMap::new();
|
|
let err = validate_sse_headers_for_read(&metadata, &headers).unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_sse_headers_for_read_rejects_ssec_on_plain_object() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"x-amz-server-side-encryption-customer-algorithm",
|
|
http::HeaderValue::from_static("AES256"),
|
|
);
|
|
headers.insert("x-amz-server-side-encryption-customer-key", http::HeaderValue::from_static("test-key"));
|
|
headers.insert(
|
|
"x-amz-server-side-encryption-customer-key-md5",
|
|
http::HeaderValue::from_static("test-key-md5"),
|
|
);
|
|
|
|
let metadata = HashMap::new();
|
|
let err = validate_sse_headers_for_read(&metadata, &headers).unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_sse_headers_for_read_rejects_ssec_on_managed_object() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"x-amz-server-side-encryption-customer-algorithm",
|
|
http::HeaderValue::from_static("AES256"),
|
|
);
|
|
headers.insert("x-amz-server-side-encryption-customer-key", http::HeaderValue::from_static("test-key"));
|
|
headers.insert(
|
|
"x-amz-server-side-encryption-customer-key-md5",
|
|
http::HeaderValue::from_static("test-key-md5"),
|
|
);
|
|
|
|
let metadata = HashMap::from([("x-amz-server-side-encryption".to_string(), "aws:kms".to_string())]);
|
|
let err = validate_sse_headers_for_read(&metadata, &headers).unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_sse_headers_for_read_allows_encrypted_object_without_request_headers() {
|
|
let metadata = HashMap::from([
|
|
("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()),
|
|
("x-rustfs-encryption-key".to_string(), "encrypted-key".to_string()),
|
|
]);
|
|
let headers = HeaderMap::new();
|
|
assert!(validate_sse_headers_for_read(&metadata, &headers).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_sse_headers_for_read_rejects_sse_on_ssec_object() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("x-amz-server-side-encryption", http::HeaderValue::from_static("aws:kms"));
|
|
headers.insert("x-amz-server-side-encryption-aws-kms-key-id", http::HeaderValue::from_static("test-key"));
|
|
|
|
let metadata = HashMap::from([("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string())]);
|
|
let err = validate_sse_headers_for_read(&metadata, &headers).unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_get_object_reader_error_converts_missing_ssec_headers_to_invalid_request() {
|
|
let err = map_get_object_reader_error(StorageError::other("missing SSE-C algorithm header"));
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
assert_eq!(
|
|
err.message,
|
|
"The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_get_object_reader_error_converts_ssec_md5_mismatch_to_invalid_request() {
|
|
let err = map_get_object_reader_error(StorageError::other("SSE-C key MD5 mismatch"));
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
assert_eq!(
|
|
err.message,
|
|
"The calculated MD5 hash of the key did not match the hash that was provided."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_get_object_reader_error_leaves_non_ssec_errors_unchanged() {
|
|
let err = map_get_object_reader_error(StorageError::other("plain io failure"));
|
|
assert_eq!(err.code, S3ErrorCode::InternalError);
|
|
assert_eq!(err.message, "Io error: plain io failure");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_ssec_params_returns_invalid_request_on_bad_algorithm() {
|
|
let key = BASE64_STANDARD.encode([42u8; 32]);
|
|
let key_md5 = BASE64_STANDARD.encode(md5::compute([42u8; 32]).0);
|
|
let params = SsecParams {
|
|
algorithm: "AES128".to_string(),
|
|
key,
|
|
key_md5,
|
|
};
|
|
let err = validate_ssec_params(params).unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_ssec_params_returns_invalid_request_on_bad_md5() {
|
|
let key = BASE64_STANDARD.encode([42u8; 32]);
|
|
let params = SsecParams {
|
|
algorithm: "AES256".to_string(),
|
|
key,
|
|
key_md5: BASE64_STANDARD.encode([99u8; 16]),
|
|
};
|
|
let err = validate_ssec_params(params).unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Unit tests for issue #2041: no mandatory KMS when encryption not used
|
|
// ========================================================================
|
|
|
|
/// When SSE-C params are not present and no managed SSE is requested,
|
|
/// encryption should be skipped (Ok(None)). Ensures we do not require KMS
|
|
/// when the client sends no encryption headers.
|
|
#[tokio::test]
|
|
async fn test_sse_encryption_skip_when_no_ssec_and_no_managed_sse_requested() {
|
|
let request = EncryptionRequest {
|
|
bucket: "test-bucket",
|
|
key: "test-key",
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
content_size: 1024,
|
|
};
|
|
let result = sse_encryption(request).await;
|
|
match &result {
|
|
Ok(None) => {}
|
|
Ok(Some(_)) => panic!("expected no encryption material when no SSE params provided"),
|
|
Err(e) => {
|
|
assert!(
|
|
!e.message.contains("No KMS key"),
|
|
"must not require KMS when no encryption requested; got: {}",
|
|
e.message
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// When SSE-C params are partial or invalid, sse_encryption must return an error.
|
|
#[tokio::test]
|
|
async fn test_sse_encryption_errors_on_invalid_ssec_params() {
|
|
let bucket = "test-bucket";
|
|
let key = "test-key";
|
|
let sse_key = BASE64_STANDARD.encode([42u8; 32]);
|
|
let wrong_md5 = BASE64_STANDARD.encode([99u8; 16]);
|
|
|
|
let request_wrong_md5 = EncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: Some("AES256".to_string()),
|
|
sse_customer_key: Some(sse_key.clone()),
|
|
sse_customer_key_md5: Some(wrong_md5),
|
|
content_size: 1024,
|
|
};
|
|
let err = sse_encryption(request_wrong_md5).await.unwrap_err();
|
|
assert_eq!(err.code, S3ErrorCode::InvalidRequest);
|
|
|
|
let request_unsupported_algorithm = EncryptionRequest {
|
|
bucket,
|
|
key,
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: Some("unsupported-algo".to_string()),
|
|
sse_customer_key: Some(sse_key),
|
|
sse_customer_key_md5: Some(BASE64_STANDARD.encode(md5::compute([42u8; 32]).0)),
|
|
content_size: 1024,
|
|
};
|
|
let err = sse_encryption(request_unsupported_algorithm).await.unwrap_err();
|
|
assert!(err.code == S3ErrorCode::InvalidRequest || err.code == S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
/// When bucket has no SSE-S3/aws:kms setting and request has no SSE headers,
|
|
/// encryption should be skipped (Ok(None)). Ensures no mandatory bucket default SSE.
|
|
#[tokio::test]
|
|
async fn test_sse_prepare_encryption_skip_when_no_params_and_no_bucket_sse() {
|
|
let request = PrepareEncryptionRequest {
|
|
bucket: "test-bucket-no-sse-config",
|
|
key: "test-key",
|
|
server_side_encryption: None,
|
|
ssekms_key_id: None,
|
|
ssekms_context: None,
|
|
sse_customer_algorithm: None,
|
|
sse_customer_key: None,
|
|
sse_customer_key_md5: None,
|
|
};
|
|
let result = sse_prepare_encryption(request).await;
|
|
match &result {
|
|
Ok(None) => {}
|
|
Ok(Some(_)) => panic!("expected no encryption when bucket has no SSE config and no request SSE"),
|
|
Err(e) => {
|
|
assert!(
|
|
!e.message.contains("No KMS key"),
|
|
"must not require KMS when no bucket SSE and no request SSE; got: {}",
|
|
e.message
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|