Refactor code structure for improved readability and maintainability

This commit is contained in:
唐小鸭
2026-07-24 14:01:40 +08:00
parent 5ea9a1fd8f
commit d69d682193
32 changed files with 3902 additions and 792 deletions
+30 -8
View File
@@ -15,18 +15,37 @@
# MinIO on-disk interop: prove RustFS reads MinIO-written erasure-coded SSE
# objects with byte-identical data and correct logical size.
#
# This is NOT a PR gate. The fixtures are real MinIO backend trees generated on
# the fly (they are gitignored, never committed), so the job regenerates them
# each run with Docker and then runs the `#[ignore]` reader tests in
# crates/ecstore/tests/minio_generated_read_test.rs.
# The fixtures are real MinIO backend trees generated on the fly (they are
# gitignored, never committed), so the job regenerates them each run with
# Docker and then runs the `#[ignore]` reader tests in
# crates/ecstore/tests/minio_generated_read_test.rs. SSE/RIO changes run this
# as a path-filtered PR gate; nightly and manual runs retain broader coverage.
#
# Runner: GitHub-hosted `ubuntu-latest`. It reliably ships Docker + Python,
# unlike the self-hosted fleet, whose pods drift in Docker/pip availability
# (see the infra note in e2e-s3tests.yml). Nightly + manual only.
# (see the infra note in e2e-s3tests.yml).
name: minio-interop
on:
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/minio-interop.yml"
- "Cargo.lock"
- "Cargo.toml"
- "crates/ecstore/Cargo.toml"
- "crates/ecstore/src/io_support/rio.rs"
- "crates/ecstore/src/client/api_put_object_streaming.rs"
- "crates/ecstore/src/object_api/readers.rs"
- "crates/ecstore/src/set_disk/ops/object.rs"
- "crates/ecstore/src/sse/**"
- "crates/ecstore/src/store/object.rs"
- "crates/ecstore/tests/minio_generated_read_test.rs"
- "crates/kms/**"
- "crates/rio-v2/**"
- "rustfs/src/app/object_usecase.rs"
- "rustfs/Cargo.toml"
- "rustfs/src/storage/sse.rs"
schedule:
# Nightly at 03:17 UTC (offset from other nightly jobs).
- cron: "17 3 * * *"
@@ -41,13 +60,13 @@ permissions:
jobs:
minio-interop:
name: MinIO interop (EC + SSE read parity)
# Skip on forks: needs the repo's runners and is not a contributor gate.
# Skip on forks because this job depends on the repository's CI setup.
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
timeout-minutes: 40
env:
# Fixed 32-byte test KMS key baked into the fixture lab; not a secret.
RUSTFS_MINIO_STATIC_KMS_KEY_B64: IyqsU3kMFloCNup4BsZtf/rmfHVcTgznO2F25CkEH1g=
RUSTFS_MINIO_STATIC_KMS_KEY: minio-default-key:IyqsU3kMFloCNup4BsZtf/rmfHVcTgznO2F25CkEH1g=
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -61,7 +80,10 @@ jobs:
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Generate real MinIO fixtures via Docker
run: bash crates/rio-v2/tests/minio_fixture_lab/capture_via_docker.sh
run: bash crates/rio-v2/tests/minio_fixture_lab/capture_via_docker.sh all
- name: Generate real RustFS beta.5 KMS fixture
run: uv run python crates/rio-v2/tests/minio_fixture_lab/capture_rustfs_beta5.py
- name: Run MinIO interop reader tests
run: |
+23
View File
@@ -132,6 +132,29 @@
"rust"
],
},
{
"name": "Debug executable target/debug/rustfs with sse-s3",
"type": "lldb",
"request": "launch",
"program": "${workspaceFolder}/target/debug/rustfs",
"args": [],
"cwd": "${workspaceFolder}",
"env": {
"RUSTFS_ACCESS_KEY": "rustfsadmin",
"RUSTFS_SECRET_KEY": "rustfsadmin",
"RUSTFS_VOLUMES": "./target/volumes/test{1...4}",
"RUSTFS_ADDRESS": ":9000",
"RUSTFS_CONSOLE_ENABLE": "true",
"RUSTFS_CONSOLE_ADDRESS": "127.0.0.1:9001",
"RUSTFS_OBS_LOG_DIRECTORY": "./target/logs",
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
"RUSTFS_SSE_S3_MASTER_KEY": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
"RUST_LOG": "rustfs=debug,ecstore=debug,s3s=debug,iam=debug"
},
"sourceLanguages": [
"rust"
]
},
{
"type": "lldb",
"request": "launch",
Generated
+3
View File
@@ -8952,6 +8952,7 @@ dependencies = [
"url",
"urlencoding",
"uuid",
"zeroize",
"zip",
"zstd",
]
@@ -9093,6 +9094,7 @@ dependencies = [
"byteorder",
"bytes",
"bytesize",
"chacha20",
"chacha20poly1305",
"chrono",
"criterion",
@@ -9189,6 +9191,7 @@ dependencies = [
"urlencoding",
"uuid",
"xxhash-rust",
"zeroize",
]
[[package]]
+1
View File
@@ -194,6 +194,7 @@ aes-gcm = { version = "=0.11.0" }
argon2 = { version = "=0.6.0-rc.8" }
blake2 = "=0.11.0-rc.6"
chacha20poly1305 = { version = "=0.11.0" }
chacha20 = { version = "=0.10.1", features = ["xchacha"] }
crc-fast = "1.10.0"
hmac = { version = "0.13.0" }
jsonwebtoken = { version = "10.4.0" }
+2
View File
@@ -126,6 +126,8 @@ rustfs-madmin.workspace = true
reqwest = { workspace = true }
aes-gcm = { workspace = true, features = ["rand_core"] }
chacha20poly1305.workspace = true
chacha20.workspace = true
zeroize.workspace = true
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
urlencoding = { workspace = true }
smallvec = { workspace = true, features = ["serde"] }
+8 -4
View File
@@ -361,9 +361,10 @@ pub mod notification {
pub mod object {
pub use crate::object_api::{
BLOCK_SIZE_V2, ERASURE_ALGORITHM, GetObjectBodyCacheHook, GetObjectBodyCacheHookLookup, GetObjectBodySource,
GetObjectReader, ObjectInfo, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, StreamConsumer,
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
GetObjectReader, GetObjectSse, ObjectInfo, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader,
StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
unregister_object_mutation_hook,
};
pub use crate::store::PreparedGetObjectReader;
}
@@ -399,7 +400,10 @@ pub mod set_disk {
}
pub mod sse {
pub use crate::sse::{ManagedDekProvider, ManagedSseScheme, managed_dek_provider};
pub use crate::sse::{
ManagedDekProvider, ManagedSseScheme, PersistedEncryptionError, PersistedManagedEncryption,
classify_persisted_managed_encryption, decrypt_minio_static_kms_dek,
};
}
pub mod store_list {
@@ -1625,6 +1625,7 @@ mod tests {
..Default::default()
},
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}
@@ -657,6 +657,7 @@ mod tests {
stream: Box::new(r),
object_info: Default::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
});
@@ -687,6 +688,7 @@ mod tests {
stream: Box::new(r),
object_info: Default::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
});
let buf = read_multipart_part(&mut reader, 100).await.unwrap();
@@ -153,6 +153,7 @@ pub fn new_getobjectreader<'a>(
object_info: oi.clone(),
stream: Box::new(input_reader),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
};
r
@@ -166,6 +167,7 @@ pub fn new_getobjectreader<'a>(
object_info: oi.clone(),
stream: Box::new(input_reader),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
});
+2
View File
@@ -2089,6 +2089,7 @@ mod tests {
}),
object_info: self.object_info(bucket, object),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}
@@ -3315,6 +3316,7 @@ mod tests {
stream: Box::new(Cursor::new(data)),
object_info,
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}
+1
View File
@@ -1240,6 +1240,7 @@ mod tests {
stream: Box::new(Cursor::new(raw_payload.clone())),
object_info: object_info.clone(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
};
+11 -1
View File
@@ -406,6 +406,9 @@ impl WritePlan {
false,
)?,
WriteEncryptionMode::Singlepart { base_nonce } => HashReader::from_reader(
#[cfg(feature = "rio-v2")]
rustfs_rio::EncryptReader::new(reader, encryption.key_bytes, base_nonce),
#[cfg(not(feature = "rio-v2"))]
EncryptReader::new(reader, encryption.key_bytes, base_nonce),
HashReader::SIZE_PRESERVE_LAYER,
actual_size,
@@ -417,6 +420,9 @@ impl WritePlan {
base_nonce,
multipart_part_number,
} => HashReader::from_reader(
#[cfg(feature = "rio-v2")]
rustfs_rio::EncryptReader::new_multipart(reader, encryption.key_bytes, base_nonce, multipart_part_number),
#[cfg(not(feature = "rio-v2"))]
EncryptReader::new_multipart(reader, encryption.key_bytes, base_nonce, multipart_part_number),
HashReader::SIZE_PRESERVE_LAYER,
actual_size,
@@ -509,6 +515,10 @@ mod tests {
.await
.expect("read transformed ciphertext");
#[cfg(feature = "rio-v2")]
let decrypt_reader =
rustfs_rio::DecryptReader::new_multipart(Cursor::new(ciphertext), key_bytes, base_nonce, vec![part_number]);
#[cfg(not(feature = "rio-v2"))]
let decrypt_reader = DecryptReader::new_multipart(Cursor::new(ciphertext), key_bytes, base_nonce, vec![part_number]);
let mut decompressed = DecompressReader::new(Box::new(decrypt_reader), CompressionAlgorithm::default());
@@ -705,7 +715,7 @@ mod tests {
.expect("read transformed ciphertext");
let mut decrypted_compressed = Vec::new();
DecryptReader::new(Cursor::new(ciphertext), key_bytes, base_nonce)
rustfs_rio::DecryptReader::new(Cursor::new(ciphertext), key_bytes, base_nonce)
.read_to_end(&mut decrypted_compressed)
.await
.expect("decrypt compressed stream");
+349 -324
View File
@@ -13,7 +13,19 @@
// limitations under the License.
use super::*;
use crate::sse::{ManagedDekProvider, ManagedSseScheme, managed_dek_provider as classify_managed_dek_provider};
#[cfg(feature = "rio-v2")]
use crate::sse::PersistedManagedEncryption;
#[cfg(feature = "rio-v2")]
use crate::sse::{
AMZ_SERVER_SIDE_ENCRYPTION_KMS_KEY_ID, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER,
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM, decrypt_minio_static_kms_dek,
};
use crate::sse::{
DEFAULT_SSE_ALGORITHM, INTERNAL_ENCRYPTION_IV_HEADER, INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER,
MINIO_INTERNAL_ENCRYPTION_IV_HEADER, ManagedDekProvider, classify_persisted_managed_encryption,
};
#[cfg(feature = "rio-v2")]
use aes_gcm::aead::Payload;
use aes_gcm::{
@@ -28,47 +40,27 @@ use hmac::{Hmac, Mac};
use md5::{Digest, Md5};
use rustfs_kms::types::ObjectEncryptionContext;
#[cfg(feature = "rio-v2")]
use rustfs_kms::{MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, RUSTFS_ENCRYPTION_CONTEXT_HEADER, decode_managed_kms_context};
use rustfs_utils::http::{
AMZ_SERVER_SIDE_ENCRYPTION, SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER, get_consistent_metadata_value,
};
use rustfs_kms::{MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, decode_managed_kms_context};
use rustfs_utils::http::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
use rustfs_utils::path::path_join_buf;
#[cfg(feature = "rio-v2")]
use serde::Deserialize;
#[cfg(feature = "rio-v2")]
use sha2::Sha256;
use std::collections::HashMap;
use std::env;
#[cfg(feature = "rio-v2")]
use zeroize::Zeroizing;
use crate::io_support::rio::Index;
const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id";
const INTERNAL_ENCRYPTION_KEY_HEADER: &str = "x-rustfs-encryption-key";
const INTERNAL_ENCRYPTION_IV_HEADER: &str = "x-rustfs-encryption-iv";
const INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER: &str = "x-rustfs-encryption-original-size";
const SSEC_ORIGINAL_SIZE_HEADER: &str = "x-amz-server-side-encryption-customer-original-size";
const DEFAULT_SSE_ALGORITHM: &str = "AES256";
const SSE_KMS_ALGORITHM: &str = "aws:kms";
#[cfg(feature = "rio-v2")]
const DARE_PAYLOAD_SIZE: i64 = 64 * 1024;
#[cfg(feature = "rio-v2")]
const DARE_PACKAGE_SIZE: i64 = DARE_PAYLOAD_SIZE + 32;
const MINIO_INTERNAL_ENCRYPTION_IV_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Iv";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Kms-Sealed-Key";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Sealed-Key";
#[cfg(feature = "rio-v2")]
const MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM: &str = "DAREv2-HMAC-SHA256";
#[cfg(feature = "rio-v2")]
const DARE_VERSION_20: u8 = 0x20;
#[cfg(feature = "rio-v2")]
const DARE_CIPHER_AES_256_GCM: u8 = 0x00;
@@ -82,13 +74,6 @@ const DARE_TAG_SIZE: usize = 16;
const SEALED_KEY_IV_SIZE: usize = 32;
#[cfg(feature = "rio-v2")]
const SEALED_KEY_SIZE: usize = DARE_HEADER_SIZE + 32 + DARE_TAG_SIZE;
#[cfg(feature = "rio-v2")]
const MINIO_SECRET_KEY_RANDOM_SIZE: usize = 28;
#[cfg(feature = "rio-v2")]
const MINIO_SECRET_KEY_IV_SIZE: usize = 16;
#[cfg(feature = "rio-v2")]
const MINIO_SECRET_KEY_NONCE_SIZE: usize = 12;
#[cfg(feature = "rio-v2")]
type HmacSha256 = Hmac<Sha256>;
@@ -113,14 +98,6 @@ fn build_object_encryption_context(
object_context
}
#[cfg(feature = "rio-v2")]
fn is_legacy_rustfs_managed_metadata(metadata: &HashMap<String, String>) -> bool {
metadata_get(metadata, INTERNAL_ENCRYPTION_KEY_HEADER).is_some()
&& metadata_get(metadata, INTERNAL_ENCRYPTION_IV_HEADER).is_some()
&& metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER).is_none()
&& metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER).is_none()
}
fn part_plaintext_size(part: &ObjectPartInfo) -> i64 {
if part.actual_size > 0 {
part.actual_size
@@ -304,11 +281,19 @@ pub struct GetObjectReader {
pub stream: Box<dyn AsyncRead + Unpin + Send + Sync>,
pub object_info: ObjectInfo,
pub buffered_body: Option<Bytes>,
pub resolved_sse: Option<GetObjectSse>,
/// Cache-hook provenance; defaults to [`GetObjectBodySource::Unprobed`] for
/// every reader that never passed through the app-layer cache probe.
pub body_source: GetObjectBodySource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GetObjectSse {
SseC { customer_key_md5: String },
SseS3,
SseKms { key_id: String },
}
impl GetObjectReader {
/// Builds a fully materialized reader from a cache-coordinated body.
pub fn from_cache_body(mut object_info: ObjectInfo, body: Bytes) -> Result<Self> {
@@ -317,6 +302,7 @@ impl GetObjectReader {
stream: Box::new(std::io::Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source: GetObjectBodySource::HookServed,
})
}
@@ -334,12 +320,13 @@ impl GetObjectReader {
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
struct EncryptionMaterial {
key_bytes: [u8; 32],
base_nonce: [u8; 12],
key_kind: EncryptionKeyKind,
reader_backend: crate::io_support::rio::ReadEncryptionBackend,
resolved_sse: Option<GetObjectSse>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -585,6 +572,7 @@ impl ReadPlan {
stream: reader,
object_info: oi.clone(),
buffered_body: None,
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
},
self.storage_offset,
@@ -640,6 +628,7 @@ impl ReadPlan {
stream: final_reader,
object_info,
buffered_body: None,
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
},
self.storage_offset,
@@ -736,12 +725,14 @@ impl ReadPlan {
let mut object_info = oi.clone();
object_info.size = self.object_size;
let resolved_sse = material.resolved_sse;
Ok((
GetObjectReader {
stream: final_reader,
object_info,
buffered_body: None,
resolved_sse,
body_source: GetObjectBodySource::Unprobed,
},
self.storage_offset,
@@ -1131,19 +1122,19 @@ fn is_supported_sealed_object_key_cipher(cipher: u8) -> bool {
}
#[cfg(feature = "rio-v2")]
fn decrypt_sealed_object_key_payload(sealing_key: [u8; 32], header: &[u8], sealed_key: &[u8]) -> Result<Vec<u8>> {
fn decrypt_sealed_object_key_payload(sealing_key: &[u8; 32], header: &[u8], sealed_key: &[u8]) -> Result<Vec<u8>> {
let nonce = &header[4..16];
let ciphertext = &sealed_key[DARE_HEADER_SIZE..];
let aad = &header[..4];
match header[1] {
DARE_CIPHER_AES_256_GCM => {
let cipher = Aes256Gcm::new_from_slice(&sealing_key)
let cipher = Aes256Gcm::new_from_slice(sealing_key)
.map_err(|err| Error::other(format!("invalid AES-GCM sealing key: {err}")))?;
let nonce = Nonce::try_from(nonce).map_err(|_| Error::other("invalid sealed object-key package nonce"))?;
cipher.decrypt(&nonce, Payload { msg: ciphertext, aad })
}
DARE_CIPHER_CHACHA20_POLY1305 => {
let cipher = ChaCha20Poly1305::new_from_slice(&sealing_key)
let cipher = ChaCha20Poly1305::new_from_slice(sealing_key)
.map_err(|err| Error::other(format!("invalid ChaCha20-Poly1305 sealing key: {err}")))?;
let nonce =
chacha20poly1305::Nonce::try_from(nonce).map_err(|_| Error::other("invalid sealed object-key package nonce"))?;
@@ -1290,8 +1281,8 @@ fn try_unseal_minio_object_key(
return Err(Error::other("invalid sealed object-key payload header"));
}
let sealing_key = derive_sealing_key(external_key, iv, managed_sse_domain(metadata), bucket, object);
let plaintext = decrypt_sealed_object_key_payload(sealing_key, header, &sealed_key)?;
let sealing_key = Zeroizing::new(derive_sealing_key(external_key, iv, managed_sse_domain(metadata), bucket, object));
let plaintext = Zeroizing::new(decrypt_sealed_object_key_payload(&sealing_key, header, &sealed_key)?);
let object_key: [u8; 32] = plaintext
.as_slice()
.try_into()
@@ -1339,12 +1330,19 @@ fn resolve_ssec_material(oi: &ObjectInfo, headers: &HeaderMap<HeaderValue>) -> R
}
#[cfg(feature = "rio-v2")]
if let Some(object_key) = try_unseal_minio_object_key(&oi.user_defined, &oi.bucket, &oi.name, key_bytes)? {
if metadata_get(&oi.user_defined, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER).is_some()
|| metadata_get(&oi.user_defined, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER).is_some()
{
let object_key = try_unseal_minio_object_key(&oi.user_defined, &oi.bucket, &oi.name, key_bytes)?
.ok_or_else(|| Error::other("incomplete or invalid MinIO SSE-C sealed object-key metadata"))?;
return Ok(EncryptionMaterial {
key_bytes: object_key,
base_nonce: [0u8; 12],
key_kind: EncryptionKeyKind::Object,
reader_backend: crate::io_support::rio::ReadEncryptionBackend::V2,
resolved_sse: Some(GetObjectSse::SseC {
customer_key_md5: expected_md5,
}),
});
}
@@ -1353,6 +1351,9 @@ fn resolve_ssec_material(oi: &ObjectInfo, headers: &HeaderMap<HeaderValue>) -> R
base_nonce: read_stored_ssec_nonce(&oi.user_defined, &oi.bucket, &oi.name),
key_kind: EncryptionKeyKind::Direct,
reader_backend: crate::io_support::rio::ReadEncryptionBackend::Legacy,
resolved_sse: Some(GetObjectSse::SseC {
customer_key_md5: expected_md5,
}),
})
}
@@ -1375,52 +1376,94 @@ fn read_stored_ssec_nonce(metadata: &HashMap<String, String>, bucket: &str, key:
}
async fn resolve_managed_material(bucket: &str, object: &str, metadata: &HashMap<String, String>) -> Result<EncryptionMaterial> {
let normalized_metadata = normalize_managed_metadata(metadata);
let encrypted_dek = metadata_get(&normalized_metadata, INTERNAL_ENCRYPTION_KEY_HEADER)
.ok_or_else(|| Error::other("missing managed encrypted DEK"))?;
let encrypted_dek = BASE64_STANDARD
.decode(encrypted_dek)
.map_err(|e| Error::other(format!("failed to decode managed encrypted DEK: {e}")))?;
let encrypted_dek = metadata_get(metadata, INTERNAL_ENCRYPTION_KEY_HEADER);
#[cfg(feature = "rio-v2")]
let encrypted_dek = encrypted_dek.or_else(|| metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER));
let encrypted_dek = match encrypted_dek {
Some(encrypted_dek) => BASE64_STANDARD
.decode(encrypted_dek)
.map_err(|e| Error::other(format!("failed to decode managed encrypted DEK: {e}")))?,
None => Vec::new(),
};
let persisted_format =
classify_persisted_managed_encryption(metadata, &encrypted_dek).map_err(|err| Error::other(err.to_string()))?;
let kms_key_id = metadata_get(&normalized_metadata, INTERNAL_ENCRYPTION_KEY_ID_HEADER).unwrap_or("default");
let kms_key_id = metadata_get(metadata, INTERNAL_ENCRYPTION_KEY_ID_HEADER).filter(|value| !value.is_empty());
#[cfg(feature = "rio-v2")]
let kms_key_id = kms_key_id
.or_else(|| metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER).filter(|value| !value.is_empty()))
.or_else(|| metadata_get(metadata, AMZ_SERVER_SIDE_ENCRYPTION_KMS_KEY_ID).filter(|value| !value.is_empty()));
let kms_key_id = kms_key_id.unwrap_or("default");
let resolved_sse = match persisted_format.scheme() {
crate::sse::ManagedSseScheme::SseS3 => GetObjectSse::SseS3,
crate::sse::ManagedSseScheme::SseKms => GetObjectSse::SseKms {
key_id: kms_key_id.to_string(),
},
};
#[cfg(feature = "rio-v2")]
let kms_context = decode_managed_kms_context(metadata).map_err(|err| Error::other(err.to_string()))?;
#[cfg(not(feature = "rio-v2"))]
let kms_context: Option<HashMap<String, String>> = None;
let object_context = build_object_encryption_context(bucket, object, kms_context.as_ref());
let decrypted_key = match managed_dek_provider(metadata, &encrypted_dek)? {
ManagedDekProvider::LocalSseS3 => decrypt_local_sse_dek(&encrypted_dek, kms_key_id, &object_context)?,
ManagedDekProvider::Kms => {
let service = crate::runtime::sources::object_encryption_service()
let provider = persisted_format.provider();
#[cfg(feature = "rio-v2")]
let minio_static_key = if matches!(provider, ManagedDekProvider::Kms) {
decrypt_minio_static_kms_dek(kms_key_id, &encrypted_dek, &object_context.encryption_context)
.map_err(|err| Error::other(err.to_string()))?
} else {
None
};
#[cfg(not(feature = "rio-v2"))]
let minio_static_key: Option<[u8; 32]> = None;
let service = match provider {
ManagedDekProvider::LocalSseS3 | ManagedDekProvider::MinioKeyValue => None,
ManagedDekProvider::Kms if minio_static_key.is_some() => None,
ManagedDekProvider::Kms => Some(
crate::runtime::sources::object_encryption_service()
.await
.ok_or_else(|| Error::other("KMS encryption service is required to decrypt this object"))?;
#[cfg(feature = "rio-v2")]
let data_key = if is_legacy_rustfs_managed_metadata(&normalized_metadata) {
service.decrypt_legacy_data_key(&encrypted_dek).await
} else {
service.decrypt_data_key(&encrypted_dek, &object_context).await
};
#[cfg(not(feature = "rio-v2"))]
let data_key = service.decrypt_data_key(&encrypted_dek, &object_context).await;
.ok_or_else(|| Error::other("KMS encryption service is required to decrypt this object"))?,
),
};
let decrypted_key = if let Some(key) = minio_static_key {
key
} else if let Some(service) = service {
#[cfg(feature = "rio-v2")]
let data_key = if matches!(
persisted_format,
PersistedManagedEncryption::LegacySseS3Local
| PersistedManagedEncryption::LegacySseS3Kms
| PersistedManagedEncryption::LegacySseKms
) {
service.decrypt_legacy_data_key(&encrypted_dek).await
} else {
service.decrypt_data_key(&encrypted_dek, &object_context).await
};
#[cfg(not(feature = "rio-v2"))]
let data_key = service.decrypt_data_key(&encrypted_dek, &object_context).await;
data_key
.map_err(|e| Error::other(format!("failed to decrypt managed data key: {e}")))?
.plaintext_key
}
data_key
.map_err(|e| Error::other(format!("failed to decrypt managed data key: {e}")))?
.plaintext_key
} else {
decrypt_local_sse_dek(&encrypted_dek)?
};
#[cfg(feature = "rio-v2")]
if let Some(object_key) = try_unseal_minio_object_key(&normalized_metadata, bucket, object, decrypted_key)? {
if persisted_format.uses_object_key() {
let object_key = try_unseal_minio_object_key(metadata, bucket, object, decrypted_key)?
.ok_or_else(|| Error::other("MinIO managed SSE metadata is missing a valid sealed object key"))?;
return Ok(EncryptionMaterial {
key_bytes: object_key,
base_nonce: [0u8; 12],
key_kind: EncryptionKeyKind::Object,
reader_backend: crate::io_support::rio::ReadEncryptionBackend::V2,
resolved_sse: Some(resolved_sse),
});
}
let iv_b64 = metadata_get(&normalized_metadata, INTERNAL_ENCRYPTION_IV_HEADER)
let iv_b64 = metadata_get(metadata, INTERNAL_ENCRYPTION_IV_HEADER)
.or_else(|| metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_IV_HEADER))
.ok_or_else(|| Error::other("missing managed encryption IV"))?;
let iv = BASE64_STANDARD
.decode(iv_b64)
@@ -1435,79 +1478,15 @@ async fn resolve_managed_material(bucket: &str, object: &str, metadata: &HashMap
base_nonce,
key_kind: EncryptionKeyKind::Direct,
reader_backend: crate::io_support::rio::ReadEncryptionBackend::Legacy,
resolved_sse: Some(resolved_sse),
})
}
fn managed_dek_provider(metadata: &HashMap<String, String>, encrypted_dek: &[u8]) -> Result<ManagedDekProvider> {
let algorithm = get_consistent_metadata_value(metadata, AMZ_SERVER_SIDE_ENCRYPTION)
.map_err(|_| Error::other(format!("conflicting managed encryption metadata for {AMZ_SERVER_SIDE_ENCRYPTION}")))?;
let scheme = match algorithm {
Some(SSE_KMS_ALGORITHM) => ManagedSseScheme::SseKms,
Some(DEFAULT_SSE_ALGORITHM) | None => ManagedSseScheme::SseS3,
Some(algorithm) => return Err(Error::other(format!("unsupported stored server-side encryption {algorithm}"))),
};
// RUSTFS_COMPAT_TODO(rustfs-5063): Keep legacy SSE-S3 KMS envelopes readable. Remove after SSE-S3 migration rewraps every referenced legacy DEK.
let has_kms_envelope = rustfs_kms::is_data_key_envelope(encrypted_dek);
Ok(classify_managed_dek_provider(scheme, has_kms_envelope))
}
fn normalize_managed_metadata(metadata: &HashMap<String, String>) -> HashMap<String, String> {
#[cfg(feature = "rio-v2")]
{
let mut normalized = metadata.clone();
if metadata_get(&normalized, INTERNAL_ENCRYPTION_KEY_HEADER).is_none()
&& let Some(value) = metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)
.or_else(|| metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER))
.or_else(|| metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER))
{
normalized.insert(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), value.to_string());
}
if metadata_get(&normalized, INTERNAL_ENCRYPTION_IV_HEADER).is_none()
&& let Some(value) = metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_IV_HEADER)
{
normalized.insert(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), value.to_string());
}
if metadata_get(&normalized, INTERNAL_ENCRYPTION_KEY_ID_HEADER).is_none()
&& let Some(value) = metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)
{
normalized.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), value.to_string());
}
if metadata_get(&normalized, RUSTFS_ENCRYPTION_CONTEXT_HEADER).is_none()
&& let Some(value) = metadata_get(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER)
&& let Ok(decoded) = BASE64_STANDARD.decode(value)
&& let Ok(context) = serde_json::from_slice::<HashMap<String, String>>(&decoded)
&& let Ok(encoded) = serde_json::to_string(&context)
{
normalized.insert(RUSTFS_ENCRYPTION_CONTEXT_HEADER.to_string(), encoded);
}
normalized
}
#[cfg(not(feature = "rio-v2"))]
{
metadata.clone()
}
}
fn decrypt_local_sse_dek(encrypted_dek: &[u8], _kms_key_id: &str, object_context: &ObjectEncryptionContext) -> Result<[u8; 32]> {
if let Ok(plaintext) = decrypt_rustfs_local_sse_dek(encrypted_dek) {
return Ok(plaintext);
}
#[cfg(feature = "rio-v2")]
{
decrypt_minio_secret_key_dek(encrypted_dek, object_context)
}
#[cfg(not(feature = "rio-v2"))]
{
let _ = object_context;
Err(Error::other("invalid managed DEK format"))
fn decrypt_local_sse_dek(encrypted_dek: &[u8]) -> Result<[u8; 32]> {
if encrypted_dek.is_empty() {
return local_sse_master_key();
}
decrypt_rustfs_local_sse_dek(encrypted_dek)
}
fn decrypt_rustfs_local_sse_dek(encrypted_dek: &[u8]) -> Result<[u8; 32]> {
@@ -1541,103 +1520,6 @@ fn decrypt_rustfs_local_sse_dek(encrypted_dek: &[u8]) -> Result<[u8; 32]> {
.map_err(|_| Error::other("managed DEK has invalid plaintext length"))
}
#[cfg(feature = "rio-v2")]
#[derive(Deserialize)]
struct MinioLegacyCiphertext {
#[serde(rename = "aead")]
algorithm: String,
iv: Vec<u8>,
nonce: Vec<u8>,
bytes: Vec<u8>,
}
#[cfg(feature = "rio-v2")]
fn decrypt_minio_secret_key_dek(encrypted_dek: &[u8], object_context: &ObjectEncryptionContext) -> Result<[u8; 32]> {
let key = local_sse_master_key()?;
let (ciphertext, iv, nonce) = parse_minio_secret_key_ciphertext(encrypted_dek)?;
let associated_data = marshal_minio_kms_context(&object_context.encryption_context);
let mut mac = HmacSha256::new_from_slice(&key).map_err(|err| Error::other(format!("invalid local SSE master key: {err}")))?;
mac.update(&iv);
let sealing_key = mac.finalize().into_bytes();
let cipher = Aes256Gcm::new_from_slice(sealing_key.as_slice())
.map_err(|err| Error::other(format!("invalid MinIO sealing key: {err}")))?;
let nonce = Nonce::try_from(&nonce[..]).map_err(|_| Error::other("invalid MinIO managed DEK nonce"))?;
let plaintext = cipher
.decrypt(
&nonce,
aes_gcm::aead::Payload {
msg: &ciphertext,
aad: &associated_data,
},
)
.map_err(|err| Error::other(format!("failed to decrypt MinIO managed DEK: {err}")))?;
plaintext
.as_slice()
.try_into()
.map_err(|_| Error::other("MinIO managed DEK has invalid plaintext length"))
}
#[cfg(feature = "rio-v2")]
fn parse_minio_secret_key_ciphertext(
encrypted_dek: &[u8],
) -> Result<(Vec<u8>, [u8; MINIO_SECRET_KEY_IV_SIZE], [u8; MINIO_SECRET_KEY_NONCE_SIZE])> {
if encrypted_dek.first() == Some(&b'{') && encrypted_dek.last() == Some(&b'}') {
let legacy: MinioLegacyCiphertext = serde_json::from_slice(encrypted_dek)
.map_err(|err| Error::other(format!("failed to parse MinIO legacy managed DEK: {err}")))?;
if legacy.algorithm != "AES-256-GCM-HMAC-SHA-256" {
return Err(Error::other(format!(
"unsupported MinIO legacy managed DEK algorithm {}",
legacy.algorithm
)));
}
let iv = legacy
.iv
.as_slice()
.try_into()
.map_err(|_| Error::other("invalid MinIO legacy managed DEK IV length"))?;
let nonce = legacy
.nonce
.as_slice()
.try_into()
.map_err(|_| Error::other("invalid MinIO legacy managed DEK nonce length"))?;
return Ok((legacy.bytes, iv, nonce));
}
if encrypted_dek.len() <= MINIO_SECRET_KEY_RANDOM_SIZE {
return Err(Error::other("invalid MinIO managed DEK length"));
}
let split_at = encrypted_dek.len() - MINIO_SECRET_KEY_RANDOM_SIZE;
let (ciphertext, random) = encrypted_dek.split_at(split_at);
let iv = random[..MINIO_SECRET_KEY_IV_SIZE]
.try_into()
.map_err(|_| Error::other("invalid MinIO managed DEK IV length"))?;
let nonce = random[MINIO_SECRET_KEY_IV_SIZE..]
.try_into()
.map_err(|_| Error::other("invalid MinIO managed DEK nonce length"))?;
Ok((ciphertext.to_vec(), iv, nonce))
}
#[cfg(feature = "rio-v2")]
fn marshal_minio_kms_context(context: &HashMap<String, String>) -> Vec<u8> {
let mut entries: Vec<_> = context.iter().collect();
entries.sort_by_key(|(left, _)| *left);
let mut json = String::from("{");
for (index, (key, value)) in entries.into_iter().enumerate() {
if index > 0 {
json.push(',');
}
json.push_str(&serde_json::to_string(key).expect("string key serializes"));
json.push(':');
json.push_str(&serde_json::to_string(value).expect("string value serializes"));
}
json.push('}');
json.into_bytes()
}
fn local_sse_master_key() -> Result<[u8; 32]> {
#[cfg(test)]
if let Some(key) = decode_master_key_env("__RUSTFS_SSE_SIMPLE_CMK")? {
@@ -1728,6 +1610,7 @@ mod tests {
assert_eq!(reader.body_source, GetObjectBodySource::HookServed);
assert_eq!(reader.buffered_body.as_ref(), Some(&body));
assert_eq!(reader.resolved_sse, None);
assert_eq!(reader.object_info.size, 11);
assert_eq!(reader.object_info.actual_size, 11);
assert!(reader.object_info.is_compressed());
@@ -1788,20 +1671,38 @@ mod tests {
#[cfg(feature = "rio-v2")]
#[test]
fn test_legacy_managed_metadata_excludes_sealed_keys() {
let legacy_metadata = HashMap::from([
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), "encrypted-dek".to_string()),
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "nonce".to_string()),
]);
assert!(is_legacy_rustfs_managed_metadata(&legacy_metadata));
fn resolve_ssec_material_rejects_partial_object_key_metadata() {
let customer_key = [0x42u8; 32];
let headers = ssec_headers_from_key(customer_key);
for partial in [
HashMap::from([(
MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode([0x11u8; SEALED_KEY_SIZE]),
)]),
HashMap::from([(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
)]),
] {
let mut metadata = HashMap::from([
(SSEC_ALGORITHM_HEADER.to_string(), DEFAULT_SSE_ALGORITHM.to_string()),
(SSEC_KEY_MD5_HEADER.to_string(), BASE64_STANDARD.encode(md5_bytes(customer_key))),
]);
metadata.extend(partial);
let object_info = ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
user_defined: Arc::new(metadata),
..Default::default()
};
let sealed_metadata = HashMap::from([
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), "encrypted-dek".to_string()),
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "nonce".to_string()),
(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(), "sealed-key".to_string()),
]);
assert!(!is_legacy_rustfs_managed_metadata(&sealed_metadata));
assert!(
resolve_ssec_material(&object_info, &headers)
.expect_err("partial MinIO SSE-C metadata must fail closed")
.to_string()
.contains("incomplete or invalid")
);
}
}
#[cfg(feature = "rio-v2")]
@@ -2129,57 +2030,6 @@ mod tests {
format!("{}:{}", BASE64_STANDARD.encode(nonce), BASE64_STANDARD.encode(ciphertext))
}
#[test]
fn managed_dek_provider_routes_by_algorithm_and_persisted_envelope() {
let local_dek = encrypt_managed_dek_for_test([0x24; 32], [0x42; 32]);
let kms_dek = serde_json::to_vec(&serde_json::json!({
"key_id": "legacy-data-key",
"master_key_id": "legacy-master-key",
"key_spec": "AES_256",
"encrypted_key": [1, 2, 3, 4],
"nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
"encryption_context": {},
"created_at": "2024-01-01T00:00:00+00:00"
}))
.expect("legacy KMS envelope should serialize");
assert_eq!(
managed_dek_provider(
&HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), DEFAULT_SSE_ALGORITHM.to_string())]),
local_dek.as_bytes(),
)
.expect("SSE-S3 local DEK should be classified"),
ManagedDekProvider::LocalSseS3
);
assert_eq!(
managed_dek_provider(
&HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), SSE_KMS_ALGORITHM.to_string())]),
local_dek.as_bytes(),
)
.expect("SSE-KMS should be classified by its stored algorithm"),
ManagedDekProvider::Kms
);
assert_eq!(
managed_dek_provider(
&HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), DEFAULT_SSE_ALGORITHM.to_string())]),
&kms_dek,
)
.expect("legacy SSE-S3 KMS envelope should be classified"),
ManagedDekProvider::Kms
);
assert_eq!(
managed_dek_provider(&HashMap::new(), &kms_dek).expect("legacy KMS envelope without algorithm should be classified"),
ManagedDekProvider::Kms
);
assert!(
managed_dek_provider(
&HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "unsupported".to_string())]),
local_dek.as_bytes(),
)
.is_err()
);
}
#[cfg(feature = "rio-v2")]
fn seal_managed_s3_object_key_for_test(
bucket: &str,
@@ -2197,9 +2047,21 @@ mod tests {
data_key: [u8; 32],
object_key: [u8; 32],
cipher_id: u8,
) -> ([u8; 32], Vec<u8>) {
seal_managed_object_key_for_test(bucket, object, data_key, object_key, "SSE-S3", cipher_id)
}
#[cfg(feature = "rio-v2")]
fn seal_managed_object_key_for_test(
bucket: &str,
object: &str,
data_key: [u8; 32],
object_key: [u8; 32],
domain: &str,
cipher_id: u8,
) -> ([u8; 32], Vec<u8>) {
let iv = [0x24u8; SEALED_KEY_IV_SIZE];
let sealing_key = derive_sealing_key(data_key, iv, "SSE-S3", bucket, object);
let sealing_key = derive_sealing_key(data_key, iv, domain, bucket, object);
let mut header = [0u8; DARE_HEADER_SIZE];
header[0] = DARE_VERSION_20;
@@ -2275,7 +2137,7 @@ mod tests {
#[tokio::test]
async fn resolve_managed_material_accepts_chacha20_poly1305_header_variant() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
let data_key = [0x24; 32];
let data_key = [0u8; 32];
let object_key = [0x33; 32];
let (iv, sealed_key) = seal_managed_s3_object_key_for_test_with_cipher(
"bucket",
@@ -2285,7 +2147,6 @@ mod tests {
DARE_CIPHER_CHACHA20_POLY1305,
);
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0u8; 32]);
let metadata = HashMap::from([
(
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(),
@@ -2296,11 +2157,6 @@ mod tests {
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
),
(
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(encrypted_dek.as_bytes()),
),
(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "default".to_string()),
]);
let material = resolve_managed_material("bucket", "object", &metadata)
@@ -2530,11 +2386,52 @@ mod tests {
));
}
#[tokio::test]
async fn test_zero_length_ssec_read_still_requires_customer_key() {
let object_info = ObjectInfo {
size: 0,
user_defined: Arc::new(HashMap::from([
(SSEC_ALGORITHM_HEADER.to_string(), DEFAULT_SSE_ALGORITHM.to_string()),
(SSEC_KEY_MD5_HEADER.to_string(), BASE64_STANDARD.encode([0x11; 16])),
(SSEC_ORIGINAL_SIZE_HEADER.to_string(), "0".to_string()),
])),
..Default::default()
};
let result = ReadPlan::build(None, &object_info, &ObjectOptions::default(), &HeaderMap::new()).await;
assert!(result.is_err(), "zero-length SSE-C reads must not bypass customer-key authorization");
}
#[tokio::test]
async fn test_zero_length_sse_kms_read_still_requires_kms() {
let object_info = ObjectInfo {
size: 0,
bucket: "bucket".to_string(),
name: "zero-kms".to_string(),
user_defined: Arc::new(HashMap::from([
("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()),
(
INTERNAL_ENCRYPTION_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(b"opaque-kms-ciphertext"),
),
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([0x12; 12])),
(INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER.to_string(), "0".to_string()),
])),
..Default::default()
};
let result = ReadPlan::build(None, &object_info, &ObjectOptions::default(), &HeaderMap::new()).await;
assert!(result.is_err(), "zero-length SSE-KMS reads must not bypass KMS authorization");
}
#[tokio::test]
async fn test_get_object_reader_allows_encrypted_full_object_passthrough() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
let plaintext = b"managed-full-object".to_vec();
let data_key = [0x21; 32];
let data_key = [0u8; 32];
#[cfg(not(feature = "rio-v2"))]
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0u8; 32]);
let bucket = "bucket";
let object = "managed-full-object";
@@ -2550,7 +2447,6 @@ mod tests {
.expect("encrypt managed object");
HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
("x-rustfs-encryption-key".to_string(), BASE64_STANDARD.encode(encrypted_dek.as_bytes())),
("x-rustfs-encryption-original-size".to_string(), plaintext.len().to_string()),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
@@ -2611,7 +2507,8 @@ mod tests {
async fn test_get_object_reader_decrypts_managed_sse_range_on_plaintext_semantics() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
let plaintext = b"0123456789abcdefghijklmnopqrstuvwxyz".to_vec();
let data_key = [0x23; 32];
let data_key = [0u8; 32];
#[cfg(not(feature = "rio-v2"))]
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0u8; 32]);
let bucket = "bucket";
let object = "managed-range-object";
@@ -2627,7 +2524,6 @@ mod tests {
.expect("encrypt managed ranged object");
HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
("x-rustfs-encryption-key".to_string(), BASE64_STANDARD.encode(encrypted_dek.as_bytes())),
("x-rustfs-encryption-original-size".to_string(), plaintext.len().to_string()),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
@@ -2698,7 +2594,8 @@ mod tests {
],
async {
let plaintext = b"managed-local-fallback".to_vec();
let data_key = [0x22; 32];
let data_key = [0x33; 32];
#[cfg(not(feature = "rio-v2"))]
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0x33; 32]);
let bucket = "bucket";
let object = "managed-local-fallback";
@@ -2714,7 +2611,6 @@ mod tests {
.expect("encrypt managed object with local fallback key");
HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
("x-rustfs-encryption-key".to_string(), BASE64_STANDARD.encode(encrypted_dek.as_bytes())),
("x-rustfs-encryption-original-size".to_string(), plaintext.len().to_string()),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
@@ -2775,8 +2671,7 @@ mod tests {
async fn test_get_object_reader_accepts_minio_only_managed_metadata() {
async_with_vars([("__RUSTFS_SSE_SIMPLE_CMK", Some(BASE64_STANDARD.encode([0u8; 32])))], async {
let plaintext = b"managed-minio-metadata".to_vec();
let data_key = [0x23; 32];
let encrypted_dek = encrypt_managed_dek_for_test(data_key, [0u8; 32]);
let data_key = [0u8; 32];
let bucket = "bucket";
let object = "managed-minio-metadata";
let object_key = [0x44; 32];
@@ -2794,10 +2689,6 @@ mod tests {
size: encrypted.len() as i64,
user_defined: Arc::new(HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
(
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(encrypted_dek.as_bytes()),
),
(
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(sealed_key),
@@ -2807,7 +2698,6 @@ mod tests {
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
),
(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "default".to_string()),
("x-minio-internal-actual-size".to_string(), plaintext.len().to_string()),
])),
..Default::default()
@@ -2823,6 +2713,7 @@ mod tests {
.await
.expect("managed encrypted reads should accept MinIO-style metadata");
assert_eq!(reader.resolved_sse, Some(GetObjectSse::SseS3));
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read managed plaintext");
@@ -2834,6 +2725,128 @@ mod tests {
.await;
}
#[cfg(feature = "rio-v2")]
#[tokio::test]
async fn test_get_object_reader_accepts_minio_legacy_key_value_metadata() {
let external_key = [0x23; 32];
async_with_vars([("RUSTFS_SSE_S3_MASTER_KEY", Some(BASE64_STANDARD.encode(external_key)))], async {
let plaintext = b"managed-minio-key-value-metadata".to_vec();
let bucket = "bucket";
let object = "managed-minio-key-value-metadata";
let object_key = [0x45; 32];
let (sealing_iv, sealed_key) = seal_managed_s3_object_key_for_test(bucket, object, external_key, object_key);
let mut encrypted = Vec::new();
crate::io_support::rio::EncryptReader::new_with_object_key(Cursor::new(plaintext.clone()), object_key)
.read_to_end(&mut encrypted)
.await
.expect("encrypt managed object");
let object_info = ObjectInfo {
bucket: bucket.to_string(),
name: object.to_string(),
size: encrypted.len() as i64,
user_defined: Arc::new(HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
(
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(sealed_key),
),
(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode(sealing_iv)),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
),
("x-minio-internal-actual-size".to_string(), plaintext.len().to_string()),
])),
..Default::default()
};
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(encrypted)),
None,
&object_info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("MinIO historical K/V metadata should remain readable");
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.await
.expect("read MinIO historical K/V object");
assert_eq!(reader.resolved_sse, Some(GetObjectSse::SseS3));
assert_eq!(actual, plaintext);
})
.await;
}
#[cfg(feature = "rio-v2")]
#[tokio::test]
async fn test_get_object_reader_accepts_minio_legacy_kms_key_value_metadata() {
let external_key = [0x26; 32];
async_with_vars([("RUSTFS_SSE_S3_MASTER_KEY", Some(BASE64_STANDARD.encode(external_key)))], async {
let plaintext = b"managed-minio-kms-key-value-metadata".to_vec();
let bucket = "bucket";
let object = "managed-minio-kms-key-value-metadata";
let object_key = [0x48; 32];
let (sealing_iv, sealed_key) =
seal_managed_object_key_for_test(bucket, object, external_key, object_key, "SSE-KMS", DARE_CIPHER_AES_256_GCM);
let mut encrypted = Vec::new();
crate::io_support::rio::EncryptReader::new_with_object_key(Cursor::new(plaintext.clone()), object_key)
.read_to_end(&mut encrypted)
.await
.expect("encrypt managed KMS object");
let object_info = ObjectInfo {
bucket: bucket.to_string(),
name: object.to_string(),
size: encrypted.len() as i64,
user_defined: Arc::new(HashMap::from([
("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()),
(
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(sealed_key),
),
(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode(sealing_iv)),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
),
("x-minio-internal-actual-size".to_string(), plaintext.len().to_string()),
])),
..Default::default()
};
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(encrypted)),
None,
&object_info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("MinIO historical SSE-KMS K/V metadata should remain readable");
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.await
.expect("read MinIO historical SSE-KMS K/V object");
assert_eq!(
reader.resolved_sse,
Some(GetObjectSse::SseKms {
key_id: "default".to_string(),
})
);
assert_eq!(actual, plaintext);
})
.await;
}
#[tokio::test]
async fn test_get_object_reader_compressed_range_returns_physical_offset_from_index() {
let mut index = Index::new();
@@ -3106,6 +3119,12 @@ mod tests {
.await
.expect("ssec read should be supported");
assert_eq!(
reader.resolved_sse,
Some(GetObjectSse::SseC {
customer_key_md5: BASE64_STANDARD.encode(md5_bytes(key_bytes)),
})
);
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read decrypted ssec object");
@@ -3168,6 +3187,12 @@ mod tests {
.await
.expect("rio-v2 ssec sealed-object-key read should be supported");
assert_eq!(
reader.resolved_sse,
Some(GetObjectSse::SseC {
customer_key_md5: BASE64_STANDARD.encode(md5_bytes(customer_key)),
})
);
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
@@ -141,6 +141,7 @@ impl MigrationBackendSpy {
stream: Box::new(Cursor::new(vec![0_u8; 3])),
object_info: ObjectInfo::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
}
}
+1
View File
@@ -8194,6 +8194,7 @@ mod tests {
..Default::default()
},
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}
+56 -1
View File
@@ -206,7 +206,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
// });
// }
if object_info.size == 0 {
if object_info.size == 0 && !object_info.is_encrypted() {
record_get_object_reader_path_observation(GET_OBJECT_PATH_EMPTY, object_class, size_bucket);
// if let Some(rs) = range {
// let _ = rs.get_offset_length(object_info.size)?;
@@ -216,6 +216,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(Vec::new())),
object_info,
buffered_body: Some(Bytes::new()),
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
};
return Ok(reader);
@@ -303,6 +304,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
};
return Ok(reader);
@@ -381,6 +383,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
};
return Ok(reader);
@@ -462,6 +465,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source: GetObjectBodySource::HookServed,
};
if lock_optimization_enabled {
@@ -505,6 +509,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source,
};
if lock_optimization_enabled {
@@ -547,6 +552,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
resolved_sse: None,
body_source,
};
if lock_optimization_enabled {
@@ -3311,6 +3317,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
stream: Box::new(TransitionUploadReader::new(pr, Arc::clone(&consumed))),
object_info: oi,
buffered_body: None,
resolved_sse: None,
body_source: GetObjectBodySource::Unprobed,
});
@@ -4047,6 +4054,54 @@ pub(in crate::set_disk::ops) mod hermetic_set_disks_support {
}
}
#[cfg(test)]
mod zero_length_encrypted_read_tests {
use super::hermetic_set_disks_support::hermetic_set_disks;
use super::*;
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
#[tokio::test]
async fn encrypted_zero_length_object_does_not_use_plain_empty_fast_path() {
let (_temp_dirs, _disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "zero-length-encrypted-read";
let object = "empty";
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(Vec::new());
set_disks
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("empty object should be written");
let metadata = HashMap::from([(rustfs_utils::http::SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]);
set_disks
.put_object_metadata(
bucket,
object,
&ObjectOptions {
eval_metadata: Some(metadata),
..Default::default()
},
)
.await
.expect("SSE-C metadata should be persisted");
let result = set_disks
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await;
let error = match result {
Ok(_) => panic!("encrypted empty object must validate SSE-C headers"),
Err(error) => error,
};
assert!(
error.to_string().contains("SSE-C") || error.to_string().contains("customer"),
"the encrypted read must fail at SSE-C validation, got: {error}"
);
}
}
#[cfg(test)]
mod metadata_mutation_generation_tests {
use super::hermetic_set_disks_support::hermetic_set_disks;
+914 -10
View File
@@ -12,6 +12,43 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit, Payload},
};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
use chacha20poly1305::ChaCha20Poly1305;
use hmac::{Hmac, Mac};
use rustfs_utils::http::{AMZ_SERVER_SIDE_ENCRYPTION, get_consistent_metadata_value};
use serde::Deserialize;
use sha2::Sha256;
use std::collections::HashMap;
#[cfg(not(any(test, debug_assertions)))]
use std::sync::OnceLock;
use thiserror::Error;
use zeroize::Zeroizing;
pub(crate) const DEFAULT_SSE_ALGORITHM: &str = "AES256";
const SSE_KMS_ALGORITHM: &str = "aws:kms";
pub(crate) const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id";
pub(crate) const INTERNAL_ENCRYPTION_KEY_HEADER: &str = "x-rustfs-encryption-key";
pub(crate) const INTERNAL_ENCRYPTION_IV_HEADER: &str = "x-rustfs-encryption-iv";
pub(crate) const AMZ_SERVER_SIDE_ENCRYPTION_KMS_KEY_ID: &str = "x-amz-server-side-encryption-aws-kms-key-id";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_IV_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Iv";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Kms-Sealed-Key";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER: &str =
"X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id";
pub(crate) const MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM: &str = "DAREv2-HMAC-SHA256";
const MINIO_STATIC_KMS_KEY_ENV: &str = "RUSTFS_MINIO_STATIC_KMS_KEY";
const MINIO_STATIC_KMS_RANDOM_SIZE: usize = 28;
const MINIO_STATIC_KMS_IV_SIZE: usize = 16;
const MINIO_STATIC_KMS_NONCE_SIZE: usize = 12;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManagedSseScheme {
SseS3,
@@ -21,26 +58,893 @@ pub enum ManagedSseScheme {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManagedDekProvider {
LocalSseS3,
MinioKeyValue,
Kms,
}
pub fn managed_dek_provider(scheme: ManagedSseScheme, has_kms_envelope: bool) -> ManagedDekProvider {
match scheme {
ManagedSseScheme::SseKms => ManagedDekProvider::Kms,
ManagedSseScheme::SseS3 if has_kms_envelope => ManagedDekProvider::Kms,
ManagedSseScheme::SseS3 => ManagedDekProvider::LocalSseS3,
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PersistedManagedEncryption {
LegacySseS3Local,
LegacySseS3Kms,
LegacySseKms,
MinioSseS3KeyValue,
MinioSseS3Kms,
MinioSseKmsKms,
}
impl PersistedManagedEncryption {
pub fn scheme(self) -> ManagedSseScheme {
match self {
Self::LegacySseS3Local | Self::LegacySseS3Kms | Self::MinioSseS3KeyValue | Self::MinioSseS3Kms => {
ManagedSseScheme::SseS3
}
Self::LegacySseKms | Self::MinioSseKmsKms => ManagedSseScheme::SseKms,
}
}
pub fn provider(self) -> ManagedDekProvider {
match self {
Self::LegacySseS3Local => ManagedDekProvider::LocalSseS3,
Self::MinioSseS3KeyValue => ManagedDekProvider::MinioKeyValue,
Self::LegacySseS3Kms | Self::LegacySseKms | Self::MinioSseS3Kms | Self::MinioSseKmsKms => ManagedDekProvider::Kms,
}
}
pub fn uses_object_key(self) -> bool {
matches!(self, Self::MinioSseS3KeyValue | Self::MinioSseS3Kms | Self::MinioSseKmsKms)
}
}
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum PersistedEncryptionError {
#[error("conflicting values for encryption metadata {field}")]
ConflictingValue { field: &'static str },
#[error("conflicting MinIO managed SSE format markers")]
ConflictingMinioMarkers,
#[error("incomplete managed SSE metadata: missing {field}")]
MissingField { field: &'static str },
#[error("unsupported stored server-side encryption {algorithm}")]
UnsupportedSseAlgorithm { algorithm: String },
#[error("unsupported MinIO seal algorithm {algorithm}")]
UnsupportedSealAlgorithm { algorithm: String },
#[error("stored SSE algorithm conflicts with the persisted encryption format")]
SchemeConflict,
#[error("conflicting encrypted data-key metadata")]
ConflictingEncryptedDataKey,
#[error("conflicting KMS key-id metadata")]
ConflictingKmsKeyId,
#[error("invalid MinIO static KMS configuration: {reason}")]
InvalidMinioStaticKmsConfiguration { reason: String },
#[error("invalid MinIO static KMS ciphertext: {reason}")]
InvalidMinioStaticKmsCiphertext { reason: String },
#[error("unrecognized legacy managed SSE encrypted data-key format")]
UnknownLegacyEnvelope,
}
pub fn classify_persisted_managed_encryption(
metadata: &HashMap<String, String>,
encrypted_dek: &[u8],
) -> Result<PersistedManagedEncryption, PersistedEncryptionError> {
let public_algorithm = consistent(metadata, AMZ_SERVER_SIDE_ENCRYPTION)?;
if let Some(algorithm) = public_algorithm
&& !matches!(algorithm, DEFAULT_SSE_ALGORITHM | SSE_KMS_ALGORITHM)
{
return Err(PersistedEncryptionError::UnsupportedSseAlgorithm {
algorithm: algorithm.to_string(),
});
}
let s3_sealed_key = consistent(metadata, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)?;
let kms_sealed_key = consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)?;
if s3_sealed_key.is_some() && kms_sealed_key.is_some() {
return Err(PersistedEncryptionError::ConflictingMinioMarkers);
}
if s3_sealed_key.is_some() || kms_sealed_key.is_some() {
if s3_sealed_key.is_some() {
require_non_empty(s3_sealed_key, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)?;
}
if kms_sealed_key.is_some() {
require_non_empty(kms_sealed_key, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)?;
}
require_non_empty(
consistent(metadata, MINIO_INTERNAL_ENCRYPTION_IV_HEADER)?,
MINIO_INTERNAL_ENCRYPTION_IV_HEADER,
)?;
let seal_algorithm = require_non_empty(
consistent(metadata, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER)?,
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
)?;
if seal_algorithm != MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM {
return Err(PersistedEncryptionError::UnsupportedSealAlgorithm {
algorithm: seal_algorithm.to_string(),
});
}
let kms_key_id = consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)?;
let encrypted_data_key = consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)?;
let has_kms_pair = match (kms_key_id, encrypted_data_key) {
(Some(key_id), Some(data_key)) => {
require_non_empty(Some(key_id), MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)?;
require_non_empty(Some(data_key), MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)?;
true
}
(None, None) => false,
(None, Some(_)) => {
return Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER,
});
}
(Some(_), None) => {
return Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER,
});
}
};
validate_encrypted_key_alias(metadata)?;
validate_kms_key_id_alias(metadata)?;
return match (s3_sealed_key, kms_sealed_key, public_algorithm) {
(Some(_), None, Some(SSE_KMS_ALGORITHM)) | (None, Some(_), Some(DEFAULT_SSE_ALGORITHM)) => {
Err(PersistedEncryptionError::SchemeConflict)
}
(Some(_), None, _) if !has_kms_pair => Ok(PersistedManagedEncryption::MinioSseS3KeyValue),
(Some(_), None, _) => Ok(PersistedManagedEncryption::MinioSseS3Kms),
(None, Some(_), _) if !has_kms_pair => Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER,
}),
(None, Some(_), _) => Ok(PersistedManagedEncryption::MinioSseKmsKms),
_ => Err(PersistedEncryptionError::ConflictingMinioMarkers),
};
}
if consistent(metadata, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER)?.is_some()
|| consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)?.is_some()
{
return Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
});
}
require_non_empty(consistent(metadata, INTERNAL_ENCRYPTION_KEY_HEADER)?, INTERNAL_ENCRYPTION_KEY_HEADER)?;
require_non_empty(consistent(metadata, INTERNAL_ENCRYPTION_IV_HEADER)?, INTERNAL_ENCRYPTION_IV_HEADER)?;
match public_algorithm {
Some(SSE_KMS_ALGORITHM) if is_local_sse_s3_envelope(encrypted_dek) => Err(PersistedEncryptionError::SchemeConflict),
Some(SSE_KMS_ALGORITHM) => Ok(PersistedManagedEncryption::LegacySseKms),
Some(DEFAULT_SSE_ALGORITHM) | None if rustfs_kms::is_data_key_envelope(encrypted_dek) => {
// RUSTFS_COMPAT_TODO(rustfs-5063): older SSE-S3 objects may contain KMS-wrapped DEKs. Remove after every referenced legacy DEK has been rewrapped with the local SSE-S3 provider.
Ok(PersistedManagedEncryption::LegacySseS3Kms)
}
Some(DEFAULT_SSE_ALGORITHM) | None if is_local_sse_s3_envelope(encrypted_dek) => {
Ok(PersistedManagedEncryption::LegacySseS3Local)
}
Some(DEFAULT_SSE_ALGORITHM) | None => Err(PersistedEncryptionError::UnknownLegacyEnvelope),
Some(_) => unreachable!("unsupported algorithms return before format classification"),
}
}
fn consistent<'a>(
metadata: &'a HashMap<String, String>,
field: &'static str,
) -> Result<Option<&'a str>, PersistedEncryptionError> {
get_consistent_metadata_value(metadata, field).map_err(|_| PersistedEncryptionError::ConflictingValue { field })
}
fn require_non_empty<'a>(value: Option<&'a str>, field: &'static str) -> Result<&'a str, PersistedEncryptionError> {
value
.filter(|value| !value.is_empty())
.ok_or(PersistedEncryptionError::MissingField { field })
}
fn validate_encrypted_key_alias(metadata: &HashMap<String, String>) -> Result<(), PersistedEncryptionError> {
let Some(rustfs_key) = consistent(metadata, INTERNAL_ENCRYPTION_KEY_HEADER)? else {
return Ok(());
};
let minio_key = require_non_empty(
consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)?,
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER,
)?;
if rustfs_key != minio_key {
return Err(PersistedEncryptionError::ConflictingEncryptedDataKey);
}
Ok(())
}
fn validate_kms_key_id_alias(metadata: &HashMap<String, String>) -> Result<(), PersistedEncryptionError> {
let values = [
consistent(metadata, INTERNAL_ENCRYPTION_KEY_ID_HEADER)?,
consistent(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)?,
consistent(metadata, AMZ_SERVER_SIDE_ENCRYPTION_KMS_KEY_ID)?,
];
let mut present = values.into_iter().flatten().filter(|value| !value.is_empty());
let Some(first) = present.next() else {
return Ok(());
};
if present.any(|value| value != first) {
return Err(PersistedEncryptionError::ConflictingKmsKeyId);
}
Ok(())
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MinioStaticKmsJsonCiphertext {
#[serde(rename = "aead")]
algorithm: String,
id: Option<String>,
iv: String,
nonce: String,
bytes: String,
}
#[derive(Clone, Copy)]
enum MinioStaticKmsAlgorithm {
Aes256,
ChaCha20,
}
#[derive(Clone)]
struct ConfiguredMinioStaticKmsKey {
id: String,
material: Zeroizing<[u8; 32]>,
}
struct ParsedMinioStaticKmsCiphertext {
ciphertext: Vec<u8>,
iv: [u8; 16],
nonce: [u8; 12],
algorithm: MinioStaticKmsAlgorithm,
}
pub fn decrypt_minio_static_kms_dek(
kms_key_id: &str,
encrypted_dek: &[u8],
context: &HashMap<String, String>,
) -> Result<Option<[u8; 32]>, PersistedEncryptionError> {
let Some(configured_key) = minio_static_kms_key()? else {
return Ok(None);
};
if configured_key.id != kms_key_id {
return Ok(None);
}
let parsed = parse_minio_static_kms_ciphertext(encrypted_dek)?;
let ParsedMinioStaticKmsCiphertext {
ciphertext,
iv,
nonce,
algorithm,
} = parsed;
let master_key = configured_key.material;
let sealing_key: Zeroizing<[u8; 32]> = Zeroizing::new(match algorithm {
MinioStaticKmsAlgorithm::Aes256 => {
let mut mac = HmacSha256::new_from_slice(master_key.as_slice()).map_err(|err| {
PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("invalid HMAC key: {err}"),
}
})?;
mac.update(&iv);
mac.finalize().into_bytes().into()
}
MinioStaticKmsAlgorithm::ChaCha20 => chacha20::hchacha::<chacha20::R20>((&*master_key).into(), (&iv).into()).into(),
});
let associated_data = marshal_minio_kms_context(context);
let plaintext = Zeroizing::new(
match algorithm {
MinioStaticKmsAlgorithm::Aes256 => Aes256Gcm::new_from_slice(sealing_key.as_slice())
.map_err(|err| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("invalid AES sealing key: {err}"),
})?
.decrypt(
&Nonce::from(nonce),
Payload {
msg: &ciphertext,
aad: &associated_data,
},
),
MinioStaticKmsAlgorithm::ChaCha20 => ChaCha20Poly1305::new_from_slice(sealing_key.as_slice())
.map_err(|err| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("invalid ChaCha20 sealing key: {err}"),
})?
.decrypt(
&chacha20poly1305::Nonce::from(nonce),
Payload {
msg: &ciphertext,
aad: &associated_data,
},
),
}
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: "AEAD authentication failed".to_string(),
})?,
);
plaintext
.as_slice()
.try_into()
.map(Some)
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: "plaintext data key must be 32 bytes".to_string(),
})
}
fn minio_static_kms_key() -> Result<Option<ConfiguredMinioStaticKmsKey>, PersistedEncryptionError> {
#[cfg(not(any(test, debug_assertions)))]
{
static CONFIG: OnceLock<Result<Option<ConfiguredMinioStaticKmsKey>, PersistedEncryptionError>> = OnceLock::new();
return CONFIG.get_or_init(parse_minio_static_kms_key).clone();
}
#[cfg(any(test, debug_assertions))]
parse_minio_static_kms_key()
}
fn parse_minio_static_kms_key() -> Result<Option<ConfiguredMinioStaticKmsKey>, PersistedEncryptionError> {
let Some(value) = std::env::var_os(MINIO_STATIC_KMS_KEY_ENV) else {
return Ok(None);
};
let value =
Zeroizing::new(
value
.into_string()
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: format!("{MINIO_STATIC_KMS_KEY_ENV} must be valid UTF-8"),
})?,
);
let (key_id, encoded_key) =
value
.split_once(':')
.ok_or_else(|| PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: format!("{MINIO_STATIC_KMS_KEY_ENV} must use <key-id>:<base64-key>"),
})?;
if key_id.is_empty() {
return Err(PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: "key ID must not be empty".to_string(),
});
}
let decoded_key = Zeroizing::new(BASE64_STANDARD.decode(encoded_key).map_err(|_| {
PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: "key material must be valid Base64".to_string(),
}
})?);
let key: [u8; 32] =
decoded_key
.as_slice()
.try_into()
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: "key material must decode to exactly 32 bytes".to_string(),
})?;
if key == [0u8; 32] {
return Err(PersistedEncryptionError::InvalidMinioStaticKmsConfiguration {
reason: "key material must not be all zero".to_string(),
});
}
Ok(Some(ConfiguredMinioStaticKmsKey {
id: key_id.to_string(),
material: Zeroizing::new(key),
}))
}
fn parse_minio_static_kms_ciphertext(encrypted_dek: &[u8]) -> Result<ParsedMinioStaticKmsCiphertext, PersistedEncryptionError> {
if encrypted_dek.first() == Some(&b'{') && encrypted_dek.last() == Some(&b'}') {
let envelope: MinioStaticKmsJsonCiphertext =
serde_json::from_slice(encrypted_dek).map_err(|err| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("invalid legacy JSON: {err}"),
})?;
let _ = envelope.id;
let algorithm = match envelope.algorithm.as_str() {
"AES-256-GCM-HMAC-SHA-256" => MinioStaticKmsAlgorithm::Aes256,
"ChaCha20Poly1305" => MinioStaticKmsAlgorithm::ChaCha20,
algorithm => {
return Err(PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("unsupported algorithm {algorithm}"),
});
}
};
return Ok(ParsedMinioStaticKmsCiphertext {
ciphertext: decode_minio_static_kms_field("bytes", &envelope.bytes)?,
iv: decode_minio_static_kms_array("iv", &envelope.iv)?,
nonce: decode_minio_static_kms_array("nonce", &envelope.nonce)?,
algorithm,
});
}
if encrypted_dek.len() <= MINIO_STATIC_KMS_RANDOM_SIZE {
return Err(PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: "binary ciphertext is too short".to_string(),
});
}
let split_at = encrypted_dek.len() - MINIO_STATIC_KMS_RANDOM_SIZE;
let (ciphertext, random) = encrypted_dek.split_at(split_at);
Ok(ParsedMinioStaticKmsCiphertext {
ciphertext: ciphertext.to_vec(),
iv: random[..MINIO_STATIC_KMS_IV_SIZE].try_into().map_err(|_| {
PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: "invalid binary IV length".to_string(),
}
})?,
nonce: random[MINIO_STATIC_KMS_IV_SIZE..MINIO_STATIC_KMS_IV_SIZE + MINIO_STATIC_KMS_NONCE_SIZE]
.try_into()
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: "invalid binary nonce length".to_string(),
})?,
algorithm: MinioStaticKmsAlgorithm::Aes256,
})
}
fn decode_minio_static_kms_field(field: &'static str, value: &str) -> Result<Vec<u8>, PersistedEncryptionError> {
BASE64_STANDARD
.decode(value)
.map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("{field} must be valid Base64"),
})
}
fn decode_minio_static_kms_array<const N: usize>(field: &'static str, value: &str) -> Result<[u8; N], PersistedEncryptionError> {
decode_minio_static_kms_field(field, value)?.try_into().map_err(|_| {
PersistedEncryptionError::InvalidMinioStaticKmsCiphertext {
reason: format!("{field} must decode to exactly {N} bytes"),
}
})
}
fn marshal_minio_kms_context(context: &HashMap<String, String>) -> Vec<u8> {
let mut entries: Vec<_> = context.iter().collect();
entries.sort_by_key(|(key, _)| *key);
let mut json = String::from("{");
for (index, (key, value)) in entries.into_iter().enumerate() {
if index > 0 {
json.push(',');
}
push_minio_json_string(&mut json, key);
json.push(':');
push_minio_json_string(&mut json, value);
}
json.push('}');
json.into_bytes()
}
fn push_minio_json_string(output: &mut String, value: &str) {
output.push('"');
for character in value.chars() {
match character {
'"' => output.push_str("\\\""),
'\\' => output.push_str("\\\\"),
'\n' => output.push_str("\\n"),
'\r' => output.push_str("\\r"),
'\t' => output.push_str("\\t"),
'<' => output.push_str("\\u003c"),
'>' => output.push_str("\\u003e"),
'&' => output.push_str("\\u0026"),
'\u{2028}' => output.push_str("\\u2028"),
'\u{2029}' => output.push_str("\\u2029"),
character if character <= '\u{1f}' => {
const HEX: &[u8; 16] = b"0123456789abcdef";
let byte = character as u8;
output.push_str("\\u00");
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
character => output.push(character),
}
}
output.push('"');
}
fn is_local_sse_s3_envelope(encrypted_dek: &[u8]) -> bool {
let Ok(encoded) = std::str::from_utf8(encrypted_dek) else {
return false;
};
let Some((nonce, ciphertext)) = encoded.split_once(':') else {
return false;
};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
BASE64_STANDARD.decode(nonce).is_ok_and(|nonce| nonce.len() == 12)
&& BASE64_STANDARD
.decode(ciphertext)
.is_ok_and(|ciphertext| ciphertext.len() == 48)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
fn local_envelope() -> Vec<u8> {
format!("{}:{}", BASE64_STANDARD.encode([1u8; 12]), BASE64_STANDARD.encode([2u8; 48])).into_bytes()
}
fn kms_envelope() -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"key_id": "data-key",
"master_key_id": "master-key",
"key_spec": "AES_256",
"encrypted_key": [1, 2, 3, 4],
"nonce": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
"encryption_context": {},
"created_at": "2024-01-01T00:00:00+00:00"
}))
.expect("serialize KMS envelope fixture")
}
fn legacy_metadata(algorithm: Option<&str>, encrypted_dek: &[u8]) -> HashMap<String, String> {
let mut metadata = HashMap::from([
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), BASE64_STANDARD.encode(encrypted_dek)),
(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([3u8; 12])),
]);
if let Some(algorithm) = algorithm {
metadata.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), algorithm.to_string());
}
metadata
}
fn minio_metadata(kms: bool, encrypted_dek: &[u8]) -> HashMap<String, String> {
let object_key_header = if kms {
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER
} else {
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER
};
let mut metadata = HashMap::from([
(object_key_header.to_string(), BASE64_STANDARD.encode([4u8; 64])),
(
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(),
BASE64_STANDARD.encode(encrypted_dek),
),
(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "default".to_string()),
(MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([5u8; 32])),
(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(),
),
]);
metadata.insert(
AMZ_SERVER_SIDE_ENCRYPTION.to_string(),
if kms { SSE_KMS_ALGORITHM } else { DEFAULT_SSE_ALGORITHM }.to_string(),
);
metadata
}
#[test]
fn managed_sse_routing_is_determined_by_scheme_and_envelope() {
assert_eq!(managed_dek_provider(ManagedSseScheme::SseS3, false), ManagedDekProvider::LocalSseS3);
assert_eq!(managed_dek_provider(ManagedSseScheme::SseS3, true), ManagedDekProvider::Kms);
assert_eq!(managed_dek_provider(ManagedSseScheme::SseKms, false), ManagedDekProvider::Kms);
assert_eq!(managed_dek_provider(ManagedSseScheme::SseKms, true), ManagedDekProvider::Kms);
fn classifies_released_legacy_formats() {
let local = local_envelope();
let kms = kms_envelope();
assert_eq!(
classify_persisted_managed_encryption(&legacy_metadata(Some(DEFAULT_SSE_ALGORITHM), &local), &local)
.expect("classify local Direct format"),
PersistedManagedEncryption::LegacySseS3Local
);
assert_eq!(
classify_persisted_managed_encryption(&legacy_metadata(Some(DEFAULT_SSE_ALGORITHM), &kms), &kms)
.expect("classify legacy SSE-S3 KMS envelope"),
PersistedManagedEncryption::LegacySseS3Kms
);
assert_eq!(
classify_persisted_managed_encryption(
&legacy_metadata(Some(SSE_KMS_ALGORITHM), b"opaque-kms-key"),
b"opaque-kms-key"
)
.expect("classify opaque SSE-KMS envelope"),
PersistedManagedEncryption::LegacySseKms
);
}
#[test]
fn minio_markers_override_envelope_shape() {
let local = local_envelope();
let kms = kms_envelope();
assert_eq!(
classify_persisted_managed_encryption(&minio_metadata(false, &kms), &kms)
.expect("SSE-S3 marker selects MinIO SSE-S3"),
PersistedManagedEncryption::MinioSseS3Kms
);
assert_eq!(
classify_persisted_managed_encryption(&minio_metadata(true, &local), &local)
.expect("SSE-KMS marker selects MinIO SSE-KMS"),
PersistedManagedEncryption::MinioSseKmsKms
);
}
#[test]
fn accepts_minio_sse_s3_key_value_only_when_kms_pair_is_absent() {
let local = local_envelope();
let mut metadata = minio_metadata(false, &local);
metadata.remove(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER);
metadata.remove(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&metadata, &[]).expect("classify MinIO SSE-S3 K/V metadata"),
PersistedManagedEncryption::MinioSseS3KeyValue
);
metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), String::new());
metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), String::new());
assert_eq!(
classify_persisted_managed_encryption(&metadata, &[]),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER
})
);
}
#[test]
fn rejects_minio_sse_kms_without_kms_pair() {
let local = local_envelope();
let mut metadata = minio_metadata(true, &local);
metadata.remove(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER);
metadata.remove(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&metadata, &[]),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER
})
);
}
#[test]
fn persisted_minio_provider_does_not_depend_on_runtime_kms_availability() {
let static_kms = br#"{"aead":"AES-256-GCM-HMAC-SHA-256","iv":"AQEBAQEBAQEBAQEBAQEBAQ==","nonce":"AgICAgICAgICAgIC","bytes":"AwMDAw=="}"#;
let opaque_kms = b"opaque-external-kms-ciphertext";
assert_eq!(
classify_persisted_managed_encryption(&minio_metadata(false, static_kms), static_kms)
.expect("classify MinIO static KMS envelope")
.provider(),
ManagedDekProvider::Kms
);
assert_eq!(
classify_persisted_managed_encryption(&minio_metadata(true, opaque_kms), opaque_kms)
.expect("classify external KMS ciphertext")
.provider(),
ManagedDekProvider::Kms
);
let mut key_value = minio_metadata(false, opaque_kms);
key_value.remove(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER);
key_value.remove(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&key_value, &[])
.expect("classify historical K/V provider")
.provider(),
ManagedDekProvider::MinioKeyValue
);
}
#[test]
fn decrypts_minio_static_kms_binary_aes_ciphertext() {
let master_key = [0x31; 32];
let plaintext_key = [0x52; 32];
let iv = [0x63; 16];
let nonce = [0x74; 12];
let context = HashMap::from([("bucket".to_string(), "bucket/object".to_string())]);
let mut mac = HmacSha256::new_from_slice(&master_key).expect("valid master key");
mac.update(&iv);
let sealing_key = mac.finalize().into_bytes();
let ciphertext = Aes256Gcm::new_from_slice(sealing_key.as_slice())
.expect("valid sealing key")
.encrypt(
&Nonce::from(nonce),
Payload {
msg: &plaintext_key,
aad: &marshal_minio_kms_context(&context),
},
)
.expect("encrypt fixture");
let mut envelope = ciphertext;
envelope.extend_from_slice(&iv);
envelope.extend_from_slice(&nonce);
let configured_key = format!("minio-key:{}", BASE64_STANDARD.encode(master_key));
temp_env::with_var(MINIO_STATIC_KMS_KEY_ENV, Some(configured_key), || {
assert_eq!(
decrypt_minio_static_kms_dek("minio-key", &envelope, &context).expect("decrypt binary ciphertext"),
Some(plaintext_key)
);
assert_eq!(
decrypt_minio_static_kms_dek("external-key", &envelope, &context)
.expect("different key ID must remain external KMS"),
None
);
});
}
#[test]
fn decrypts_official_minio_legacy_chacha20_ciphertext() {
let configured_key = "my-key:eEm+JI9/q4JhH8QwKvf3LKo4DEBl6QbfvAl1CAbMIv8=";
let ciphertext = br#"{"aead":"ChaCha20Poly1305","iv":"JbI+vwvYww1lCb5VpkAFuQ==","nonce":"ARjIjJxBSD541Gz8","bytes":"KCbEc2sA0TLvA7aWTWa23AdccVfJMpOxwgG8hm+4PaNrxYfy1xFWZg2gEenVrOgv"}"#;
let expected: [u8; 32] = BASE64_STANDARD
.decode("zmS7NrG765UZ0ZN85oPjybelxqVvpz01vxsSpOISy2M=")
.expect("decode official plaintext")
.try_into()
.expect("official plaintext is 32 bytes");
temp_env::with_var(MINIO_STATIC_KMS_KEY_ENV, Some(configured_key), || {
assert_eq!(
decrypt_minio_static_kms_dek("my-key", ciphertext, &HashMap::new()).expect("decrypt official MinIO ciphertext"),
Some(expected)
);
});
}
#[test]
fn rejects_malformed_config_and_unknown_legacy_fields() {
temp_env::with_var(MINIO_STATIC_KMS_KEY_ENV, Some("missing-separator"), || {
assert!(matches!(
decrypt_minio_static_kms_dek("my-key", b"ciphertext", &HashMap::new()),
Err(PersistedEncryptionError::InvalidMinioStaticKmsConfiguration { .. })
));
});
let configured_key = format!("my-key:{}", BASE64_STANDARD.encode([0x31; 32]));
let ciphertext =
br#"{"aead":"AES-256-GCM-HMAC-SHA-256","iv":"Y2NjY2NjY2NjY2NjY2NjYw==","nonce":"dHR0dHR0dHR0dHR0","bytes":"AA==","extra":true}"#;
temp_env::with_var(MINIO_STATIC_KMS_KEY_ENV, Some(configured_key), || {
assert!(matches!(
decrypt_minio_static_kms_dek("my-key", ciphertext, &HashMap::new()),
Err(PersistedEncryptionError::InvalidMinioStaticKmsCiphertext { .. })
));
});
}
#[test]
fn rejects_all_zero_minio_static_kms_key() {
let configured_key = format!("my-key:{}", BASE64_STANDARD.encode([0u8; 32]));
temp_env::with_var(MINIO_STATIC_KMS_KEY_ENV, Some(configured_key), || {
assert!(matches!(
decrypt_minio_static_kms_dek("my-key", b"ciphertext", &HashMap::new()),
Err(PersistedEncryptionError::InvalidMinioStaticKmsConfiguration { .. })
));
});
}
#[test]
fn marshals_minio_kms_context_with_go_json_escaping() {
let context = HashMap::from([
("z".to_string(), "line\n".to_string()),
("<\u{8}".to_string(), "&\u{2028}".to_string()),
]);
assert_eq!(marshal_minio_kms_context(&context), br#"{"\u003c\u0008":"\u0026\u2028","z":"line\n"}"#);
}
#[test]
fn rejects_conflicting_or_partial_minio_metadata() {
let local = local_envelope();
let mut both = minio_metadata(false, &local);
both.insert(
MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode([6u8; 64]),
);
assert_eq!(
classify_persisted_managed_encryption(&both, &local),
Err(PersistedEncryptionError::ConflictingMinioMarkers)
);
let mut wrong_scheme = minio_metadata(true, &local);
wrong_scheme.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), DEFAULT_SSE_ALGORITHM.to_string());
assert_eq!(
classify_persisted_managed_encryption(&wrong_scheme, &local),
Err(PersistedEncryptionError::SchemeConflict)
);
let mut partial = minio_metadata(false, &local);
partial.remove(MINIO_INTERNAL_ENCRYPTION_IV_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&partial, &local),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_IV_HEADER
})
);
let mut missing_key_id = minio_metadata(false, &local);
missing_key_id.remove(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&missing_key_id, &local),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER
})
);
let mut missing_data_key = minio_metadata(false, &local);
missing_data_key.remove(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER);
assert_eq!(
classify_persisted_managed_encryption(&missing_data_key, &local),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER
})
);
let mut unknown_algorithm = minio_metadata(false, &local);
unknown_algorithm.insert(
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(),
"future-seal-algorithm".to_string(),
);
assert_eq!(
classify_persisted_managed_encryption(&unknown_algorithm, &local),
Err(PersistedEncryptionError::UnsupportedSealAlgorithm {
algorithm: "future-seal-algorithm".to_string()
})
);
for field in [
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER,
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER,
] {
let mut empty = minio_metadata(false, &local);
empty.insert(field.to_string(), String::new());
assert_eq!(
classify_persisted_managed_encryption(&empty, &local),
Err(PersistedEncryptionError::MissingField { field })
);
}
for field in [
MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER,
] {
let metadata = HashMap::from([(field.to_string(), "orphaned".to_string())]);
assert_eq!(
classify_persisted_managed_encryption(&metadata, &local),
Err(PersistedEncryptionError::MissingField {
field: MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER
})
);
}
}
#[test]
fn rejects_unknown_legacy_envelope_and_conflicting_alias() {
let unknown = b"not-an-envelope";
assert_eq!(
classify_persisted_managed_encryption(&legacy_metadata(Some(DEFAULT_SSE_ALGORITHM), unknown), unknown),
Err(PersistedEncryptionError::UnknownLegacyEnvelope)
);
let local = local_envelope();
let mut conflicting = minio_metadata(false, &local);
conflicting.insert(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), BASE64_STANDARD.encode(b"different"));
assert_eq!(
classify_persisted_managed_encryption(&conflicting, &local),
Err(PersistedEncryptionError::ConflictingEncryptedDataKey)
);
let mut conflicting_key_id = minio_metadata(false, &local);
conflicting_key_id.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), "different-key".to_string());
assert_eq!(
classify_persisted_managed_encryption(&conflicting_key_id, &local),
Err(PersistedEncryptionError::ConflictingKmsKeyId)
);
assert_eq!(
classify_persisted_managed_encryption(&legacy_metadata(Some(SSE_KMS_ALGORITHM), &local), &local),
Err(PersistedEncryptionError::SchemeConflict)
);
}
#[test]
fn accepts_case_insensitive_minio_markers_and_rejects_conflicting_duplicates() {
let local = local_envelope();
let mut metadata = minio_metadata(false, &local);
let sealed_key = metadata
.remove(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)
.expect("MinIO fixture contains the sealed-key marker");
metadata.insert(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_ascii_lowercase(), sealed_key);
assert_eq!(
classify_persisted_managed_encryption(&metadata, &local).expect("classify mixed-case MinIO metadata"),
PersistedManagedEncryption::MinioSseS3Kms
);
metadata.insert(
MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(),
BASE64_STANDARD.encode([0x77u8; 64]),
);
assert_eq!(
classify_persisted_managed_encryption(&metadata, &local),
Err(PersistedEncryptionError::ConflictingValue {
field: MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER
})
);
}
}
+1
View File
@@ -642,6 +642,7 @@ mod tests {
stream: Box::new(Cursor::new(self.read_payload.clone())),
object_info: self.object_info(bucket, object, self.read_payload.len()),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}
+4
View File
@@ -2646,6 +2646,7 @@ mod tests {
stream: Box::new(Cursor::new(Vec::<u8>::new())),
object_info: ObjectInfo::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
};
@@ -2686,6 +2687,7 @@ mod tests {
stream: Box::new(Cursor::new(vec![1, 2, 3])),
object_info: ObjectInfo::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
};
@@ -2723,6 +2725,7 @@ mod tests {
stream: Box::new(Cursor::new(vec![1, 2, 3])),
object_info: ObjectInfo::default(),
buffered_body: Some(Bytes::from_static(b"123")),
resolved_sse: None,
body_source: Default::default(),
};
@@ -2760,6 +2763,7 @@ mod tests {
stream: Box::new(Cursor::new(vec![1, 2, 3])),
object_info: ObjectInfo::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
};
+169 -52
View File
@@ -7,10 +7,12 @@ use std::path::{Path, PathBuf};
mod storage_api;
use rustfs_filemeta::{FileInfo, FileInfoOpts, get_file_info};
use rustfs_utils::HashAlgorithm;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use storage_api::minio_generated_read::{
DiskAPI as _, DiskOption, Endpoint, Erasure, GetObjectReader, ObjectInfo, ObjectOptions, create_bitrot_reader, new_disk,
DiskAPI as _, DiskOption, Endpoint, Erasure, GetObjectReader, HTTPRangeSpec, ObjectInfo, ObjectOptions, create_bitrot_reader,
new_disk,
};
use temp_env::async_with_vars;
use tokio::io::{AsyncReadExt, AsyncWrite};
@@ -22,6 +24,11 @@ struct ManifestRecord {
backend_files: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RequestRecord {
headers: std::collections::HashMap<String, String>,
}
#[derive(Default)]
struct VecAsyncWriter {
bytes: Vec<u8>,
@@ -56,21 +63,17 @@ fn case_dir(case_id: &str) -> PathBuf {
fixture_root().join("cases").join(case_id)
}
fn beta5_fixture_root() -> PathBuf {
std::env::var_os("RUSTFS_BETA5_FIXTURE_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../rio-v2/tests/fixtures/rustfs-beta5-generated"))
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> T {
let text = fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
serde_json::from_str(&text).unwrap_or_else(|err| panic!("parse {}: {err}", path.display()))
}
fn require_fixture_case(case_id: &str) -> PathBuf {
let path = case_dir(case_id);
assert!(
path.is_dir(),
"fixture case missing: {}. Run scripts/minio_fixture_lab/lab.py capture-matrix first.",
path.display()
);
path
}
fn read_plaintext_sha256(case_dir: &Path) -> String {
fs::read_to_string(case_dir.join("plaintext.sha256"))
.unwrap_or_else(|err| panic!("read plaintext.sha256 under {}: {err}", case_dir.display()))
@@ -78,9 +81,9 @@ fn read_plaintext_sha256(case_dir: &Path) -> String {
.to_string()
}
fn minio_static_kms_key_b64() -> String {
std::env::var("RUSTFS_MINIO_STATIC_KMS_KEY_B64")
.unwrap_or_else(|_| panic!("RUSTFS_MINIO_STATIC_KMS_KEY_B64 must point to the 32-byte static MinIO KMS key"))
fn minio_static_kms_key() -> String {
std::env::var("RUSTFS_MINIO_STATIC_KMS_KEY")
.unwrap_or_else(|_| panic!("RUSTFS_MINIO_STATIC_KMS_KEY must use <key-id>:<base64-32-byte-key>"))
}
fn object_xl_meta_path(case_dir: &Path, manifest: &ManifestRecord) -> PathBuf {
@@ -117,37 +120,53 @@ fn sha256_hex(bytes: &[u8]) -> String {
hex_simd::encode_to_string(Sha256::digest(bytes), hex_simd::AsciiCase::Lower)
}
async fn load_fixture_reader_input(case_id: &str) -> (ObjectInfo, Vec<u8>, String) {
let case_dir = require_fixture_case(case_id);
async fn load_fixture_reader_input(case_id: &str) -> (ObjectInfo, Vec<u8>, String, http::HeaderMap) {
load_fixture_reader_input_from(case_dir(case_id)).await
}
async fn load_fixture_reader_input_from(case_dir: PathBuf) -> (ObjectInfo, Vec<u8>, String, http::HeaderMap) {
assert!(case_dir.is_dir(), "fixture case missing: {}", case_dir.display());
let manifest: ManifestRecord = read_json(&case_dir.join("manifest.json"));
let request: RequestRecord = read_json(&case_dir.join("request.json"));
let expected_sha256 = read_plaintext_sha256(&case_dir);
let file_info = load_file_info(&case_dir, &manifest);
let encrypted = encrypted_fixture_bytes(&case_dir, &manifest, &file_info).await;
let object_info = load_object_info(&file_info, &manifest);
(object_info, encrypted, expected_sha256)
let mut headers = http::HeaderMap::new();
for (name, value) in request.headers {
let name = http::header::HeaderName::from_bytes(name.as_bytes())
.unwrap_or_else(|err| panic!("invalid fixture request header {name}: {err}"));
let value =
http::HeaderValue::try_from(value).unwrap_or_else(|err| panic!("invalid fixture request header value: {err}"));
headers.insert(name, value);
}
(object_info, encrypted, expected_sha256, headers)
}
async fn read_fixture_plaintext(encrypted: Vec<u8>, object_info: ObjectInfo, kms_key_b64: String) -> Result<Vec<u8>, String> {
async fn read_fixture_plaintext(
encrypted: Vec<u8>,
object_info: ObjectInfo,
headers: http::HeaderMap,
static_kms_key: Option<String>,
range: Option<HTTPRangeSpec>,
) -> Result<Vec<u8>, String> {
let object_size = object_info.size;
let full_object = range.is_none();
async_with_vars(
[
("RUSTFS_SSE_S3_MASTER_KEY", Some(kms_key_b64)),
("RUSTFS_MINIO_STATIC_KMS_KEY", static_kms_key),
("__RUSTFS_SSE_SIMPLE_CMK", None::<String>),
],
async move {
let (mut reader, offset, length) = GetObjectReader::new(
Box::new(Cursor::new(encrypted)),
None,
&object_info,
&ObjectOptions::default(),
&http::HeaderMap::new(),
)
.await
.map_err(|err| format!("construct GetObjectReader from MinIO raw fixture: {err:?}"))?;
let (mut reader, offset, length) =
GetObjectReader::new(Box::new(Cursor::new(encrypted)), range, &object_info, &ObjectOptions::default(), &headers)
.await
.map_err(|err| format!("construct GetObjectReader from MinIO raw fixture: {err:?}"))?;
if offset != 0 || length != object_size {
if full_object && (offset != 0 || length != object_size) {
return Err(format!("unexpected fixture range offset={offset} length={length} size={object_size}"));
}
@@ -164,6 +183,12 @@ async fn read_fixture_plaintext(encrypted: Vec<u8>, object_info: ObjectInfo, kms
}
async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, file_info: &FileInfo) -> Vec<u8> {
let erasure = Erasure::new_with_options(
file_info.erasure.data_blocks,
file_info.erasure.parity_blocks,
file_info.erasure.block_size,
file_info.uses_legacy_checksum,
);
let mut disks = Vec::with_capacity(file_info.erasure.distribution.len());
for disk_number in 1..=file_info.erasure.distribution.len() {
let disk_root = case_dir.join("backend").join(format!("disk{disk_number}"));
@@ -198,8 +223,13 @@ async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, fil
let mut encrypted = Vec::new();
for part in &file_info.parts {
let checksum_info = file_info.erasure.get_checksum_info(part.number);
let checksum_algorithm = if file_info.uses_legacy_checksum && checksum_info.algorithm == HashAlgorithm::HighwayHash256S {
HashAlgorithm::HighwayHash256SLegacy
} else {
checksum_info.algorithm.clone()
};
let path = format!("{}/{}/part.{}", manifest.object, data_dir, part.number);
let shard_read_len = file_info.erasure.shard_file_size(part.size as i64);
let shard_read_len = erasure.shard_file_size(part.size as i64);
let mut readers = Vec::with_capacity(disks.len());
for (idx, disk) in disk_order.iter().enumerate() {
let reader = create_bitrot_reader(
@@ -210,7 +240,7 @@ async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, fil
0,
shard_read_len as usize,
file_info.erasure.shard_size(),
checksum_info.algorithm.clone(),
checksum_algorithm.clone(),
false,
false,
)
@@ -219,11 +249,6 @@ async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, fil
readers.push(reader);
}
let erasure = Erasure::new(
file_info.erasure.data_blocks,
file_info.erasure.parity_blocks,
file_info.erasure.block_size,
);
let mut writer = VecAsyncWriter::default();
let (written, err) = erasure.decode(&mut writer, readers, 0, part.size, part.size).await;
if let Some(err) = err {
@@ -244,6 +269,12 @@ async fn reads_minio_generated_sse_s3_multipart_fixture() {
assert_fixture_round_trip("sse-s3-multipart-8m", 8 * 1024 * 1024).await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_s3_singlepart_fixture() {
assert_fixture_round_trip("sse-s3-singlepart-64k", 64 * 1024).await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_kms_multipart_fixture() {
@@ -252,11 +283,47 @@ async fn reads_minio_generated_sse_kms_multipart_fixture() {
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn rejects_minio_generated_sse_s3_fixture_with_wrong_kms_key() {
let (object_info, encrypted, _) = load_fixture_reader_input("sse-s3-multipart-8m").await;
let wrong_key_b64 = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=".to_string();
async fn reads_minio_generated_sse_kms_singlepart_fixture() {
assert_fixture_round_trip("sse-kms-singlepart-64k", 64 * 1024).await;
}
let result = read_fixture_plaintext(encrypted, object_info, wrong_key_b64).await;
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_c_multipart_fixture() {
assert_fixture_round_trip("sse-c-multipart-8m", 8 * 1024 * 1024).await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_c_singlepart_fixture() {
assert_fixture_round_trip("sse-c-singlepart-64k", 64 * 1024).await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_s3_range_fixture() {
assert_fixture_range_round_trip("sse-s3-multipart-8m").await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_kms_range_fixture() {
assert_fixture_range_round_trip("sse-kms-multipart-8m").await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn reads_minio_generated_sse_c_range_fixture() {
assert_fixture_range_round_trip("sse-c-multipart-8m").await;
}
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn rejects_minio_generated_sse_s3_fixture_with_wrong_kms_key() {
let (object_info, encrypted, _, headers) = load_fixture_reader_input("sse-s3-multipart-8m").await;
let wrong_key_b64 = "minio-default-key:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=".to_string();
let result = read_fixture_plaintext(encrypted, object_info, headers, Some(wrong_key_b64), None).await;
assert!(result.is_err(), "wrong KMS key must fail closed");
}
@@ -264,22 +331,16 @@ async fn rejects_minio_generated_sse_s3_fixture_with_wrong_kms_key() {
#[tokio::test]
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
async fn rejects_minio_generated_sse_s3_fixture_with_truncated_ciphertext() {
let (object_info, mut encrypted, expected_sha256) = load_fixture_reader_input("sse-s3-multipart-8m").await;
let (object_info, mut encrypted, _, headers) = load_fixture_reader_input("sse-s3-multipart-8m").await;
encrypted.truncate(encrypted.len() / 2);
let result = read_fixture_plaintext(encrypted, object_info, minio_static_kms_key_b64()).await;
let result = read_fixture_plaintext(encrypted, object_info, headers, Some(minio_static_kms_key()), None).await;
if let Ok(plaintext) = result {
assert_ne!(
sha256_hex(&plaintext),
expected_sha256,
"truncated ciphertext must not restore the original plaintext"
);
}
assert!(result.is_err(), "truncated ciphertext must return an explicit read error");
}
async fn assert_fixture_round_trip(case_id: &str, expected_size: i64) {
let (object_info, encrypted, expected_sha256) = load_fixture_reader_input(case_id).await;
let (object_info, encrypted, expected_sha256, headers) = load_fixture_reader_input(case_id).await;
// `ObjectInfo.size` is the on-disk size. For SSE objects that is the
// DARE-encrypted size (plaintext + 32 bytes per 64 KiB block), which is
// deliberately larger than the logical object size. The size a client sees
@@ -287,9 +348,9 @@ async fn assert_fixture_round_trip(case_id: &str, expected_size: i64) {
// `decrypted_size()`/`get_actual_size()`, so assert against that — the raw
// `size` field would never equal the plaintext length for encrypted objects.
let decrypted_size = object_info.decrypted_size().expect("decrypted size from MinIO metadata");
let kms_key_b64 = minio_static_kms_key_b64();
let kms_key = minio_static_kms_key();
let plaintext = read_fixture_plaintext(encrypted, object_info, kms_key_b64)
let plaintext = read_fixture_plaintext(encrypted, object_info, headers, Some(kms_key), None)
.await
.expect("fixture must restore with the configured KMS key");
@@ -297,3 +358,59 @@ async fn assert_fixture_round_trip(case_id: &str, expected_size: i64) {
assert_eq!(plaintext.len(), expected_size as usize);
assert_eq!(sha256_hex(&plaintext), expected_sha256);
}
async fn assert_fixture_range_round_trip(case_id: &str) {
const START: usize = 65_520;
const END: usize = 65_680;
let (object_info, encrypted, _, headers) = load_fixture_reader_input(case_id).await;
let kms_key = minio_static_kms_key();
let plaintext = read_fixture_plaintext(encrypted.clone(), object_info.clone(), headers.clone(), Some(kms_key.clone()), None)
.await
.expect("fixture full read must restore");
let ranged = read_fixture_plaintext(
encrypted,
object_info,
headers,
Some(kms_key),
Some(HTTPRangeSpec {
is_suffix_length: false,
start: START as i64,
end: END as i64,
}),
)
.await
.expect("fixture range read must restore");
assert_eq!(ranged, plaintext[START..=END]);
}
#[tokio::test]
#[ignore = "requires a fixture generated by the pinned RustFS beta.5 release"]
async fn reads_real_rustfs_beta5_sse_kms_fixture_through_production_reader() {
const CASE_ID: &str = "rustfs-beta5-sse-kms-singlepart-64k";
const KMS_KEY_ID: &str = "beta5-test-key";
let root = beta5_fixture_root();
let manager = rustfs_kms::init_global_kms_service_manager();
manager
.configure(
rustfs_kms::KmsConfig::local(root.join("kms"))
.with_default_key(KMS_KEY_ID.to_string())
.with_insecure_development_defaults(),
)
.await
.expect("configure production local KMS for beta.5 fixture");
manager.start().await.expect("start production local KMS for beta.5 fixture");
let (object_info, encrypted, expected_sha256, headers) =
load_fixture_reader_input_from(root.join("cases").join(CASE_ID)).await;
let expected_size = object_info.decrypted_size().expect("beta.5 encrypted object size");
let plaintext = read_fixture_plaintext(encrypted, object_info, headers, None, None)
.await
.expect("current production GET reader must decrypt the real beta.5 KMS fixture");
assert_eq!(expected_size, 64 * 1024);
assert_eq!(plaintext.len() as i64, expected_size);
assert_eq!(sha256_hex(&plaintext), expected_sha256);
}
+1
View File
@@ -36,6 +36,7 @@ pub(crate) mod legacy_bitrot_read {
}
pub(crate) mod minio_generated_read {
pub(crate) use super::storage_contracts::HTTPRangeSpec;
pub(crate) use super::{
DiskAPI, DiskOption, Endpoint, Erasure, GetObjectReader, ObjectInfo, ObjectOptions, create_bitrot_reader, new_disk,
};
@@ -136,7 +136,7 @@ tests read):
# Pass case ids to override, or "all" for the full default matrix.
./capture_via_docker.sh
RUSTFS_MINIO_STATIC_KMS_KEY_B64=IyqsU3kMFloCNup4BsZtf/rmfHVcTgznO2F25CkEH1g= \
RUSTFS_MINIO_STATIC_KMS_KEY=minio-default-key:IyqsU3kMFloCNup4BsZtf/rmfHVcTgznO2F25CkEH1g= \
cargo test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored
```
@@ -145,6 +145,28 @@ This is exactly what the nightly `minio-interop` GitHub Actions workflow runs
SSE-C cases still need the host-`minio` + TLS path above; the Docker helper
targets the SSE-S3 / SSE-KMS multipart cases the interop tests assert on.
## RustFS beta.5 KMS Compatibility Fixture
The same CI gate also downloads the pinned RustFS `1.0.0-beta.5` release,
verifies its published archive SHA-256, starts it with the production local KMS
backend, writes a real SSE-KMS object, and exports the resulting four-disk
backend plus its one-time KMS key directory:
```bash
uv run python ./capture_rustfs_beta5.py
RUSTFS_BETA5_FIXTURE_ROOT=../fixtures/rustfs-beta5-generated \
cargo test -p rustfs-ecstore --features rio-v2 \
--test minio_generated_read_test \
reads_real_rustfs_beta5_sse_kms_fixture_through_production_reader \
-- --ignored
```
Outside Linux x86_64, pass `--rustfs-binary` with the matching beta.5 binary.
The generated fixture and KMS key stay under the ignored fixture root and are
recreated for every CI run; neither key material nor plaintext keys are logged
or committed.
## Capture Guidance
For each case, preserve these inputs when possible:
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import platform
import secrets
import shutil
import stat
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from lab import (
FixtureCase,
LabPaths,
S3Client,
build_payload_file,
head_case,
stop_process,
store_case_artifacts,
upload_case,
wait_for_s3_ready,
)
RELEASE = "1.0.0-beta.5"
CASE_ID = "rustfs-beta5-sse-kms-singlepart-64k"
KMS_KEY_ID = "beta5-test-key"
LINUX_X86_64_ASSET = "rustfs-linux-x86_64-gnu-v1.0.0-beta.5.zip"
LINUX_X86_64_SHA256 = "73529e732adc3c2c5c78c7f2c2e4e331a20e5bee1e2db341ee7c762299e4b327"
DEFAULT_ROOT = Path(__file__).resolve().parents[1] / "fixtures" / "rustfs-beta5-generated"
def release_asset_url(asset: str) -> str:
return f"https://github.com/rustfs/rustfs/releases/download/{RELEASE}/{asset}"
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def resolve_binary(explicit_binary: Path | None, workdir: Path) -> Path:
if explicit_binary is not None:
binary = explicit_binary.resolve()
if not binary.is_file():
raise FileNotFoundError(f"RustFS beta.5 binary not found: {binary}")
return binary
if platform.system() != "Linux" or platform.machine() not in {"x86_64", "AMD64"}:
raise RuntimeError("--rustfs-binary is required outside Linux x86_64")
archive = workdir / LINUX_X86_64_ASSET
urllib.request.urlretrieve(release_asset_url(LINUX_X86_64_ASSET), archive)
actual = sha256_file(archive)
if actual != LINUX_X86_64_SHA256:
raise RuntimeError(f"RustFS beta.5 archive SHA-256 mismatch: expected {LINUX_X86_64_SHA256}, got {actual}")
with zipfile.ZipFile(archive) as bundle:
bundle.extract("rustfs", workdir)
binary = workdir / "rustfs"
binary.chmod(binary.stat().st_mode | stat.S_IXUSR)
return binary
def write_local_kms_key(key_dir: Path) -> None:
key_dir.mkdir(parents=True, mode=0o700)
key_path = key_dir / f"{KMS_KEY_ID}.key"
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
payload = {
"key_id": KMS_KEY_ID,
"version": 1,
"algorithm": "AES_256",
"usage": "EncryptDecrypt",
"status": "Active",
"description": None,
"metadata": {},
"created_at": now,
"rotated_at": None,
"created_by": "fixture-lab",
"encrypted_key_material": base64.b64encode(secrets.token_bytes(32)).decode("ascii"),
"nonce": [],
}
key_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
key_path.chmod(0o600)
def wait_for_process_s3(client: S3Client, process: subprocess.Popen[str], timeout_seconds: int) -> None:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if process.poll() is not None:
raise RuntimeError("RustFS beta.5 exited before its S3 API became ready")
try:
client.list_buckets()
return
except (RuntimeError, urllib.error.URLError):
time.sleep(1)
wait_for_s3_ready(client, 1)
def capture(args: argparse.Namespace) -> Path:
root = args.root.resolve()
if root.exists():
shutil.rmtree(root)
root.mkdir(parents=True)
with tempfile.TemporaryDirectory(prefix="rustfs-beta5-fixture-") as temporary:
workdir = Path(temporary)
binary = resolve_binary(args.rustfs_binary, workdir)
key_dir = workdir / "kms"
write_local_kms_key(key_dir)
disks = [workdir / f"disk{index}" for index in range(1, 5)]
for disk in disks:
disk.mkdir()
endpoint = args.endpoint
parsed = urlparse(endpoint)
if parsed.scheme != "http" or not parsed.netloc:
raise ValueError("beta.5 fixture endpoint must be an http:// URL")
command = [
str(binary),
"server",
"--address",
parsed.netloc,
"--access-key",
"minioadmin",
"--secret-key",
"minioadmin",
"--kms-enable",
"--kms-backend",
"local",
"--kms-key-dir",
str(key_dir),
"--kms-default-key-id",
KMS_KEY_ID,
*(str(disk) for disk in disks),
]
environment = os.environ.copy()
environment["RUSTFS_UNSAFE_BYPASS_DISK_CHECK"] = "true"
server_log_path = workdir / "server.log"
case = FixtureCase(
case_id=CASE_ID,
bucket="demo",
object_name="dir/object.bin",
encryption="SSE-KMS",
size_bytes=64 * 1024,
multipart=False,
kms_key_id=KMS_KEY_ID,
)
payload_file = workdir / "payload.bin"
plaintext_sha256 = build_payload_file(payload_file, case.size_bytes)
with server_log_path.open("w", encoding="utf-8") as server_log:
process = subprocess.Popen(
command,
cwd=str(workdir),
env=environment,
stdout=server_log,
stderr=subprocess.STDOUT,
text=True,
)
try:
client = S3Client(endpoint)
wait_for_process_s3(client, process, args.timeout_seconds)
client.create_bucket(case.bucket)
request_payload = upload_case(client, case, payload_file)
head_payload = head_case(client, case)
finally:
stop_process(process)
export_root = workdir / "export"
export_root.mkdir()
for index, disk in enumerate(disks, start=1):
shutil.copytree(disk, export_root / f"disk{index}")
shutil.copytree(key_dir, root / "kms")
return store_case_artifacts(
paths=LabPaths(root=root, cases=root / "cases"),
case_id=case.case_id,
bucket=case.bucket,
object_name=case.object_name,
source_tree=export_root,
version_id=head_payload.get("VersionId"),
request_payload=request_payload,
head_payload=head_payload,
plaintext_sha256=plaintext_sha256,
notes="Captured from the pinned RustFS 1.0.0-beta.5 release with the production local KMS backend.",
capture_payload={
"release": RELEASE,
"binary_sha256": sha256_file(binary),
"disk_count": len(disks),
"kms_key_id": KMS_KEY_ID,
},
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Capture a real RustFS beta.5 SSE-KMS fixture")
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
parser.add_argument("--rustfs-binary", type=Path)
parser.add_argument("--endpoint", default="http://127.0.0.1:19011")
parser.add_argument("--timeout-seconds", type=int, default=90)
return parser
def main() -> int:
case_dir = capture(build_parser().parse_args())
print(case_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -2,6 +2,7 @@ from __future__ import annotations
import importlib.util
import base64
import json
import sys
import tempfile
import unittest
@@ -21,6 +22,21 @@ def load_lab_module():
lab = load_lab_module()
sys.modules["lab"] = lab
def load_beta5_module():
module_path = Path(__file__).with_name("capture_rustfs_beta5.py")
spec = importlib.util.spec_from_file_location("rustfs_beta5_fixture_capture", module_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
beta5 = load_beta5_module()
class DiscoverMinioLauncherTests(unittest.TestCase):
@@ -203,5 +219,31 @@ class KmsSecretKeyTests(unittest.TestCase):
)
class RustfsBeta5CaptureTests(unittest.TestCase):
def test_explicit_beta5_binary_is_used_without_download(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
binary = Path(temp_dir) / "rustfs"
binary.write_bytes(b"beta5")
resolved = beta5.resolve_binary(binary, Path(temp_dir))
self.assertEqual(resolved, binary.resolve())
def test_local_kms_key_has_beta5_compatible_shape_and_permissions(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
key_dir = Path(temp_dir) / "kms"
beta5.write_local_kms_key(key_dir)
key_path = key_dir / f"{beta5.KMS_KEY_ID}.key"
payload = json.loads(key_path.read_text(encoding="utf-8"))
self.assertEqual(payload["key_id"], beta5.KMS_KEY_ID)
self.assertEqual(payload["algorithm"], "AES_256")
self.assertEqual(payload["usage"], "EncryptDecrypt")
self.assertEqual(len(base64.b64decode(payload["encrypted_key_material"])), 32)
self.assertEqual(payload["nonce"], [])
self.assertEqual(key_path.stat().st_mode & 0o777, 0o600)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -2514,6 +2514,7 @@ mod tests {
stream: Box::new(Cursor::new(data)),
object_info: ObjectInfo::default(),
buffered_body: None,
resolved_sse: None,
body_source: Default::default(),
})
}
+1
View File
@@ -30,6 +30,7 @@ Two rules keep this directory healthy:
## Contracts & invariants
- [erasure-coding.md](erasure-coding.md) — normative erasure-coding algorithm and on-disk (`xl.meta`) compatibility contract; the frozen invariants for all user-data read/write, encode/decode, quorum, heal, and decode tolerance
- [minio-sse-key-hierarchy.md](minio-sse-key-hierarchy.md) — MinIO-compatible SSE key hierarchy, wrapping algorithms, DARE 2.0 stream format, metadata persistence, and RustFS layer boundaries
- [placement-repair-invariants.md](placement-repair-invariants.md)
- [unified-object-generation.md](unified-object-generation.md) — single per-object generation authority (fencing epoch, transport/encoding/proto/mixed-version contracts)
- [runtime-capability-contracts.md](runtime-capability-contracts.md)
@@ -0,0 +1,983 @@
# MinIO SSE Key Hierarchy & RustFS Compatibility Contract
This document is the core reference for RustFS implementation and review of
MinIO-compatible Server-Side Encryption (SSE). It defines key terminology,
derivation relationships, inter-layer responsibilities, persistence formats,
data encryption formats for single-part and multipart objects, and the
compatibility behavior required when reading MinIO objects.
This document describes a long-term architecture contract, not a migration
plan for a specific implementation phase. The historical RustFS direct-stream
key format belongs to the read-compatibility surface and does not alter the
MinIO compatibility target specified herein.
## 1. Basis and Scope
This document takes the following source snapshots as its factual baseline:
- MinIO commit
[`7aac2a2c5b7c882e68c1ce017d8256be2feea27f`](https://github.com/minio/minio/tree/7aac2a2c5b7c882e68c1ce017d8256be2feea27f):
`cmd/encryption-v1.go`, `internal/crypto/key.go`,
`internal/crypto/metadata.go`, `internal/crypto/sse-*.go`.
- RustFS commit `5ea9a1fd8f3c1b39154b27fd419cde76abc68f3b`:
[SSE boundary](../../rustfs/src/storage/sse.rs),
[RIO v2 encryption stream](../../crates/rio-v2/src/encrypt_reader.rs), and
[MinIO generated fixtures](../../crates/rio-v2/tests/minio_generated_fixtures.rs).
- MinIO `sio v0.4.1` DARE 2.0 package format; the RustFS compatible
implementation is fixed in
[RIO v2 encryption stream](../../crates/rio-v2/src/encrypt_reader.rs).
This document covers:
- SSE-C, SSE-S3, and SSE-KMS;
- KMS envelope encryption, object key derivation, and wrapping;
- The exact meanings of `ObjectKey`, `sealingKey`, and `SealedKey`;
- The SIO/RIO DARE 2.0 data stream format;
- Multipart part keys;
- MinIO internal object metadata;
- RustFS write, read, and historical compatibility boundaries.
This document does not specify how a KMS service internally protects its KMS
master key, nor does it replace the S3 public request header and error code
API compatibility specification.
## 2. Normative Language
The terms "MUST", "MUST NOT", "SHOULD", and "MAY" in this document carry
their RFC 2119 meanings. If an implementation conflicts with this document,
the behavior that can read real MinIO objects without degrading the security
boundary takes precedence, and this document SHALL be revised accordingly.
## 3. Terminology: Named by Cryptographic Role
`key` and `data key` often refer to different roles in KMS APIs versus the
object encryption layer. Implementations and reviews MUST use the role names
in the table below as their primary reference and MUST NOT infer purpose from
variable names alone.
| Canonical Term | MinIO / RustFS Source Name | Length | Lifetime | Cryptographic Role |
|---|---|---|---|---|
| KMS master key | KMS key / master key | KMS-defined | KMS-internal only | Root key; protects the KMS data key |
| KMS data key plaintext | MinIO `key.Plaintext`; RustFS `DataKey.plaintext_key` | 32 B | Transient, in-process only | Parent/external key of the object envelope; NOT the `ObjectKey` that directly encrypts object content |
| KMS data key ciphertext | MinIO `key.Ciphertext`; RustFS `encrypted_data_key` | KMS-defined | Persisted | KMS data key plaintext wrapped under the KMS master key |
| Local SSE-S3 master key | RustFS local SSE-S3 provider master key | 32 B | Process-lifetime secret | Parent/external key for local/K/V SSE-S3; MUST NOT be persisted in object metadata |
| SSE-C customer key | SSE-C key from the client request | 32 B | In-memory for the request lifetime | Parent/external key for SSE-C |
| object-key generation nonce | Random value inside `crypto.GenerateKey` | 32 B | Discarded after derivation | Ensures distinct `ObjectKey` values under the same parent key |
| **content-encryption DEK / CEK** | **MinIO `ObjectKey`; RustFS object-key mode `key_bytes`** | **32 B** | **In-memory only** | **The key that actually encrypts object content; derives part keys for multipart** |
| key-wrapping IV | MinIO `SealedKey.IV` / `MetaIV`; RustFS `ManagedSealedKey.iv` | 32 B | Persisted | Contributes to deriving the per-object KEK |
| **per-object KEK** | **MinIO local variable `sealingKey`; RustFS `sealing_key`** | **32 B** | **Derived transiently, never persisted** | **Wraps and unwraps `ObjectKey`** |
| wrapped ObjectKey / wrapped content DEK | MinIO `SealedKey.Key`; Base64-decoded value of the corresponding metadata field | 64 B | Persisted | DARE package of `ObjectKey` AEAD-encrypted under the per-object KEK; new writes are DARE 2.0, legacy SSE-C may be DARE 1.0 |
| `SealedKey` logical record | MinIO `SealedKey { Key, IV, Algorithm }`; RustFS `ManagedSealedKey` plus algorithm constant | 64 B + 32 B + algorithm name | Persisted as multiple metadata fields | The complete ObjectKey wrapping record; NOT an object content encryption key |
| DARE stream nonce | Nonce material in the SIO/RIO DARE header | 12 B | Carried in every package header | Combined with the package sequence number to form the AEAD nonce; independent of the key-wrapping IV |
| multipart part key | MinIO `DerivePartKey`; RustFS `derive_part_key` | 32 B | In-memory only | Content-encryption DEK for each part |
### 3.1 Ambiguities That MUST Be Avoided
1. `SealedKey` is NOT "the key that participates in object content
encryption"; it is the logical record of a wrapped `ObjectKey` and its
unwrapping parameters.
2. `sealingKey` IS the per-object KEK; it is not an additional key layer
beyond the KEK.
3. KMS calls its generation output a data key, but in MinIO's object
encryption layer that plaintext data key serves as the parent/external
key; the key that actually encrypts object content is the subsequently
derived `ObjectKey`.
4. `SealedKey.IV` is a 32 B key-wrapping IV; the DARE data stream nonce is
12 B. The two MUST NOT be reused, stored interchangeably, or derived from
each other.
## 4. Overall Key Hierarchy
```mermaid
flowchart TB
subgraph ROOT["Upstream Key Sources"]
KMSMK["KMS master key<br/>root KEK; KMS-internal only"]
KMSGEN["KMS GenerateKey<br/>carrying KMS AssociatedData"]
KMSPT["KMS data key plaintext<br/>32 B; parent/external key; in-memory only"]
KMSCT["KMS data key ciphertext<br/>KMS-wrapped; persisted"]
SSES3LOCAL["Local SSE-S3 master key<br/>32 B; server-managed parent key"]
SSECK["SSE-C customer key<br/>32 B; parent/external key; MUST NOT be persisted"]
KMSMK --> KMSGEN
KMSGEN --> KMSPT
KMSGEN --> KMSCT
end
subgraph SSE["SSE Layer: Key Hierarchy and Metadata Semantics"]
PARENT["parent/external key"]
OGN["object-key generation nonce<br/>random 32 B; discarded after derivation"]
OKEY["ObjectKey<br/>content-encryption DEK / CEK<br/>32 B; in-memory only"]
WIV["key-wrapping IV<br/>random 32 B; persisted"]
BIND["domain + algorithm + bucket/object"]
KEK["sealingKey<br/>per-object KEK<br/>32 B; never persisted"]
SEALED["SealedKey.Key<br/>wrapped ObjectKey<br/>64 B DARE 2.0 package; persisted"]
PARENT -->|"HMAC-SHA256"| OKEY
OGN -->|"context ∥ nonce"| OKEY
PARENT -->|"HMAC-SHA256"| KEK
WIV --> KEK
BIND --> KEK
KEK -->|"AEAD key"| SEALED
OKEY -->|"32 B plaintext"| SEALED
end
subgraph RIO["SIO / RIO Layer: Object Data Stream"]
PLAIN["Object plaintext"]
PART["multipart only<br/>partKey = HMAC-SHA256(ObjectKey, LE32(partNumber))"]
DARE["DARE 2.0 AEAD packages<br/>AES-256-GCM / ChaCha20-Poly1305"]
CIPHER["Object ciphertext stream"]
OKEY -->|"single-part key"| DARE
OKEY --> PART
PART -->|"multipart part key"| DARE
PLAIN --> DARE
DARE --> CIPHER
end
KMSPT --> PARENT
SSES3LOCAL --> PARENT
SSECK --> PARENT
subgraph STORE["Persistence Boundary"]
META["Object SSE metadata<br/>wrapped ObjectKey + wrapping IV + algorithm<br/>+ KMS key ID/ciphertext/context"]
BODY["Object data region<br/>DARE 2.0 ciphertext stream"]
FORBID["Forbidden to persist or log<br/>KMS data key plaintext<br/>SSE-C customer key<br/>ObjectKey / part key<br/>sealingKey"]
end
KMSCT --> META
WIV --> META
SEALED --> META
CIPHER --> BODY
```
The minimal equivalence relationships are:
```text
ObjectKey = content-encryption DEK / CEK
sealingKey = per-object KEK
SealedKey.Key = Wrap(sealingKey, ObjectKey) = wrapped content DEK
```
## 5. Precise Key Transformations
The concatenation operator `||` below denotes byte-string concatenation, and
integers use little-endian encoding.
### 5.1 Origin of the Parent/External Key
| SSE Mode / Provider | Parent/External Key | Upstream Action |
|---|---|---|
| SSE-C | 32 B customer key, client-provided and verified | No KMS call; customer key MUST NOT be persisted |
| SSE-S3 local/K/V | 32 B local SSE-S3 master key | No KMS call; the local parent key seals the per-object `ObjectKey` |
| SSE-S3 KMS compatibility | KMS data key plaintext generated by KMS/provider | Calls `GenerateKey` with the default service-side key ID and object context |
| SSE-KMS | KMS data key plaintext generated by KMS | Calls `GenerateKey` with the requested or default KMS key ID and KMS encryption context |
Provider choice and object write format are independent. RustFS SSE-S3 writes
MUST use the local SSE-S3 provider even when the MinIO ObjectKey write format
is enabled; configuring KMS MUST NOT change SSE-S3 write routing. SSE-KMS
writes MUST use KMS and MUST NOT fall back to the local SSE-S3 provider.
KMS-backed SSE-S3 compatibility objects and SSE-KMS objects MUST preserve
both the KMS key ID and KMS data key ciphertext so that the read path can
recover the same parent/external key via KMS. Local/K/V SSE-S3 ObjectKey
objects omit both fields simultaneously and seal `ObjectKey` directly under
the local SSE-S3 parent key. Having only one KMS field present, or either
present field empty, is corrupt metadata and MUST be rejected.
KMS AssociatedData MUST exactly reproduce MinIO:
```text
objectPath = path.Join(bucket, object)
SSE-S3 KMS compatibility:
kmsContext = { bucket: objectPath }
SSE-KMS:
storedContext = exact copy of the client-provided context
kmsContext = copy(storedContext)
if bucket is absent from kmsContext:
kmsContext[bucket] = objectPath
```
For SSE-KMS, if the client has explicitly provided an entry whose key equals
the current bucket name, MinIO preserves that value and does not overwrite
it; reads MUST reconstruct the same rule using the persisted `storedContext`.
The augmented `kmsContext` used for the KMS call MUST NOT overwrite the
client context that SHALL be persisted as-is. Copy, rename, and rewrap MUST
reproduce the same context rules for the corresponding source/target
operations and MUST NOT transparently forward a temporary map that lacks the
required object binding.
### 5.2 ObjectKey: Object Content DEK
MinIO's computation is:
```text
objectKeyGenerationNonce = CSPRNG(32)
ObjectKey = HMAC-SHA256(
key = parentExternalKey,
data = UTF8("object-encryption-key generation")
|| objectKeyGenerationNonce
)
```
Constraints:
- `ObjectKey` MUST be 32 B.
- `objectKeyGenerationNonce` is used for exactly one derivation and is not
persisted.
- `ObjectKey` does not include bucket/object; path binding occurs in the
per-object KEK derivation.
- `ObjectKey` MUST NOT appear in object metadata, logs, error text, or debug
output.
### 5.3 sealingKey: Per-Object KEK
```text
keyWrappingIV = CSPRNG(32)
sealingKey = HMAC-SHA256(
key = parentExternalKey,
data = keyWrappingIV
|| UTF8(domain)
|| UTF8("DAREv2-HMAC-SHA256")
|| UTF8(canonicalBucketObjectPath)
)
```
`domain` MUST exactly match the SSE mode:
| SSE Mode | domain |
|---|---|
| SSE-C | `SSE-C` |
| SSE-S3 | `SSE-S3` |
| SSE-KMS | `SSE-KMS` |
`canonicalBucketObjectPath` MUST be consistent with MinIO's
`path.Join(bucket, object)` semantics. Any change to the path, domain,
algorithm identifier, or IV will cause unwrapping authentication failure.
Therefore, copying or renaming an object cannot simply carry over the old
`SealedKey`; it MUST re-seal against the target path, or perform a
semantically equivalent key rotation.
This canonicalization is part of the MinIO sealed-key format and SHALL only
be reproduced exactly within the sealing compatibility boundary; general
path-cleaning functions that resolve `.` or `..` MUST NOT be applied to
ordinary S3 object key paths based on this.
For KMS-backed SSE-S3 compatibility objects and SSE-KMS, a target path change
may also change the KMS AssociatedData. In that case, merely re-sealing
`ObjectKey` while keeping the old KMS data key ciphertext is insufficient;
the parent data key MUST be generated or rewrapped under the target effective
KMS context, and that parent key MUST then be used to produce a new
`SealedKey` for the target path. Only when the client's explicit SSE-KMS
context makes the source and target effective AssociatedData identical may
the corresponding KMS envelope be reused after KMS semantics have been
verified. MinIO's normal key rotation path generates a new KMS data key and
then re-seals `ObjectKey`.
### 5.4 SealedKey: Wrapped ObjectKey
```text
SealedKey.Key = DAREv2-AEAD-Encrypt(
key = sealingKey,
plaintext = ObjectKey,
aad = DARE header[0:4],
nonce = DARE package nonce
)
```
The 32 B `ObjectKey` is encoded as a single DARE 2.0 package:
```text
16 B DARE header
+ 32 B ciphertext
+ 16 B AEAD tag
= 64 B SealedKey.Key
```
The MinIO writer may write AES-256-GCM or ChaCha20-Poly1305 depending on
`sio` cipher-suite selection. Compatible readers MUST accept both valid
cipher IDs:
| Cipher Suite | DARE Cipher ID |
|---|---:|
| AES-256-GCM | `0x00` |
| ChaCha20-Poly1305 | `0x01` |
The complete logical record also includes:
```text
SealedKey {
Key: [u8; 64], // wrapped ObjectKey package
IV: [u8; 32], // keyWrappingIV
Algorithm: "DAREv2-HMAC-SHA256"
}
```
The three components are persisted separately in object metadata, not
serialized as a single structure.
### 5.5 Legacy MinIO SSE-C Seal
The MinIO reader also accepts the historical SSE-C algorithm `DARE-SHA256`:
```text
legacySealingKey = SHA256(parentExternalKey || keyWrappingIV)
ObjectKey = sio.Decrypt(
key = legacySealingKey,
minVersion = DARE 1.0,
input = SealedKey.Key
)
```
This format has no domain or bucket/object binding and is permitted only as
a read-only compatibility path for historical objects:
- MinIO sets `MinVersion = DARE 1.0`, so this algorithm branch may read
DARE 1.0 or DARE 2.0 packages produced with the legacy key derivation; it
MUST NOT be implemented as "header must be v1";
- New writes, copy targets, and rewraps MUST NOT produce `DARE-SHA256`;
- SSE-S3 and SSE-KMS MUST NOT accept this algorithm;
- Legacy identification MUST be conditioned on the SSE-C metadata shape and
MUST NOT become a downgrade channel for arbitrarily corrupted v2 metadata;
- After a successful read, if a rewrite occurs, the object SHOULD be
upgraded to the current `DAREv2-HMAC-SHA256` format.
## 6. DARE 2.0 Format for Object Data
The SSE layer hands the plaintext `ObjectKey` to SIO/RIO. SIO/RIO is not
responsible for:
- Calling KMS;
- Parsing SSE-C request headers;
- Generating or unwrapping `SealedKey`;
- Interpreting SSE-S3, SSE-KMS, bucket/object, or KMS context;
- Deciding SSE metadata fields.
SIO/RIO is only responsible for encrypting and decrypting the DARE data
stream using the supplied content DEK.
### 6.1 Physical Format of Each Data Package
```text
DARE 2.0 package
┌──────────────────┬──────────────────────┬──────────────────┐
│ 16-byte header │ 1..65536 B ciphertext│ 16-byte AEAD tag │
└──────────────────┴──────────────────────┴──────────────────┘
```
Header:
| Byte | Field | Meaning |
|---:|---|---|
| `0` | version | `0x20`, DARE 2.0 |
| `1` | cipher suite | `0x00` AES-256-GCM; `0x01` ChaCha20-Poly1305 |
| `23` (`[2:4]`) | payload length minus one | `LE16(plaintextLength - 1)` |
| `415` (`[4:16]`) | stream nonce material | 12 B; the high bit also carries the final-package flag |
A zero-length plaintext is a special valid case: its ciphertext stream is
also zero bytes and produces no DARE packages, hence no final-package flag.
Only after at least one package has been observed and EOF is reached MUST
the last package be required to carry the final flag; an empty stream MUST
NOT be misclassified as truncated.
For package sequence number `sequenceNumber`:
```text
packageNonce = header[4:16]
packageNonce[8:12] ^= LE32(sequenceNumber)
ciphertext || tag = AEAD-Encrypt(
key = ObjectKey or partKey,
nonce = packageNonce,
plaintext = packagePlaintext,
aad = header[0:4]
)
```
Constraints:
- The first package of a given DARE stream has sequence number `0`,
monotonically increasing thereafter.
- The final-package flag MUST be covered by AEAD authentication.
- The reader MUST verify the complete AEAD tag before releasing that
package's plaintext to the upper layer.
- The header nonce is the DARE stream nonce, NOT `SealedKey.IV`.
- Maximum plaintext per package is 64 KiB; each package adds a fixed 32 B
framing overhead.
### 6.2 Multipart Part Key
Multipart objects do not directly use `ObjectKey` to encrypt all parts.
Each part first derives:
```text
partKey = HMAC-SHA256(
key = ObjectKey,
data = LE32(partNumber)
)
```
Each part is then an independent DARE stream:
- Uses the corresponding `partKey`;
- The MinIO writer uses
`SHA256(fmt.Append(nil, uploadID, partNumber))[0:12]` as the stream
nonce; here `fmt.Append` is Go textual concatenation of upload ID and part
number;
- Sequence number restarts from `0`;
- Part boundaries MUST be accurately recovered from the actual/encrypted
part sizes in metadata.
Decimal strings, network byte order, or zero-based part indices MUST NOT
replace `LE32(partNumber)`.
The part number in the partKey derivation is `LE32`, but the part number in
the MinIO stream nonce input is the Go textual representation; the two
encodings differ. The DARE header carries its own nonce, so readers verify
against the header for interoperability. MinIO's deterministic nonce is a
recognized historical write behavior, not a requirement for new RustFS
writes: re-uploading different plaintext under the same upload ID and part
number would reuse the same AEAD key/nonce with that same partKey. New
RustFS writes MUST generate a random, non-repeating stream nonce for each
part write; this is DARE-semantically interoperable with the MinIO reader
but does not target byte-level identical output with the MinIO writer.
### 6.3 Derivation Keys for ETag and Other Internal Object Metadata
In addition to encrypting object content, `ObjectKey` also serves as the
root for several internal metadata sub-keys. This logic belongs at the
SSE/object-metadata boundary, not in RIO's `SealedKey` management.
MinIO's derivation for a non-empty ETag:
```text
etagKey = HMAC-SHA256(ObjectKey, UTF8("SSE-etag"))
sealedETag = DARE-Encrypt(etagKey, etag)
```
A typical 16 B MD5 ETag becomes a `16 B header + 16 B ciphertext + 16 B tag
= 48 B` sealed ETag. An empty ETag remains empty; on read, a 16 B backend
ETag is treated as an unsealed plain ETag and no decryption is performed.
The ETag visible to S3 clients MUST be correctly unsealed/formatted and MUST
NOT directly expose backend sealed bytes.
MinIO's general-purpose internal metadata encrypter uses:
```text
metadataKey = HMAC-SHA256(ObjectKey, UTF8(baseKey))
sealedValue = DARE-Encrypt(metadataKey, value)
```
For example, the compression index uses an independent `baseKey`. Each
`baseKey` is a key-domain label and MUST exactly match MinIO; empty values
remain empty. Implementations MUST NOT directly reuse `ObjectKey` with the
same AEAD nonce to encrypt these metadata values.
## 7. MinIO Internal Metadata Persistence Contract
These fields are internal object metadata; not all of them should be
returned to S3 clients. Binary values are standard Base64 encoded.
RustFS writes of internal object metadata MUST additionally observe the
repository's twin-key rule: write the same semantic value under both
`x-rustfs-internal-<suffix>` and `x-minio-internal-<suffix>`, and on read
prefer the RustFS key while remaining compatible with objects that have only
the MinIO key. The table below lists the MinIO keys required for MinIO
interoperability; the RustFS twin key does not replace them. Write, delete,
and rotate operations MUST handle both keys simultaneously to prevent stale
values from "resurrecting" on the next read.
### 7.1 Common Fields
| MinIO Metadata Key | Value | Required When |
|---|---|---|
| `X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm` | `DAREv2-HMAC-SHA256` | MinIO-style sealed ObjectKey |
| `X-Minio-Internal-Server-Side-Encryption-Iv` | Base64(32 B key-wrapping IV) | MinIO-style sealed ObjectKey |
| `X-Minio-Internal-Encrypted-Multipart` | multipart marker | Encrypted multipart object |
### 7.2 Per-SSE-Mode Fields
| Mode | Wrapped ObjectKey Field | KMS Key ID | KMS Data Key Ciphertext | KMS Context |
|---|---|---|---|---|
| SSE-C | `X-Minio-Internal-Server-Side-Encryption-Sealed-Key` | Not stored | Not stored | Not stored |
| SSE-S3 local/K/V | `X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key` | Not stored | Not stored | Not stored |
| SSE-S3 KMS compatibility | `X-Minio-Internal-Server-Side-Encryption-S3-Sealed-Key` | `X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id` | `X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key` | Typically no client context |
| SSE-KMS | `X-Minio-Internal-Server-Side-Encryption-Kms-Sealed-Key` | `X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id` | `X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key` | `X-Minio-Internal-Server-Side-Encryption-Context`, Base64(JSON) |
Note two easily confused fields:
```text
...-Kms-Sealed-Key
= wrapped ObjectKey
= content DEK wrapped under the per-object sealingKey
...-S3-Kms-Sealed-Key
= KMS data key ciphertext
= parent/external key wrapped under the KMS master key
```
These are at different envelope layers and MUST NOT be interchanged.
The KMS encryption context is a Base64-encoded JSON AssociatedData that
provides only KMS authentication binding, not confidentiality. It appears in
`xl.meta`, backups, and diagnostic data, and therefore MUST NOT contain
tokens, passwords, plaintext keys, personally sensitive information, or
other secrets; it MUST NOT be written to logs, audit events, notification
events, or client responses.
### 7.3 Materials That MUST NOT Be Persisted
The following materials MUST NOT enter `xl.meta`, user metadata, bucket
metadata, logs, audit events, notification events, error messages, or
metrics labels:
- KMS data key plaintext;
- SSE-C customer key;
- `ObjectKey`;
- Multipart `partKey`;
- `sealingKey`;
- Any debug structure from which the above plaintext keys could be
recovered.
Types that carry the above plaintext materials MUST additionally:
- Use a secret wrapper and zeroize on drop in a way that the compiler cannot
optimize away;
- Zeroize on error, cancellation, and early-return paths as well;
- Not implement or derive `Debug`, `Display`, or `Serialize` in a way that
would expose their contents;
- Avoid unnecessary `Clone` and MUST NOT copy bare `[u8; 32]` or `Vec<u8>`
across async tasks;
- Treat core dumps, swap, telemetry samples, and crash diagnostics as
sensitive memory.
## 8. Write and Read Sequence
All three modes share the same main flow:
```text
Prepare parent/external key
-> Derive ObjectKey and sealingKey
-> Generate and persist the complete envelope metadata
-> Hand ObjectKey/partKey to RIO to produce DARE ciphertext
-> Atomically commit metadata and ciphertext in the same object generation
-> Wipe all plaintext keys
```
Mode differences are only in the parent key and persisted envelope:
| Mode | Write Preparation | Additional Persistence | Read Recovery |
|---|---|---|---|
| SSE-C | Verify customer key and MD5 | No KMS fields | Client provides customer key again |
| SSE-S3 local/K/V | Load local SSE-S3 parent key | No KMS fields | Local SSE-S3 provider supplies the parent key |
| SSE-S3 KMS compatibility | Call KMS/provider with default service-side key ID | KMS key ID + KMS ciphertext | KMS unwraps parent key |
| SSE-KMS | Request/default key ID, construct context per §5.1 | KMS key ID + ciphertext + original client context | Reconstruct effective context, then KMS unwraps |
Reads execute the strict reverse: parse the complete envelope, recover the
parent key, derive sealingKey, AEAD-unwrap `ObjectKey`, then RIO uses
`ObjectKey`/partKey to authenticate each package and return plaintext. Any
missing field, illegal Base64, wrong length, unsupported algorithm/cipher,
path or KMS context mismatch, or AEAD tag failure MUST fail explicitly; MUST
NOT fabricate success with a default key, zero key, truncated value, or
legacy fallback.
## 9. Layered Responsibility Contract
### 9.1 S3 API / Application Layer
Responsible for:
- Parsing and validating public SSE request headers;
- Deciding SSE-C, SSE-S3, or SSE-KMS;
- Passing bucket, object, KMS key ID, and context;
- Organizing copy, multipart, range, and error mapping;
- Ensuring the encryption reader is actually in the final write-to-disk
chain.
MUST NOT:
- Implement key derivation on its own;
- Expose internal SSE metadata as user metadata;
- Rely solely on metadata claiming the object is encrypted without verifying
actual on-disk bytes.
### 9.2 SSE / Key-Management Layer
Solely responsible for:
- Calling KMS/provider;
- Generating and recovering parent/external keys;
- Deriving `ObjectKey`;
- Deriving per-object `sealingKey`;
- Sealing/unsealing `ObjectKey`;
- Generating, parsing, and validating SSE metadata;
- Binding domain, algorithm, and bucket/object;
- Managing the lifecycle of plaintext keys.
### 9.3 SIO / RIO Layer
Only responsible for:
- Receiving `ObjectKey` or `partKey`;
- Generating and managing DARE stream nonces;
- Maintaining sequence numbers;
- Producing and parsing DARE 2.0 packages;
- Performing AEAD encryption/decryption and tag verification;
- Supporting sequence/boundary positioning as required by parts and ranges.
The RIO API SHALL NOT receive `SealedKey`, KMS key ID, KMS ciphertext, or
KMS context.
### 9.4 Storage / Erasure Layer
Responsible for persisting:
- The encrypted DARE data stream;
- The internal SSE metadata, intact and complete;
- Information needed for reads, such as part sizes and object actual size.
The Storage layer MUST NOT have persistence semantics for plaintext keys,
nor attempt to reconstruct `ObjectKey` on its own.
## 10. RustFS Implementation Navigation
The following anchors locate implementations and do not, by their mere
presence, prove complete interoperability:
| Specification Role | RustFS Implementation Anchor |
|---|---|
| SSE boundary | [rustfs/src/storage/sse.rs](../../rustfs/src/storage/sse.rs) |
| ObjectKey derivation | `derive_object_key` |
| Per-object KEK derivation | `derive_sealing_key` |
| ObjectKey seal/unseal | `seal_object_key` / `unseal_object_key` |
| Complete wrapping record | `ManagedSealedKey` |
| SSE-to-RIO material | `EncryptionMaterial` / `DecryptionMaterial` |
| Key semantic label | `EncryptionKeyKind::Object` |
| DARE data stream | [crates/rio-v2/src/encrypt_reader.rs](../../crates/rio-v2/src/encrypt_reader.rs) |
| Multipart part key | `derive_part_key` |
| MinIO metadata/`xl.meta` fixtures | [crates/rio-v2/tests/minio_generated_fixtures.rs](../../crates/rio-v2/tests/minio_generated_fixtures.rs) |
| MinIO end-to-end read fixtures | [crates/ecstore/tests/minio_generated_read_test.rs](../../crates/ecstore/tests/minio_generated_read_test.rs) |
`EncryptionKeyKind::Direct` and the `x-rustfs-encryption-*` fields belong to
the historical RustFS direct-stream key format. The compatibility principles
are:
1. Existing historical objects SHALL remain readable unless a verified
migration and rollback plan exists.
2. MinIO-compatible object-key mode MUST follow the `ObjectKey` /
`SealedKey` hierarchy defined in this document.
3. Legacy fallback MUST NOT swallow format corruption, wrong keys, or
authentication failures.
4. New MinIO compatibility tests MUST use real MinIO generated fixtures, not
only RustFS self-write-self-read round trips.
## 11. Compatibility and Security Invariants
Any SSE/RIO modification MUST preserve the following invariants.
### 11.1 Format Invariants
- `ObjectKey`, parent/external key, sealingKey, and partKey are all 32 B.
- Wrapping IV is 32 B; DARE stream nonce is 12 B.
- Wrapped `ObjectKey` is fixed at 64 B; new writes are DARE 2.0 packages,
and the legacy SSE-C reader additionally accepts DARE 1.0 packages meeting
§5.5.
- New writes' seal algorithm is exactly `DAREv2-HMAC-SHA256`.
- Domain exactly matches the SSE mode.
- Bucket/object canonicalization is consistent with MinIO `path.Join`
semantics.
- DARE header, cipher ID, payload length, final flag, and AEAD tag MUST be
validated.
- Zero-length plaintext corresponds to a zero-byte DARE stream; only
non-empty streams require the final package.
- Multipart part key uses `HMAC-SHA256(ObjectKey, LE32(partNumber))`.
- New writes only use `DAREv2-HMAC-SHA256`; legacy `DARE-SHA256` is only
permitted for the SSE-C reader under strict legacy format recognition.
### 11.2 Security Invariants
- All random values come from a CSPRNG.
- The same key/nonce combination MUST NOT be reused.
- Package plaintext MUST NOT be released before AEAD tag verification
completes.
- Any plaintext key MUST NOT be persisted or logged.
- Plaintext keys MUST be carried by a secret type that is non-printable,
non-serializable, and zeroizes on drop.
- Corrupt metadata MUST fail closed.
- Copy/rename/replication MUST NOT break bucket/object cryptographic binding.
- SSE metadata SHALL NOT serve as sufficient evidence that "on-disk content
is encrypted"; tests MUST inspect actual stored bytes.
- Internal SSE metadata MUST NOT be echoed as user-controllable or
client-visible metadata.
### 11.3 MinIO Interoperability Invariants
- The reader MUST accept AES-256-GCM and ChaCha20-Poly1305 sealed ObjectKeys
legitimately written by MinIO.
- KMS-backed SSE-S3 compatibility objects and SSE-KMS objects MUST save and
parse the KMS data-key ciphertext and wrapped ObjectKey as separate layers;
local/K/V SSE-S3 objects have only the wrapped ObjectKey layer.
- New KMS-backed writes MUST save KMS key ID/ciphertext as a pair. Only an
SSE-S3 sealed-key marker with both KMS fields absent identifies the local
K/V shape; an SSE-KMS sealed-key marker always requires the pair. If only
one field is present, the metadata is corrupt; if both fields are present,
the metadata belongs to the KMS-backed branch and empty values MUST NOT be
treated as absent.
- Provider routing MUST NOT guess from the byte shape of the KMS ciphertext:
the historical K/V shape is identified by KMS key ID/ciphertext both
absent; when both fields are present they uniformly belong to the
KMS-backed branch, including when either value is empty and subsequently
rejected as invalid. MinIO static KMS modern binary ciphertext and
external KMS ciphertext are both opaque bytes and cannot be reliably
distinguished.
- Only when the key ID in
`RUSTFS_MINIO_STATIC_KMS_KEY=<key-id>:<base64-32-byte-key>` exactly
matches the object's persisted key ID SHALL the reader unwrap locally
using the MinIO static KMS format; on mismatch it MUST delegate to the
external KMS. Opportunistic fallback is forbidden. This configuration
supports the modern binary AES-256-GCM envelope and the historical Base64
JSON `AES-256-GCM-HMAC-SHA-256` / `ChaCha20Poly1305` envelope.
- The key ID in the static KMS configuration is the sole provider
declaration for that ID within the process and MUST NOT duplicate a key ID
from the external KMS. After an ID match, if ciphertext, AAD, or tag
verification fails, it MUST fail closed; falling back to the external KMS
is forbidden—otherwise a key error becomes a provider-probing oracle.
Release builds SHALL freeze the configuration on first use; changes
require a process restart. The same persisted object MUST NOT drift
providers across requests.
- The static KMS root key MUST be exactly 32 bytes and MUST NOT be all
zeros.
- `RUSTFS_MINIO_STATIC_KMS_KEY` contains a plaintext root key and SHALL only
be injected through a protected secret channel; it MUST NOT be written to
the repository, logs, errors, audit, or object metadata.
- The Base64(JSON) representation and decryption context of the KMS context
MUST be consistent.
- The KMS context has no confidentiality and MUST NOT contain secrets or be
echoed to clients.
- Metadata key spelling and casing MUST be compatible with the MinIO
persistence format.
- RustFS writes MUST preserve both the `x-rustfs-internal-*` and
`x-minio-internal-*` twin keys; the MinIO key MUST NOT be omitted because
the RustFS key is present.
- Real MinIO single-part, multipart, SSE-C, SSE-S3, and SSE-KMS fixtures
MUST be parseable and readable by RustFS.
- Real four-disk local-KMS SSE-KMS fixtures generated by a fixed RustFS
`1.0.0-beta.5` release MUST be readable through the current production
bitrot/erasure/GET/KMS decryption chain; tests MUST NOT replace these with
fixed-plaintext mocks that ignore the envelope, key ID, or context.
### 11.4 Concurrency and Persistence Invariants
- DARE ciphertext, SSE metadata, part metadata, and actual sizes MUST belong
to the same object generation and become visible within the same object
commit boundary.
- "Encrypted" metadata MUST NOT be committed before the ciphertext data;
after crash recovery there MUST NOT be a state where metadata claims
encryption while the data region is still plaintext or from an old
generation.
- KMS success and key derivation completion do not imply object commit
success; failure or cancellation paths MUST clean up temporary data and
release all plaintext keys together with the request state.
- Copy, multipart complete, version writes, and retries MUST be idempotent:
a once-committed object SHALL only reference wrapped keys and part
boundaries that match its ciphertext.
- Key rotation or metadata-only rewrap MUST atomically replace the complete
envelope while holding the corresponding object write/versioning
coordination boundary; readers MUST NOT observe a mix of new KMS
ciphertext with old `SealedKey`, or old KMS ciphertext with new
`SealedKey`.
- Concurrent requests MUST NOT share mutable plaintext key buffers, nonce
state, or DARE sequence state; each object stream and each multipart part
MUST have independent state.
- PutPart (especially same part number), ListParts, Complete, and Abort for
the same upload ID MUST be linearized by a per-upload coordination
boundary. Complete MUST pin a part snapshot; only one of Complete and
Abort may pass the final commit linearization point.
- Multipart part data/metadata SHALL only be cleaned up on a best-effort
basis after the final generation durable commit; retries MUST be
reentrant and MUST NOT delete the last copy referenced by a committed
object.
- Copy/rename/rewrap MUST pin the ciphertext, SSE envelope, and part
boundaries of the same source version/generation and commit under the
target write lock/commit fence; rewrap uses generation CAS. Cross-generation
splicing of envelope and ciphertext is forbidden.
- SSE commits follow the quorum/atomic commit contract of
[erasure-coding.md](erasure-coding.md) and the generation/fencing contract
of [unified-object-generation.md](unified-object-generation.md): temporary
data and `xl.meta` are synced per the selected `DurabilityMode`, lock
loss/epoch is checked before the commit rename, the directory is synced
after rename per the durability gate, and the ACK is only sent after the
corresponding durable boundary is reached.
- Cancellation and crash cleanup MUST distinguish pre-commit from
post-commit: pre-commit only rolls back this request's tmp generation;
post-commit MUST NEVER delete a visible generation. Old data and part
metadata cleanup is best-effort only, must be reentrant, and failure MUST
NOT turn a committed write into a reported failure.
### 11.5 Performance Invariants
- Each independent request/operation SHALL parse the same SSE metadata at
most once, recover the parent key at most once, and unwrap the same
`ObjectKey` at most once; within the same request, reuse across ranges,
parts, and shards. KMS/provider calls MUST NOT be pushed down to every
DARE package, every erasure shard, or every range block.
- Multipart initiate is responsible for generating and persisting the
session envelope; subsequent independent UploadPart requests recover
material from that envelope. Plaintext parent keys/`ObjectKey` MUST NOT be
cached long-term across requests or across multipart sessions to reduce
KMS calls.
- ObjectKey and sealingKey derivation is constant work per object; partKey
derivation is constant work per part.
- RIO MUST maintain streaming encryption/decryption and MUST NOT buffer the
complete object for SSE wrapping.
- Bulk paths such as ListObjects, when they must recover multiple
ObjectKey/ETag keys, SHOULD prefer bulk KMS decrypt; when the provider
lacks bulk capability, use bounded concurrency with an explicit batch
limit. Serial execution of N remote KMS RTTs is forbidden, as is
substituting long-term caching of plaintext keys for batching.
- Performance optimizations MUST NOT cache or extend the lifetime of
plaintext keys, nor reduce work by skipping tag, nonce, final-package, or
metadata consistency validation.
### 11.6 rio-v2 Write Format Enablement Gate
Compiling `rio-v2` only means the process has the code capability to read
and write the MinIO ObjectKey format; it does not mean every node on the
shared storage has completed the upgrade. To ensure rolling upgrades and
rollbacks:
- When `RUSTFS_SSE_RIO_V2_WRITE_FORMAT` is unset, or set to
`legacy-direct`, the historical Direct format continues to be written; the
rio-v2 reader can still read both Direct and MinIO ObjectKey formats.
- Only after confirming that all nodes that may read the storage pool are
running a version that supports the ObjectKey format may
`RUSTFS_SSE_RIO_V2_WRITE_FORMAT=minio-object-key` be uniformly set on all
write nodes.
- Unknown configuration values MUST fail closed and MUST NOT silently fall
back or be independently enabled by some nodes.
- `RUSTFS_MINIO_STATIC_KMS_KEY` only controls static KMS unwrapping for
compatible reads; it does not change the write format of new objects, nor
does it replace the formal KMS service required for SSE-KMS writes.
- New SSE-S3 writes under `minio-object-key` MUST use the local SSE-S3
provider as the parent-key source and persist the historical K/V metadata
shape with both KMS fields absent. Enabling this write format MUST NOT make
SSE-S3 depend on a configured KMS service. RustFS private local envelopes
MUST NOT be placed in the MinIO KMS ciphertext field. The reader SHALL
remain compatible with KMS-backed historical SSE-S3 objects by routing
their complete persisted KMS pair to KMS.
- Rolling back from `minio-object-key` to `legacy-direct` only affects
subsequent writes; existing ObjectKey objects still require the rio-v2
reader, so before rollback it MUST be guaranteed that old nodes will not
take over these objects, or a verified migration/rewrap MUST be completed
first.
- The current gate is a deployment-level consistency contract, not automatic
peer capability negotiation. If the cluster capability epoch is integrated
later, automatic switching SHALL only occur after all live write nodes
have confirmed the same epoch; if any node's state is unknown,
`legacy-direct` MUST be retained.
## 12. Required Compatibility Test Matrix
Behavior implementations MUST NOT rely solely on encrypt/decrypt round trips
within the same codebase. The following tests MUST exercise real
S3/application/storage production call chains and assert on complete bodies,
exact lengths, typed errors, or physical storage formats:
| Test Category | What MUST Be Proven |
|---|---|
| Real MinIO single-part | SSE-C, SSE-S3, and SSE-KMS objects are all completely readable with exact content and length |
| Real MinIO multipart | Part boundaries, partKey, per-part sequence reset, and complete object assembly are correct |
| Cipher-suite compatibility | Sealed ObjectKey and object content DARE stream each cover AES-256-GCM/ChaCha20-Poly1305, and cover combinations where seal and content ciphers differ |
| Metadata physical format | Required MinIO fields are present, Base64 decode lengths are correct, RustFS/MinIO twin keys are simultaneously written |
| Actual on-disk ciphertext | Stored bytes contain no known plaintext, and "encrypted" is not judged solely from SSE metadata |
| Path binding | The same wrapped ObjectKey fails to unwrap under a different bucket/object; copy to the target path succeeds |
| Path edge values | Leading/trailing slashes and legal test keys containing `.` and `..` segments match MinIO `path.Join` results |
| Range | First/last byte, 64 KiB1/boundary/+1, exact slices across DARE packages and across multipart parts, Content-Length/Content-Range correct |
| Copy | Same/cross-bucket CopyObject and UploadPartCopy cover representative source/target SSE-C/S3/KMS combinations, pinned source version, target envelope, and path rebinding |
| Metadata corruption | Missing fields, illegal Base64, wrong length, unknown algorithm/cipher all fail explicitly |
| KMS pair consistency | Both key ID/ciphertext present succeeds; both absent routes to the recognized legacy provider; only one absent returns a typed error |
| Ciphertext corruption and truncation | Corruption of header, payload, tag, or final package never returns unauthenticated or truncated plaintext |
| Zero-length objects | Plaintext and ciphertext are both zero bytes; not misclassified as truncated due to missing final package |
| Multipart boundaries | Part number 1, maximum supported part number, empty/minimum part, and cross-64 KiB package boundaries |
| KMS context | Context absent, explicitly provided, field order variations, and mismatch scenarios match MinIO behavior |
| ETag/internal metadata | Empty, 16 B, and 48 B paths for sealed ETag, plus at least one metadata value with a distinct `baseKey` domain, match MinIO |
| Legacy MinIO SSE-C | `DARE-SHA256` historical objects readable, but new writes and corrupted v2 inputs MUST NOT enter the legacy path |
| Legacy RustFS | Recognizable old objects remain readable; corrupted MinIO format MUST NOT mistakenly enter legacy fallback |
| Crash/cancellation | Metadata and ciphertext do not mix across generations; temporary files and plaintext material do not leak |
| Secret non-leakage | Unique sentinel parent/ObjectKey/customer key does not appear in committed metadata, raw payload, tmp/rollback, error/log/audit/notification; secret wrapper drop observably zeroizes |
| KMS/parse/KDF call counts | Fake KMS, decoder, and KDF counters prove one GET/range recovers the same ObjectKey only once; package/shard count does not increase ObjectKey/sealingKey derivations; multipart range derives partKey at most once per touched part; bulk paths do not serially execute N remote KMS calls |
| Streaming memory | Constrained reader/peak memory tests prove buffering is O(package/block), not O(object/part) |
Real MinIO fixture tests MUST parse MinIO-generated persisted objects and
MUST NOT use RustFS writer on-the-fly generation fed to the RustFS reader.
The fixture corpus MUST be sanitized, pinned to a MinIO release/commit and
content hash, and included in the default CI gate. Test functions may use
`#[ignore]` to isolate expensive fixture generation, but MUST be explicitly
executed via `ignored-only` by a dedicated required workflow covered by the
relevant source path filter; they MUST NOT rely on developers running them
manually locally. When the corresponding compatibility branch is removed,
that gate MUST fail. Until that condition is met, the relevant mode MUST NOT
be marked as MinIO-interoperable.
Write compatibility MUST be inversely proven by the target MinIO release
actually HEAD/GET-ing RustFS-generated single-part, multipart, SSE-C,
SSE-S3, and SSE-KMS objects, or by using cross-implementation fixtures that
have been verified and frozen by the MinIO reader. Self-write-self-read and
expected bytes encoded by RustFS itself cannot substitute for this proof.
The test oracle MUST additionally satisfy:
- Twin-key tests SHALL assert identical values for both the RustFS and MinIO
prefixes for every SSE suffix in the final committed `xl.meta`, and cover
write, remove, and rotate; a write or delete that omits either prefix MUST
fail the test.
- "Actual on-disk ciphertext" tests SHALL, after a real PUT write, read the
storage payload bypassing SSE decode, and verify it is valid DARE framing,
differs from the plaintext, and can be decrypted by the expected
`ObjectKey`; merely searching physical shards for absence of contiguous
plaintext does not constitute a proof.
- Fake KMS MUST record exact AssociatedData, covering bucket entry absent,
preexisting, stored/effective context separation, JSON map ordering,
mismatch, and same/cross-bucket copy/rename/rewrap; changing
insert-if-absent or persisting the effective map MUST fail the test.
- The crash harness SHALL inject failures after data tmp write, after
`xl.meta` tmp write, before/after commit rename, and between the two
envelope replacements during rewrap; after restart, only a complete old or
complete new generation may be observed. Removing generation commit,
lock-loss fence, or CAS MUST fail the test.
- Corruption/truncation tests SHALL consume package by package and assert
specific error categories; first-package authentication failure returns
zero plaintext, subsequent package failures return at most the previously
fully authenticated packages, and MUST NEVER return `Ok(wrong plaintext)`,
silent EOF, or 200 + truncated body.
- Range tests MUST assert exact byte slices and response range headers;
corruption of a touched package MUST error, and corruption of an untouched
package MUST NOT pollute a valid range.
- CopyObject and UploadPartCopy MUST fail variants that "directly carry over
old SealedKey/partKey", "use wrong source/target KMS context", and "do not
pin source version".
- Zero-length, AES/ChaCha sealed key, AES/ChaCha content stream, legacy
SSE-C, and multipart boundaries MUST each have at least one end-to-end
production-path test and MUST NOT be proven only by RIO synthetic unit
tests.
- KMS pair tests MUST distinguish both-present, both-absent, key-ID-only,
and ciphertext-only, and MUST NOT reject legitimate legacy shapes with a
generic "missing field failure" assertion.
- Secret sentinel tests MUST cover success, KMS error, cancellation, and
crash paths, and capture storage/tmp, error, log, audit, and notification
output; zeroize additionally has an observable unit test.
- Fake KMS, metadata decoder, and KDF counters MUST be attached to
production GetObject/range/list/multipart paths; reversing in-request
reuse, moving decrypt/KDF into the package/shard loop, or turning bulk KMS
into serial calls MUST fail the test.
## 13. One-Sentence Architecture Conclusion
> The SSE layer owns the complete key hierarchy: it selects the local SSE-S3
> parent key, KMS data key plaintext, or SSE-C customer key according to the
> SSE mode independently of the selected write format; derives `ObjectKey`
> and the per-object `sealingKey`; and persists the wrapped `ObjectKey`. RIO
> only receives the in-memory `ObjectKey` or partKey, performs DARE 2.0 data
> stream encryption/decryption, and never interprets or creates a
> `SealedKey`.
+1
View File
@@ -181,6 +181,7 @@ libc = { workspace = true }
rand = { workspace = true, features = ["serde"] }
aes-gcm = { workspace = true, features = ["rand_core"] }
chacha20poly1305 = { workspace = true }
zeroize.workspace = true
# Observability and Metrics
metrics = { workspace = true }
+250 -26
View File
@@ -197,8 +197,9 @@ use tracing::{debug, error, instrument, warn};
use uuid::Uuid;
use super::storage_api::object_usecase::{
GetObjectReader, StorageDeletedObject, StorageObjectInfo as ObjectInfo, StorageObjectLockDeleteOptions,
StorageObjectOptions as ObjectOptions, StorageObjectToDelete as ObjectToDelete, StoragePutObjReader as PutObjReader,
GetObjectReader, StorageDeletedObject, StorageGetObjectSse as GetObjectSse, StorageObjectInfo as ObjectInfo,
StorageObjectLockDeleteOptions, StorageObjectOptions as ObjectOptions, StorageObjectToDelete as ObjectToDelete,
StoragePutObjReader as PutObjReader,
};
use crate::app::object_data_cache::{
ColdFillCoordinateOutcome, ColdFillDiskPermitOwner, ColdFillError, ColdFillProducer, GetObjectBodyCacheLookup,
@@ -3826,6 +3827,7 @@ impl DefaultObjectUsecase {
// metadata resolution.
let cache_hook_served = reader.is_cache_hook_served();
let cache_hook_probed = reader.cache_hook_probed();
let resolved_sse = reader.resolved_sse;
let info = reader.object_info;
let stream = reader.stream;
let buffered_body = reader.buffered_body;
@@ -3899,15 +3901,12 @@ impl DefaultObjectUsecase {
req.input.sse_customer_key.is_some()
);
let decryption_request = DecryptionRequest {
bucket,
key,
metadata: &info.user_defined,
sse_customer_key: req.input.sse_customer_key.as_ref(),
sse_customer_key_md5: req.input.sse_customer_key_md5.as_ref(),
};
let response_content_length = content_length;
// A cache-served body did not pass through ecstore's encryption-material
// resolver. Keep one KMS/SSE-C validation on that path; storage-backed
// readers have already resolved their material and only need response
// header classification here.
let resolved_sse = (!cache_hook_served).then_some(resolved_sse).flatten();
let (
server_side_encryption,
@@ -3917,22 +3916,60 @@ impl DefaultObjectUsecase {
encryption_applied,
final_stream,
buffered_body,
) = match sse_decryption(decryption_request).await? {
Some(material) => {
let server_side_encryption = Some(material.server_side_encryption.clone());
let sse_customer_algorithm = matches!(material.sse_type, SSEType::SseC).then_some(material.algorithm.clone());
let sse_customer_key_md5 = material.customer_key_md5.clone();
(
server_side_encryption,
sse_customer_algorithm,
sse_customer_key_md5,
material.kms_key_id,
true,
wrap_reader(stream),
None,
)
}
None => (None, None, None, None, false, wrap_reader(stream), buffered_body),
) = match resolved_sse {
Some(GetObjectSse::SseC { customer_key_md5 }) => (
Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)),
Some(SSECustomerAlgorithm::from(ServerSideEncryption::AES256.to_string())),
Some(customer_key_md5),
None,
true,
wrap_reader(stream),
None,
),
Some(GetObjectSse::SseS3) => (
Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)),
None,
None,
None,
true,
wrap_reader(stream),
None,
),
Some(GetObjectSse::SseKms { key_id }) => (
Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS)),
None,
None,
Some(SSEKMSKeyId::from(key_id)),
true,
wrap_reader(stream),
None,
),
None if !cache_hook_served => (None, None, None, None, false, wrap_reader(stream), buffered_body),
None => match sse_decryption(DecryptionRequest {
bucket,
key,
metadata: &info.user_defined,
sse_customer_key: req.input.sse_customer_key.as_ref(),
sse_customer_key_md5: req.input.sse_customer_key_md5.as_ref(),
})
.await?
{
Some(material) => {
let server_side_encryption = Some(material.server_side_encryption.clone());
let sse_customer_algorithm = matches!(material.sse_type, SSEType::SseC).then_some(material.algorithm.clone());
let sse_customer_key_md5 = material.customer_key_md5.clone();
(
server_side_encryption,
sse_customer_algorithm,
sse_customer_key_md5,
material.kms_key_id,
true,
wrap_reader(stream),
None,
)
}
None => (None, None, None, None, false, wrap_reader(stream), buffered_body),
},
};
Ok(GetObjectReadSetup {
@@ -8096,6 +8133,185 @@ mod tests {
}
}
#[tokio::test]
async fn storage_resolved_sse_skips_second_app_layer_material_resolution() {
let input = GetObjectInput::builder()
.bucket("bucket".to_string())
.key("object".to_string())
.build()
.expect("GET input must build");
let request = build_request(input, Method::GET);
let reader = GetObjectReader {
stream: Box::new(std::io::Cursor::new(b"body".to_vec())),
object_info: ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
size: 4,
actual_size: 4,
user_defined: Arc::new(HashMap::from([
("x-amz-server-side-encryption".to_string(), "AES256".to_string()),
("x-rustfs-encryption-key".to_string(), "not-base64".to_string()),
])),
..Default::default()
},
buffered_body: None,
resolved_sse: Some(GetObjectSse::SseS3),
body_source: GetObjectBodySource::Unprobed,
};
let setup = DefaultObjectUsecase::finish_get_object_read(
&request,
get_concurrency_manager(),
"bucket",
"object",
None,
None,
std::time::Instant::now(),
reader,
false,
)
.await
.expect("storage-resolved SSE must not reparse the malformed envelope in the app layer");
assert_eq!(
setup.server_side_encryption.as_ref().map(ServerSideEncryption::as_str),
Some(ServerSideEncryption::AES256)
);
assert!(setup.encryption_applied);
}
#[cfg(feature = "rio-v2")]
#[tokio::test]
#[serial_test::serial]
async fn cache_served_minio_static_kms_object_resolves_locally() {
use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit, Payload},
};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let master_key = [0x31; 32];
let external_key = [0x42; 32];
let object_key = [0x53; 32];
let kms_iv = [0x64; 16];
let kms_nonce = [0x75; 12];
let mut kms_mac = HmacSha256::new_from_slice(&master_key).expect("valid KMS master key");
kms_mac.update(&kms_iv);
let kms_sealing_key = kms_mac.finalize().into_bytes();
let mut encrypted_dek = Aes256Gcm::new_from_slice(kms_sealing_key.as_slice())
.expect("valid KMS sealing key")
.encrypt(
&Nonce::from(kms_nonce),
Payload {
msg: &external_key,
aad: br#"{"bucket":"bucket/object"}"#,
},
)
.expect("encrypt static-KMS fixture DEK");
encrypted_dek.extend_from_slice(&kms_iv);
encrypted_dek.extend_from_slice(&kms_nonce);
let sealing_iv = [0x86; 32];
let mut object_mac = HmacSha256::new_from_slice(&external_key).expect("valid external key");
object_mac.update(&sealing_iv);
object_mac.update(b"SSE-KMS");
object_mac.update(b"DAREv2-HMAC-SHA256");
object_mac.update(b"bucket/object");
let object_sealing_key = object_mac.finalize().into_bytes();
let mut sealed_header = [0u8; 16];
sealed_header[0] = 0x20;
sealed_header[2..4].copy_from_slice(&(31u16).to_le_bytes());
sealed_header[4] = 0x80;
let mut sealed_object_key = sealed_header.to_vec();
sealed_object_key.extend_from_slice(
&Aes256Gcm::new_from_slice(object_sealing_key.as_slice())
.expect("valid object sealing key")
.encrypt(
&Nonce::from(<[u8; 12]>::try_from(&sealed_header[4..]).expect("12-byte nonce")),
Payload {
msg: &object_key,
aad: &sealed_header[..4],
},
)
.expect("seal object key"),
);
let input = GetObjectInput::builder()
.bucket("bucket".to_string())
.key("object".to_string())
.build()
.expect("GET input must build");
let request = build_request(input, Method::GET);
let metadata = HashMap::from([
("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()),
("x-amz-server-side-encryption-aws-kms-key-id".to_string(), "minio-key".to_string()),
(
"X-Minio-Internal-Server-Side-Encryption-S3-Kms-Key-Id".to_string(),
"minio-key".to_string(),
),
(
"X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key".to_string(),
BASE64_STANDARD.encode(encrypted_dek),
),
(
"X-Minio-Internal-Server-Side-Encryption-Kms-Sealed-Key".to_string(),
BASE64_STANDARD.encode(sealed_object_key),
),
(
"X-Minio-Internal-Server-Side-Encryption-Iv".to_string(),
BASE64_STANDARD.encode(sealing_iv),
),
(
"X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm".to_string(),
"DAREv2-HMAC-SHA256".to_string(),
),
]);
let configured_key = format!("minio-key:{}", BASE64_STANDARD.encode(master_key));
temp_env::async_with_vars([("RUSTFS_MINIO_STATIC_KMS_KEY", Some(configured_key))], async {
let reader = GetObjectReader {
stream: Box::new(std::io::Cursor::new(b"body".to_vec())),
object_info: ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
size: 4,
actual_size: 4,
user_defined: Arc::new(metadata),
..Default::default()
},
buffered_body: Some(Bytes::from_static(b"body")),
resolved_sse: None,
body_source: GetObjectBodySource::HookServed,
};
let setup = DefaultObjectUsecase::finish_get_object_read(
&request,
get_concurrency_manager(),
"bucket",
"object",
None,
None,
std::time::Instant::now(),
reader,
false,
)
.await
.expect("cache-served MinIO static-KMS object must resolve without external KMS");
assert_eq!(
setup.server_side_encryption.as_ref().map(ServerSideEncryption::as_str),
Some(ServerSideEncryption::AWS_KMS)
);
assert_eq!(setup.ssekms_key_id.as_deref(), Some("minio-key"));
assert!(setup.encryption_applied);
})
.await;
}
#[test]
fn internal_object_info_lookup_opts_drops_http_preconditions() {
let version_id = Uuid::new_v4().to_string();
@@ -8911,6 +9127,7 @@ mod tests {
..Default::default()
},
buffered_body: None,
resolved_sse: None,
body_source: GetObjectBodySource::HookMissed,
})
},
@@ -9011,6 +9228,7 @@ mod tests {
..Default::default()
},
buffered_body: None,
resolved_sse: None,
body_source: GetObjectBodySource::HookMissed,
})
},
@@ -9614,6 +9832,7 @@ mod tests {
..Default::default()
},
buffered_body: Some(Bytes::from_static(b"body")),
resolved_sse: None,
body_source: GetObjectBodySource::HookMissed,
})
},
@@ -9788,6 +10007,7 @@ mod tests {
..Default::default()
},
buffered_body: Some(Bytes::from_static(b"body")),
resolved_sse: None,
body_source: GetObjectBodySource::HookMissed,
})
},
@@ -9918,6 +10138,7 @@ mod tests {
..Default::default()
},
buffered_body: None,
resolved_sse: None,
body_source: GetObjectBodySource::HookMissed,
})
},
@@ -9991,6 +10212,7 @@ mod tests {
..Default::default()
},
buffered_body: Some(Bytes::from_static(b"body")),
resolved_sse: None,
body_source: GetObjectBodySource::HookMissed,
})
},
@@ -10051,6 +10273,7 @@ mod tests {
..Default::default()
},
buffered_body: None,
resolved_sse: None,
body_source: GetObjectBodySource::HookMissed,
})
},
@@ -10182,6 +10405,7 @@ mod tests {
..Default::default()
},
buffered_body: None,
resolved_sse: None,
body_source: GetObjectBodySource::HookMissed,
})
},
+1 -1
View File
@@ -994,7 +994,7 @@ pub(crate) mod object_usecase {
object_utils, options, request_context, s3_api, set_disk, sse, storage_class, timeout_wrapper,
};
pub(crate) use crate::storage::storage_api::{
ECStore, GetObjectReader, OldCurrentSize, RFC1123, StorageDeletedObject, StorageObjectInfo,
ECStore, GetObjectReader, OldCurrentSize, RFC1123, StorageDeletedObject, StorageGetObjectSse, StorageObjectInfo,
StorageObjectLockDeleteOptions, StorageObjectOptions, StorageObjectToDelete, StoragePutObjReader, check_preconditions,
get_validated_store, has_replication_rules, parse_object_lock_legal_hold, parse_object_lock_retention,
parse_part_number_i32_to_usize, remove_object_lock_metadata_for_copy, strip_managed_encryption_metadata,
+786 -360
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -82,6 +82,7 @@ pub(crate) mod contract {
pub(crate) type StorageDeletedObject = contract::object::DeletedObject;
pub(crate) type StorageGetObjectReader = super::GetObjectReader;
pub(crate) type StorageGetObjectSse = GetObjectSse;
pub(crate) type StorageObjectInfo = super::ObjectInfo;
pub(crate) type StorageObjectLockDeleteOptions = contract::object::ObjectLockDeleteOptions;
pub(crate) type StorageObjectOptions = super::ObjectOptions;
@@ -492,9 +493,9 @@ pub(crate) mod ecstore_object {
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::object::GetObjectBodySource;
pub(crate) use rustfs_ecstore::api::object::{
GetObjectBodyCacheHook, GetObjectBodyCacheHookLookup, ObjectMutationHook, get_object_body_cache_plaintext_len,
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
GetObjectBodyCacheHook, GetObjectBodyCacheHookLookup, GetObjectSse, ObjectMutationHook,
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
};
}
@@ -503,7 +504,9 @@ pub(crate) mod ecstore_set_disk {
}
pub(crate) mod ecstore_sse {
pub(crate) use rustfs_ecstore::api::sse::{ManagedDekProvider, ManagedSseScheme, managed_dek_provider};
pub(crate) use rustfs_ecstore::api::sse::{ManagedDekProvider, ManagedSseScheme, classify_persisted_managed_encryption};
#[cfg(feature = "rio-v2")]
pub(crate) use rustfs_ecstore::api::sse::{PersistedManagedEncryption, decrypt_minio_static_kms_dek};
}
pub(crate) mod ecstore_storage {
@@ -1563,6 +1566,7 @@ impl StorageVersioningConfigExt for s3s::dto::VersioningConfiguration {
}
pub(crate) type GetObjectReader = <ECStore as contract::object::ObjectIO>::GetObjectReader;
pub(crate) type GetObjectSse = ecstore_object::GetObjectSse;
pub(crate) type ObjectInfo = <ECStore as contract::object::ObjectOperations>::ObjectInfo;
pub(crate) type ObjectOptions = <ECStore as contract::object::ObjectOperations>::ObjectOptions;
pub(crate) type PutObjReader = <ECStore as contract::object::ObjectIO>::PutObjectReader;