diff --git a/.github/workflows/minio-interop.yml b/.github/workflows/minio-interop.yml index 585dca543..3de6e60c1 100644 --- a/.github/workflows/minio-interop.yml +++ b/.github/workflows/minio-interop.yml @@ -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: | diff --git a/.vscode/launch.json b/.vscode/launch.json index e69583e4a..97bad6b66 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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", diff --git a/Cargo.lock b/Cargo.lock index ca565d24c..dd128e9ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 8be5fbbf8..1392720be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/ecstore/Cargo.toml b/crates/ecstore/Cargo.toml index c35289f72..ee4ecd5d9 100644 --- a/crates/ecstore/Cargo.toml +++ b/crates/ecstore/Cargo.toml @@ -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"] } diff --git a/crates/ecstore/src/api/mod.rs b/crates/ecstore/src/api/mod.rs index dccde0e82..01b7a5385 100644 --- a/crates/ecstore/src/api/mod.rs +++ b/crates/ecstore/src/api/mod.rs @@ -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 { diff --git a/crates/ecstore/src/bucket/replication/replication_pool.rs b/crates/ecstore/src/bucket/replication/replication_pool.rs index 5b279eb7e..b33168ad7 100644 --- a/crates/ecstore/src/bucket/replication/replication_pool.rs +++ b/crates/ecstore/src/bucket/replication/replication_pool.rs @@ -1625,6 +1625,7 @@ mod tests { ..Default::default() }, buffered_body: None, + resolved_sse: None, body_source: Default::default(), }) } diff --git a/crates/ecstore/src/client/api_put_object_streaming.rs b/crates/ecstore/src/client/api_put_object_streaming.rs index fdb1e820e..5d2957ba1 100644 --- a/crates/ecstore/src/client/api_put_object_streaming.rs +++ b/crates/ecstore/src/client/api_put_object_streaming.rs @@ -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(); diff --git a/crates/ecstore/src/client/object_api_utils.rs b/crates/ecstore/src/client/object_api_utils.rs index 6099095a9..fb5ed2cd2 100644 --- a/crates/ecstore/src/client/object_api_utils.rs +++ b/crates/ecstore/src/client/object_api_utils.rs @@ -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(), }); diff --git a/crates/ecstore/src/config/com.rs b/crates/ecstore/src/config/com.rs index d29d56e8d..d935fef9a 100644 --- a/crates/ecstore/src/config/com.rs +++ b/crates/ecstore/src/config/com.rs @@ -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(), }) } diff --git a/crates/ecstore/src/data_movement/mod.rs b/crates/ecstore/src/data_movement/mod.rs index f52820393..20af641a0 100644 --- a/crates/ecstore/src/data_movement/mod.rs +++ b/crates/ecstore/src/data_movement/mod.rs @@ -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(), }; diff --git a/crates/ecstore/src/io_support/rio.rs b/crates/ecstore/src/io_support/rio.rs index 38773ccc1..ced96eb6b 100644 --- a/crates/ecstore/src/io_support/rio.rs +++ b/crates/ecstore/src/io_support/rio.rs @@ -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"); diff --git a/crates/ecstore/src/object_api/readers.rs b/crates/ecstore/src/object_api/readers.rs index 29923f0ca..fb2979731 100644 --- a/crates/ecstore/src/object_api/readers.rs +++ b/crates/ecstore/src/object_api/readers.rs @@ -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; @@ -113,14 +98,6 @@ fn build_object_encryption_context( object_context } -#[cfg(feature = "rio-v2")] -fn is_legacy_rustfs_managed_metadata(metadata: &HashMap) -> 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, pub object_info: ObjectInfo, pub buffered_body: Option, + pub resolved_sse: Option, /// 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 { @@ -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, } #[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> { +fn decrypt_sealed_object_key_payload(sealing_key: &[u8; 32], header: &[u8], sealed_key: &[u8]) -> Result> { 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) -> 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) -> 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, bucket: &str, key: } async fn resolve_managed_material(bucket: &str, object: &str, metadata: &HashMap) -> Result { - 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> = 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, encrypted_dek: &[u8]) -> Result { - 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) -> HashMap { - #[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::>(&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, - nonce: Vec, - bytes: Vec, -} - -#[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; 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) -> Vec { - 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) { + 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) { 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) diff --git a/crates/ecstore/src/services/rebalance/rebalance_unit_tests.rs b/crates/ecstore/src/services/rebalance/rebalance_unit_tests.rs index bc6006907..87ee31761 100644 --- a/crates/ecstore/src/services/rebalance/rebalance_unit_tests.rs +++ b/crates/ecstore/src/services/rebalance/rebalance_unit_tests.rs @@ -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(), } } diff --git a/crates/ecstore/src/services/tier/tier.rs b/crates/ecstore/src/services/tier/tier.rs index e3e81b40d..c861ca588 100644 --- a/crates/ecstore/src/services/tier/tier.rs +++ b/crates/ecstore/src/services/tier/tier.rs @@ -8194,6 +8194,7 @@ mod tests { ..Default::default() }, buffered_body: None, + resolved_sse: None, body_source: Default::default(), }) } diff --git a/crates/ecstore/src/set_disk/ops/object.rs b/crates/ecstore/src/set_disk/ops/object.rs index c4442fedc..aa060a838 100644 --- a/crates/ecstore/src/set_disk/ops/object.rs +++ b/crates/ecstore/src/set_disk/ops/object.rs @@ -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; diff --git a/crates/ecstore/src/sse/mod.rs b/crates/ecstore/src/sse/mod.rs index 3467fa2af..69e1c0100 100644 --- a/crates/ecstore/src/sse/mod.rs +++ b/crates/ecstore/src/sse/mod.rs @@ -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; + #[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, + encrypted_dek: &[u8], +) -> Result { + 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, + field: &'static str, +) -> Result, 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) -> 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) -> 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, + 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, + iv: [u8; 16], + nonce: [u8; 12], + algorithm: MinioStaticKmsAlgorithm, +} + +pub fn decrypt_minio_static_kms_dek( + kms_key_id: &str, + encrypted_dek: &[u8], + context: &HashMap, +) -> Result, 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::((&*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, PersistedEncryptionError> { + #[cfg(not(any(test, debug_assertions)))] + { + static CONFIG: OnceLock, 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, 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 :"), + })?; + 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 { + 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, PersistedEncryptionError> { + BASE64_STANDARD + .decode(value) + .map_err(|_| PersistedEncryptionError::InvalidMinioStaticKmsCiphertext { + reason: format!("{field} must be valid Base64"), + }) +} + +fn decode_minio_static_kms_array(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) -> Vec { + 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 { + format!("{}:{}", BASE64_STANDARD.encode([1u8; 12]), BASE64_STANDARD.encode([2u8; 48])).into_bytes() + } + + fn kms_envelope() -> Vec { + 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 { + 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 { + 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 + }) + ); } } diff --git a/crates/ecstore/src/store/init.rs b/crates/ecstore/src/store/init.rs index bc566d855..0ed4a0b72 100644 --- a/crates/ecstore/src/store/init.rs +++ b/crates/ecstore/src/store/init.rs @@ -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(), }) } diff --git a/crates/ecstore/src/store/object.rs b/crates/ecstore/src/store/object.rs index 41c5ad0c9..d513470c8 100644 --- a/crates/ecstore/src/store/object.rs +++ b/crates/ecstore/src/store/object.rs @@ -2646,6 +2646,7 @@ mod tests { stream: Box::new(Cursor::new(Vec::::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(), }; diff --git a/crates/ecstore/tests/minio_generated_read_test.rs b/crates/ecstore/tests/minio_generated_read_test.rs index 621646a81..5467b4729 100644 --- a/crates/ecstore/tests/minio_generated_read_test.rs +++ b/crates/ecstore/tests/minio_generated_read_test.rs @@ -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, } +#[derive(Debug, Deserialize)] +struct RequestRecord { + headers: std::collections::HashMap, +} + #[derive(Default)] struct VecAsyncWriter { bytes: Vec, @@ -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 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 :")) } 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, String) { - let case_dir = require_fixture_case(case_id); +async fn load_fixture_reader_input(case_id: &str) -> (ObjectInfo, Vec, 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, 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, object_info: ObjectInfo, kms_key_b64: String) -> Result, String> { +async fn read_fixture_plaintext( + encrypted: Vec, + object_info: ObjectInfo, + headers: http::HeaderMap, + static_kms_key: Option, + range: Option, +) -> Result, 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::), ], 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, object_info: ObjectInfo, kms } async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, file_info: &FileInfo) -> Vec { + 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); +} diff --git a/crates/ecstore/tests/storage_api.rs b/crates/ecstore/tests/storage_api.rs index d6734a203..65ce7427c 100644 --- a/crates/ecstore/tests/storage_api.rs +++ b/crates/ecstore/tests/storage_api.rs @@ -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, }; diff --git a/crates/rio-v2/tests/minio_fixture_lab/README.md b/crates/rio-v2/tests/minio_fixture_lab/README.md index f20abe205..e88369cdd 100644 --- a/crates/rio-v2/tests/minio_fixture_lab/README.md +++ b/crates/rio-v2/tests/minio_fixture_lab/README.md @@ -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: diff --git a/crates/rio-v2/tests/minio_fixture_lab/capture_rustfs_beta5.py b/crates/rio-v2/tests/minio_fixture_lab/capture_rustfs_beta5.py new file mode 100644 index 000000000..b33e059e1 --- /dev/null +++ b/crates/rio-v2/tests/minio_fixture_lab/capture_rustfs_beta5.py @@ -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()) diff --git a/crates/rio-v2/tests/minio_fixture_lab/test_lab.py b/crates/rio-v2/tests/minio_fixture_lab/test_lab.py index 611713b2b..669932c58 100644 --- a/crates/rio-v2/tests/minio_fixture_lab/test_lab.py +++ b/crates/rio-v2/tests/minio_fixture_lab/test_lab.py @@ -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() diff --git a/crates/scanner/src/scanner.rs b/crates/scanner/src/scanner.rs index 5c4caa2d9..8710c675d 100644 --- a/crates/scanner/src/scanner.rs +++ b/crates/scanner/src/scanner.rs @@ -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(), }) } diff --git a/docs/architecture/README.md b/docs/architecture/README.md index fdacdb2fe..f80ef365c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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) diff --git a/docs/architecture/minio-sse-key-hierarchy.md b/docs/architecture/minio-sse-key-hierarchy.md new file mode 100644 index 000000000..0deb1bec5 --- /dev/null +++ b/docs/architecture/minio-sse-key-hierarchy.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
root KEK; KMS-internal only"] + KMSGEN["KMS GenerateKey
carrying KMS AssociatedData"] + KMSPT["KMS data key plaintext
32 B; parent/external key; in-memory only"] + KMSCT["KMS data key ciphertext
KMS-wrapped; persisted"] + SSES3LOCAL["Local SSE-S3 master key
32 B; server-managed parent key"] + SSECK["SSE-C customer key
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
random 32 B; discarded after derivation"] + OKEY["ObjectKey
content-encryption DEK / CEK
32 B; in-memory only"] + WIV["key-wrapping IV
random 32 B; persisted"] + BIND["domain + algorithm + bucket/object"] + KEK["sealingKey
per-object KEK
32 B; never persisted"] + SEALED["SealedKey.Key
wrapped ObjectKey
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
partKey = HMAC-SHA256(ObjectKey, LE32(partNumber))"] + DARE["DARE 2.0 AEAD packages
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
wrapped ObjectKey + wrapping IV + algorithm
+ KMS key ID/ciphertext/context"] + BODY["Object data region
DARE 2.0 ciphertext stream"] + FORBID["Forbidden to persist or log
KMS data key plaintext
SSE-C customer key
ObjectKey / part key
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 | +| `2–3` (`[2:4]`) | payload length minus one | `LE16(plaintextLength - 1)` | +| `4–15` (`[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-` and `x-minio-internal-`, 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` + 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=:` 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 KiB−1/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`. diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index cd1570eb0..6c13fb5c9 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -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 } diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index 6d3fc37d2..d25b900bf 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -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; + + 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, }) }, diff --git a/rustfs/src/app/storage_api.rs b/rustfs/src/app/storage_api.rs index d3b0b426d..3252323e5 100644 --- a/rustfs/src/app/storage_api.rs +++ b/rustfs/src/app/storage_api.rs @@ -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, diff --git a/rustfs/src/storage/sse.rs b/rustfs/src/storage/sse.rs index faac3f765..4a168f7b7 100644 --- a/rustfs/src/storage/sse.rs +++ b/rustfs/src/storage/sse.rs @@ -102,11 +102,19 @@ use sha2::Sha256; use std::collections::HashMap; use std::sync::{Arc, LazyLock, RwLock}; use tracing::{debug, error}; +#[cfg(feature = "rio-v2")] +use zeroize::Zeroizing; -use super::storage_api::ecstore_sse::{ManagedDekProvider, ManagedSseScheme, managed_dek_provider}; +#[cfg(feature = "rio-v2")] +use super::storage_api::ecstore_sse::PersistedManagedEncryption; +#[cfg(feature = "rio-v2")] +use super::storage_api::ecstore_sse::decrypt_minio_static_kms_dek; +use super::storage_api::ecstore_sse::{ManagedDekProvider, ManagedSseScheme, classify_persisted_managed_encryption}; const LOG_COMPONENT_STORAGE: &str = "storage"; const LOG_SUBSYSTEM_SSE: &str = "sse"; +#[cfg(feature = "rio-v2")] +const SSE_RIO_V2_WRITE_FORMAT_ENV: &str = "RUSTFS_SSE_RIO_V2_WRITE_FORMAT"; const INTERNAL_ENCRYPTION_KEY_ID_HEADER: &str = "x-rustfs-encryption-key-id"; const INTERNAL_ENCRYPTION_KEY_HEADER: &str = "x-rustfs-encryption-key"; @@ -114,6 +122,7 @@ const INTERNAL_ENCRYPTION_IV_HEADER: &str = "x-rustfs-encryption-iv"; const INTERNAL_ENCRYPTION_ALGORITHM_HEADER: &str = "x-rustfs-encryption-algorithm"; const INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER: &str = "x-rustfs-encryption-original-size"; const SSEC_ORIGINAL_SIZE_HEADER: &str = "x-amz-server-side-encryption-customer-original-size"; +const AMZ_SERVER_SIDE_ENCRYPTION_KMS_KEY_ID: &str = "x-amz-server-side-encryption-aws-kms-key-id"; const MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER: &str = "X-Minio-Internal-Encrypted-Multipart"; const MINIO_INTERNAL_ENCRYPTION_IV_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Iv"; const MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm"; @@ -692,6 +701,9 @@ pub struct EncryptionMaterial { pub sse_type: SSEType, pub server_side_encryption: ServerSideEncryption, pub kms_key_id: Option, + /// Provider key ID persisted inside the managed-SSE envelope. Unlike + /// `kms_key_id`, this is also present for KMS-backed SSE-S3. + pub managed_key_id: Option, #[allow(unused)] pub algorithm: SSECustomerAlgorithm, @@ -750,6 +762,13 @@ pub enum EncryptionKeyKind { Object, } +#[cfg(feature = "rio-v2")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SseWriteFormat { + LegacyDirect, + MinioObjectKey, +} + #[derive(Debug, Clone)] pub struct ManagedSealedKey { #[cfg(feature = "rio-v2")] @@ -825,20 +844,20 @@ 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, ApiError> { +fn decrypt_sealed_object_key_payload(sealing_key: &[u8; 32], header: &[u8], sealed_key: &[u8]) -> Result, ApiError> { let nonce = &header[4..16]; let ciphertext = &sealed_key[DARE_HEADER_SIZE..]; let aad = &header[..4]; match header[1] { DARE_CIPHER_AES_256_GCM => { - let cipher = Aes256Gcm::new_from_slice(&sealing_key) + let cipher = Aes256Gcm::new_from_slice(sealing_key) .map_err(|err| ApiError::from(StorageError::other(format!("Invalid AES-GCM sealing key: {err}"))))?; let nonce = Nonce::try_from(nonce) .map_err(|_| ApiError::from(StorageError::other("Invalid sealed object-key package nonce")))?; cipher.decrypt(&nonce, Payload { msg: ciphertext, aad }) } DARE_CIPHER_CHACHA20_POLY1305 => { - let cipher = ChaCha20Poly1305::new_from_slice(&sealing_key) + let cipher = ChaCha20Poly1305::new_from_slice(sealing_key) .map_err(|err| ApiError::from(StorageError::other(format!("Invalid ChaCha20-Poly1305 sealing key: {err}"))))?; let nonce = chacha20poly1305::Nonce::try_from(nonce) .map_err(|_| ApiError::from(StorageError::other("Invalid sealed object-key package nonce")))?; @@ -907,7 +926,7 @@ fn seal_object_key( ) -> Result { let mut iv = [0u8; SEALED_KEY_IV_SIZE]; rand::rng().fill(&mut iv); - let sealing_key = derive_sealing_key(external_key, iv, managed_sse_domain(sse_type), bucket, object)?; + let sealing_key = Zeroizing::new(derive_sealing_key(external_key, iv, managed_sse_domain(sse_type), bucket, object)?); let mut header = [0u8; DARE_HEADER_SIZE]; header[0] = DARE_VERSION_20; @@ -918,7 +937,7 @@ fn seal_object_key( stream_nonce[0] |= 0x80; header[4..16].copy_from_slice(&stream_nonce); - let cipher = Aes256Gcm::new_from_slice(&sealing_key) + let cipher = Aes256Gcm::new_from_slice(sealing_key.as_slice()) .map_err(|err| ApiError::from(StorageError::other(format!("Invalid sealing key: {err}"))))?; let nonce = Nonce::try_from(stream_nonce.as_slice()) .map_err(|_| ApiError::from(StorageError::other("Invalid sealed object-key stream nonce")))?; @@ -955,8 +974,8 @@ fn unseal_object_key( return Err(ApiError::from(StorageError::other("Invalid sealed object-key payload header"))); } - let sealing_key = derive_sealing_key(external_key, sealed.iv, managed_sse_domain(sse_type), bucket, object)?; - let plaintext = decrypt_sealed_object_key_payload(sealing_key, header, &sealed.sealed_key)?; + let sealing_key = Zeroizing::new(derive_sealing_key(external_key, sealed.iv, managed_sse_domain(sse_type), bucket, object)?); + let plaintext = Zeroizing::new(decrypt_sealed_object_key_payload(&sealing_key, header, &sealed.sealed_key)?); let object_key: [u8; 32] = plaintext .as_slice() @@ -1064,23 +1083,21 @@ pub fn encryption_material_to_metadata(material: &EncryptionMaterial) -> Result< } } SSEType::SseS3 | SSEType::SseKms => { - let encrypted_data_key = material - .encrypted_data_key - .as_deref() - .ok_or_else(|| ApiError::from(StorageError::other("managed SSE materials must carry an encrypted data key")))?; metadata.insert( "x-amz-server-side-encryption".to_string(), material.server_side_encryption.as_str().to_string(), ); - let internal_key_id = material - .kms_key_id - .clone() - .unwrap_or_else(|| SSEKMSKeyId::from("default".to_string())); - metadata.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), internal_key_id.clone()); - if matches!(material.sse_type, SSEType::SseKms) { + let internal_key_id = material + .managed_key_id + .clone() + .or_else(|| material.kms_key_id.clone()) + .ok_or_else(|| ApiError::from(StorageError::other("SSE-KMS materials must carry a KMS key ID")))?; + metadata.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), internal_key_id.clone()); metadata.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), internal_key_id); + } else if material.key_kind == EncryptionKeyKind::Direct { + metadata.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), "__default__".to_string()); } if let Some(original_size) = material.original_size { @@ -1101,6 +1118,9 @@ pub fn encryption_material_to_metadata(material: &EncryptionMaterial) -> Result< } if material.key_kind == EncryptionKeyKind::Direct { + let encrypted_data_key = material.encrypted_data_key.as_deref().ok_or_else(|| { + ApiError::from(StorageError::other("direct managed SSE materials must carry an encrypted data key")) + })?; metadata.insert(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), BASE64_STANDARD.encode(encrypted_data_key)); metadata.insert(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode(material.base_nonce)); metadata.insert(INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), material.algorithm.as_str().to_string()); @@ -1113,7 +1133,7 @@ pub fn encryption_material_to_metadata(material: &EncryptionMaterial) -> Result< MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(), ); - if let Some(kms_key_id) = &material.kms_key_id { + if let Some(kms_key_id) = &material.managed_key_id { metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), kms_key_id.to_string()); } match material.sse_type { @@ -1131,32 +1151,15 @@ pub fn encryption_material_to_metadata(material: &EncryptionMaterial) -> Result< } SSEType::SseC => {} } - metadata.insert( - MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), - BASE64_STANDARD.encode(encrypted_data_key), - ); - } else if cfg!(feature = "rio-v2") { - metadata.insert( - MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), - BASE64_STANDARD.encode(material.base_nonce), - ); - metadata.insert( - MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), - MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(), - ); - if let Some(kms_key_id) = &material.kms_key_id { - metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), kms_key_id.to_string()); - } - let encoded_key = BASE64_STANDARD.encode(encrypted_data_key); - match material.sse_type { - SSEType::SseS3 => { - metadata.insert(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(), encoded_key); - } - SSEType::SseKms => { - metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER.to_string(), encoded_key.clone()); - metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), encoded_key); - } - SSEType::SseC => {} + if let Some(encrypted_data_key) = material.encrypted_data_key.as_deref() { + metadata.insert( + MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), + BASE64_STANDARD.encode(encrypted_data_key), + ); + } else if material.managed_key_id.is_some() { + return Err(ApiError::from(StorageError::other( + "KMS-backed object-key materials must carry an encrypted data key", + ))); } } } @@ -1369,6 +1372,14 @@ pub async fn sse_decryption(request: DecryptionRequest<'_>) -> Result( + metadata: &'a HashMap, + field: &'static str, +) -> Result, ApiError> { + get_consistent_metadata_value(metadata, field) + .map_err(|_| ApiError::from(StorageError::other(format!("Conflicting managed encryption metadata for {field}")))) +} + // ============================================================================ // Internal Implementation - SSE-C // ============================================================================ @@ -1381,17 +1392,23 @@ async fn apply_ssec_prepare_encryption_material( sse_key_md5: SSECustomerKeyMD5, ) -> Result { #[cfg(feature = "rio-v2")] - let (key_bytes, base_nonce, key_kind, managed_sealed_key) = if let Some(sse_key) = sse_key { - let validated = validate_ssec_params(SsecParams { - algorithm: algorithm.clone(), - key: sse_key, - key_md5: sse_key_md5.clone(), - })?; - let object_key = derive_object_key(validated.key_bytes)?; - let sealed_key = seal_object_key(object_key, validated.key_bytes, SSEType::SseC, bucket, key)?; - (object_key, [0; 12], EncryptionKeyKind::Object, Some(sealed_key)) + let (key_bytes, base_nonce, key_kind, managed_sealed_key) = if matches!(sse_write_format()?, SseWriteFormat::MinioObjectKey) { + if let Some(sse_key) = sse_key { + let validated = validate_ssec_params(SsecParams { + algorithm: algorithm.clone(), + key: sse_key, + key_md5: sse_key_md5.clone(), + })?; + let object_key = derive_object_key(validated.key_bytes)?; + let sealed_key = seal_object_key(object_key, validated.key_bytes, SSEType::SseC, bucket, key)?; + (object_key, [0; 12], EncryptionKeyKind::Object, Some(sealed_key)) + } else { + ([0; 32], [0; 12], EncryptionKeyKind::Direct, None) + } } else { - ([0; 32], [0; 12], EncryptionKeyKind::Direct, None) + let mut base_nonce = [0u8; 12]; + rand::rng().fill_bytes(&mut base_nonce); + ([0; 32], base_nonce, EncryptionKeyKind::Direct, None) }; #[cfg(not(feature = "rio-v2"))] @@ -1412,6 +1429,7 @@ async fn apply_ssec_prepare_encryption_material( sse_type: SSEType::SseC, server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AES256), kms_key_id: None, + managed_key_id: None, algorithm, key_bytes, base_nonce, @@ -1441,10 +1459,14 @@ async fn apply_ssec_encryption_material( let validated = validate_ssec_params(params)?; #[cfg(feature = "rio-v2")] - let (key_bytes, base_nonce, key_kind, managed_sealed_key) = { + let (key_bytes, base_nonce, key_kind, managed_sealed_key) = if matches!(sse_write_format()?, SseWriteFormat::MinioObjectKey) { let object_key = derive_object_key(validated.key_bytes)?; let sealed_key = seal_object_key(object_key, validated.key_bytes, SSEType::SseC, bucket, key)?; (object_key, [0; 12], EncryptionKeyKind::Object, Some(sealed_key)) + } else { + let mut base_nonce = [0u8; 12]; + rand::rng().fill_bytes(&mut base_nonce); + (validated.key_bytes, base_nonce, EncryptionKeyKind::Direct, None) }; #[cfg(not(feature = "rio-v2"))] @@ -1465,6 +1487,7 @@ async fn apply_ssec_encryption_material( sse_type: SSEType::SseC, server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AES256), kms_key_id: None, + managed_key_id: None, algorithm: validated.algorithm, key_bytes, base_nonce, @@ -1516,16 +1539,22 @@ async fn apply_ssec_decryption_material( let validated = validate_ssec_params(params)?; #[cfg(feature = "rio-v2")] - let (key_bytes, base_nonce, key_kind) = if let Some(sealed_key) = parse_minio_managed_sealed_key(metadata, SSEType::SseC)? { - ( - unseal_object_key(&sealed_key, validated.key_bytes, SSEType::SseC, bucket, key)?, - [0; 12], - EncryptionKeyKind::Object, - ) - } else { - let base_nonce = read_stored_ssec_nonce(metadata, bucket, key); - (validated.key_bytes, base_nonce, EncryptionKeyKind::Direct) - }; + let (key_bytes, base_nonce, key_kind) = + if consistent_managed_metadata(metadata, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)?.is_some() + || consistent_managed_metadata(metadata, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER)?.is_some() + { + let sealed_key = parse_minio_managed_sealed_key(metadata, SSEType::SseC)?.ok_or_else(|| { + ApiError::from(StorageError::other("Incomplete or invalid MinIO SSE-C sealed object-key metadata")) + })?; + ( + unseal_object_key(&sealed_key, validated.key_bytes, SSEType::SseC, bucket, key)?, + [0; 12], + EncryptionKeyKind::Object, + ) + } else { + let base_nonce = read_stored_ssec_nonce(metadata, bucket, key); + (validated.key_bytes, base_nonce, EncryptionKeyKind::Direct) + }; #[cfg(not(feature = "rio-v2"))] let (key_bytes, base_nonce, key_kind) = { @@ -1569,46 +1598,86 @@ async fn apply_managed_encryption_material( } }; - let provider = get_sse_dek_provider(encryption_type).await?; - let kms_key_to_use = match encryption_type { - SSEType::SseS3 => "default".to_string(), - SSEType::SseKms => match kms_key_id { + let (provider, provider_kind) = match encryption_type { + SSEType::SseS3 => get_sse_dek_provider(ManagedProviderMode::SseS3).await?, + SSEType::SseKms => get_sse_dek_provider(ManagedProviderMode::Kms).await?, + SSEType::SseC => { + return Err(ApiError::from(StorageError::other("SSE-C cannot use managed encryption material"))); + } + }; + let kms_key_to_use = match (encryption_type, provider_kind) { + (SSEType::SseS3, ManagedDekProvider::LocalSseS3) => "default".to_string(), + (SSEType::SseS3, ManagedDekProvider::Kms) => provider + .default_kms_key_id() + .ok_or_else(|| sse_not_configured("SSE-S3 requires a configured KMS default key while KMS is running"))?, + (SSEType::SseKms, ManagedDekProvider::Kms) => match kms_key_id { Some(kms_key_id) => kms_key_id, None => provider .default_kms_key_id() .ok_or_else(|| sse_not_configured("SSE-KMS requires a configured KMS key"))?, }, - SSEType::SseC => { - return Err(ApiError::from(StorageError::other("SSE-C cannot use managed encryption material"))); + (_, ManagedDekProvider::MinioKeyValue) => { + return Err(ApiError::from(StorageError::other( + "MinIO key/value provider cannot be selected for managed SSE writes", + ))); } + (SSEType::SseKms, ManagedDekProvider::LocalSseS3) => { + return Err(ApiError::from(StorageError::other("SSE-KMS cannot use the local SSE-S3 provider"))); + } + (SSEType::SseC, _) => unreachable!("SSE-C returns before managed provider selection"), }; let object_context = build_object_encryption_context(bucket, key, ssekms_context.as_ref()); - let (data_key, encrypted_data_key) = provider - .generate_sse_dek(&object_context, &kms_key_to_use) - .await - .map_err(|e| ApiError::from(StorageError::other(format!("Failed to create data key: {e}"))))?; + #[cfg(feature = "rio-v2")] + let write_format = sse_write_format()?; + #[cfg(feature = "rio-v2")] + let local_object_key_parent = matches!( + (encryption_type, provider_kind, write_format), + (SSEType::SseS3, ManagedDekProvider::LocalSseS3, SseWriteFormat::MinioObjectKey) + ); + #[cfg(not(feature = "rio-v2"))] + let local_object_key_parent = false; + + let (parent_key, direct_nonce, encrypted_data_key, managed_key_id) = if local_object_key_parent { + let parent_key = provider + .local_parent_key() + .ok_or_else(|| sse_not_configured("SSE-S3 requires a configured local parent key"))?; + (parent_key, [0u8; 12], None, None) + } else { + let (data_key, encrypted_data_key) = provider + .generate_sse_dek(&object_context, &kms_key_to_use) + .await + .map_err(|e| ApiError::from(StorageError::other(format!("Failed to create data key: {e}"))))?; + ( + data_key.plaintext_key, + data_key.nonce, + Some(encrypted_data_key), + matches!(provider_kind, ManagedDekProvider::Kms).then_some(kms_key_to_use.clone()), + ) + }; let algorithm = server_side_encryption.as_str().to_string(); #[cfg(feature = "rio-v2")] - let (key_bytes, base_nonce, key_kind, managed_sealed_key) = { - let object_key = derive_object_key(data_key.plaintext_key)?; - let sealed_key = seal_object_key(object_key, data_key.plaintext_key, encryption_type, bucket, key)?; + let (key_bytes, base_nonce, key_kind, managed_sealed_key) = if matches!(write_format, SseWriteFormat::MinioObjectKey) { + let object_key = derive_object_key(parent_key)?; + let sealed_key = seal_object_key(object_key, parent_key, encryption_type, bucket, key)?; (object_key, [0u8; 12], EncryptionKeyKind::Object, Some(sealed_key)) + } else { + (parent_key, direct_nonce, EncryptionKeyKind::Direct, None) }; #[cfg(not(feature = "rio-v2"))] - let (key_bytes, base_nonce, key_kind, managed_sealed_key) = - (data_key.plaintext_key, data_key.nonce, EncryptionKeyKind::Direct, None); + let (key_bytes, base_nonce, key_kind, managed_sealed_key) = (parent_key, direct_nonce, EncryptionKeyKind::Direct, None); Ok(EncryptionMaterial { sse_type: encryption_type, server_side_encryption, - kms_key_id: matches!(encryption_type, SSEType::SseKms).then_some(kms_key_to_use), + kms_key_id: matches!(encryption_type, SSEType::SseKms).then_some(kms_key_to_use.clone()), + managed_key_id, algorithm, key_bytes, base_nonce, - encrypted_data_key: Some(encrypted_data_key), + encrypted_data_key, customer_key_md5: None, original_size: Some(content_size), key_kind, @@ -1631,54 +1700,53 @@ async fn apply_managed_decryption_material( "Conflicting managed encryption metadata for {AMZ_SERVER_SIDE_ENCRYPTION}" ))) })?; - let scheme = match stored_algorithm { - Some(ServerSideEncryption::AWS_KMS) => ManagedSseScheme::SseKms, - // RUSTFS_COMPAT_TODO(rustfs-5063): Legacy objects may omit this header. Remove after every referenced legacy DEK is rewrapped. - Some(ServerSideEncryption::AES256) | None => ManagedSseScheme::SseS3, - Some(algorithm) => { - return Err(ApiError::from(StorageError::other(format!( - "Unsupported stored server-side encryption: {algorithm}" - )))); - } + let encrypted_key_b64 = consistent_managed_metadata(metadata, INTERNAL_ENCRYPTION_KEY_HEADER)? + .or(consistent_managed_metadata(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)?); + let encrypted_data_key = match encrypted_key_b64 { + Some(encrypted_key_b64) => BASE64_STANDARD + .decode(encrypted_key_b64) + .map_err(|e| ApiError::from(StorageError::other(format!("Failed to decode encrypted key: {e}"))))?, + None => Vec::new(), }; - let server_side_encryption = stored_algorithm.unwrap_or(ServerSideEncryption::AES256).to_string(); - let normalized_metadata = normalize_managed_metadata(metadata); - + let persisted_format = classify_persisted_managed_encryption(metadata, &encrypted_data_key) + .map_err(|err| ApiError::from(StorageError::other(err.to_string())))?; + #[cfg(not(feature = "rio-v2"))] + if persisted_format.uses_object_key() { + return Err(ApiError::from(StorageError::other( + "MinIO object-key encryption requires the rio-v2 reader", + ))); + } + let scheme = persisted_format.scheme(); let encryption_type = match scheme { ManagedSseScheme::SseS3 => SSEType::SseS3, ManagedSseScheme::SseKms => SSEType::SseKms, }; + let server_side_encryption = stored_algorithm + .unwrap_or(match scheme { + ManagedSseScheme::SseS3 => ServerSideEncryption::AES256, + ManagedSseScheme::SseKms => ServerSideEncryption::AWS_KMS, + }) + .to_string(); #[cfg(feature = "rio-v2")] - let minio_sealed_key = parse_minio_managed_sealed_key(metadata, encryption_type)?; - #[cfg(not(feature = "rio-v2"))] - let minio_sealed_key: Option = None; - - let (encrypted_data_key, iv, algorithm) = if minio_sealed_key.is_some() { - let encrypted_key_b64 = normalized_metadata - .get(INTERNAL_ENCRYPTION_KEY_HEADER) - .or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)) - .ok_or_else(|| ApiError::from(StorageError::other("Missing encrypted key in metadata")))?; - let encrypted_data_key = BASE64_STANDARD - .decode(encrypted_key_b64) - .map_err(|e| ApiError::from(StorageError::other(format!("Failed to decode encrypted key: {e}"))))?; - ( - encrypted_data_key, - Vec::new(), - normalized_metadata - .get(INTERNAL_ENCRYPTION_ALGORITHM_HEADER) - .cloned() - .unwrap_or_else(|| "AES256".to_string()), + let minio_sealed_key = if persisted_format.uses_object_key() { + Some( + parse_minio_managed_sealed_key(metadata, encryption_type)? + .ok_or_else(|| ApiError::from(StorageError::other("Missing valid MinIO sealed object key metadata")))?, ) } else { - let encrypted_key_b64 = normalized_metadata - .get(INTERNAL_ENCRYPTION_KEY_HEADER) - .ok_or_else(|| ApiError::from(StorageError::other("Missing encrypted key in metadata")))?; - let encrypted_data_key = BASE64_STANDARD - .decode(encrypted_key_b64) - .map_err(|e| ApiError::from(StorageError::other(format!("Failed to decode encrypted key: {e}"))))?; - - let iv_b64 = normalized_metadata - .get(INTERNAL_ENCRYPTION_IV_HEADER) + None + }; + let (iv, algorithm) = if persisted_format.uses_object_key() { + ( + Vec::new(), + consistent_managed_metadata(metadata, INTERNAL_ENCRYPTION_ALGORITHM_HEADER)? + .or(consistent_managed_metadata(metadata, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER)?) + .unwrap_or("AES256") + .to_string(), + ) + } else { + let iv_b64 = consistent_managed_metadata(metadata, INTERNAL_ENCRYPTION_IV_HEADER)? + .or(consistent_managed_metadata(metadata, MINIO_INTERNAL_ENCRYPTION_IV_HEADER)?) .ok_or_else(|| ApiError::from(StorageError::other("Missing IV in metadata")))?; let iv = BASE64_STANDARD .decode(iv_b64) @@ -1688,46 +1756,73 @@ async fn apply_managed_decryption_material( return Err(ApiError::from(StorageError::other("Invalid encryption nonce length; expected 12 bytes"))); } - let algorithm = normalized_metadata - .get(INTERNAL_ENCRYPTION_ALGORITHM_HEADER) - .cloned() - .unwrap_or_else(|| "AES256".to_string()); + let algorithm = consistent_managed_metadata(metadata, INTERNAL_ENCRYPTION_ALGORITHM_HEADER)? + .unwrap_or("AES256") + .to_string(); - (encrypted_data_key, iv, algorithm) + (iv, algorithm) }; // Extract KMS key ID from metadata (optional, used for provider context) - let kms_key_id = normalized_metadata - .get(INTERNAL_ENCRYPTION_KEY_ID_HEADER) - .or_else(|| metadata.get("x-amz-server-side-encryption-aws-kms-key-id")) - .cloned() - .unwrap_or_else(|| "default".to_string()); - let has_kms_envelope = rustfs_kms::is_data_key_envelope(&encrypted_data_key); - // RUSTFS_COMPAT_TODO(rustfs-5063): Keep legacy SSE-S3 KMS envelopes readable. Remove after SSE-S3 migration rewraps every referenced legacy DEK. - let provider_kind = managed_dek_provider(scheme, has_kms_envelope); + let kms_key_id = consistent_managed_metadata(metadata, INTERNAL_ENCRYPTION_KEY_ID_HEADER)? + .filter(|value| !value.is_empty()) + .or(consistent_managed_metadata(metadata, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)?.filter(|value| !value.is_empty())) + .or(consistent_managed_metadata(metadata, AMZ_SERVER_SIDE_ENCRYPTION_KMS_KEY_ID)?.filter(|value| !value.is_empty())) + .unwrap_or("default") + .to_string(); + let provider_kind = persisted_format.provider(); let kms_context = if matches!(provider_kind, ManagedDekProvider::Kms) { decode_managed_kms_context(metadata).map_err(|err| ApiError::from(StorageError::other(err.to_string())))? } else { None }; let object_context = build_object_encryption_context(bucket, key, kms_context.as_ref()); - let provider_type = match provider_kind { - ManagedDekProvider::LocalSseS3 => SSEType::SseS3, - ManagedDekProvider::Kms => SSEType::SseKms, - }; - let provider = get_sse_dek_provider(provider_type).await?; #[cfg(feature = "rio-v2")] - let decrypted_data_key = if is_legacy_rustfs_managed_metadata(&normalized_metadata) { + let minio_static_key = if matches!(provider_kind, ManagedDekProvider::Kms) { + decrypt_minio_static_kms_dek(&kms_key_id, &encrypted_data_key, &object_context.encryption_context) + .map_err(|err| ApiError::from(StorageError::other(err.to_string())))? + } else { + None + }; + #[cfg(not(feature = "rio-v2"))] + let minio_static_key: Option<[u8; 32]> = None; + let provider = match provider_kind { + ManagedDekProvider::LocalSseS3 | ManagedDekProvider::MinioKeyValue => { + Some(get_sse_dek_provider(ManagedProviderMode::SseS3).await?.0) + } + ManagedDekProvider::Kms if minio_static_key.is_some() => None, + ManagedDekProvider::Kms => Some(get_sse_dek_provider(ManagedProviderMode::Kms).await?.0), + }; + #[cfg(feature = "rio-v2")] + let decrypted_data_key = if let Some(key) = minio_static_key { + Ok(key) + } else if matches!(persisted_format, PersistedManagedEncryption::MinioSseS3KeyValue) { provider + .as_ref() + .and_then(|provider| provider.local_parent_key()) + .ok_or_else(|| sse_not_configured("MinIO K/V managed SSE requires a configured local parent key")) + } else if matches!( + persisted_format, + PersistedManagedEncryption::LegacySseS3Local + | PersistedManagedEncryption::LegacySseS3Kms + | PersistedManagedEncryption::LegacySseKms + ) { + provider + .as_ref() + .ok_or_else(|| ApiError::from(StorageError::other("Missing managed SSE provider")))? .decrypt_legacy_sse_dek(&encrypted_data_key, &kms_key_id, &object_context) .await } else { provider + .as_ref() + .ok_or_else(|| ApiError::from(StorageError::other("Missing managed SSE provider")))? .decrypt_sse_dek(&encrypted_data_key, &kms_key_id, &object_context) .await }; #[cfg(not(feature = "rio-v2"))] let decrypted_data_key = provider + .as_ref() + .ok_or_else(|| ApiError::from(StorageError::other("Missing managed SSE provider")))? .decrypt_sse_dek(&encrypted_data_key, &kms_key_id, &object_context) .await; let decrypted_data_key = @@ -1802,6 +1897,10 @@ pub trait SseDekProvider: Send + Sync { None } + fn local_parent_key(&self) -> Option<[u8; 32]> { + None + } + /// Generate an SSE data encryption key async fn generate_sse_dek(&self, context: &ObjectEncryptionContext, kms_key_id: &str) -> Result<(DataKey, Vec), ApiError>; @@ -1918,6 +2017,31 @@ impl SseDekProvider for KmsSseDekProvider { } } +#[cfg(feature = "rio-v2")] +fn sse_write_format() -> Result { + #[cfg(test)] + let configured = parse_sse_write_format(get_env_opt_str(SSE_RIO_V2_WRITE_FORMAT_ENV).as_deref()); + #[cfg(not(test))] + let configured = SSE_WRITE_FORMAT.clone(); + + configured.map_err(sse_not_configured) +} + +#[cfg(feature = "rio-v2")] +fn parse_sse_write_format(value: Option<&str>) -> Result { + match value.map(str::trim) { + None | Some("") | Some("legacy-direct") => Ok(SseWriteFormat::LegacyDirect), + Some("minio-object-key") => Ok(SseWriteFormat::MinioObjectKey), + Some(value) => Err(format!( + "{SSE_RIO_V2_WRITE_FORMAT_ENV} must be legacy-direct or minio-object-key, got {value}" + )), + } +} + +#[cfg(all(feature = "rio-v2", not(test)))] +static SSE_WRITE_FORMAT: LazyLock> = + LazyLock::new(|| parse_sse_write_format(get_env_opt_str(SSE_RIO_V2_WRITE_FORMAT_ENV).as_deref())); + // ============================================================================ // Test/Simple DEK Provider // ============================================================================ @@ -2081,6 +2205,10 @@ impl TestSseDekProvider { #[async_trait] impl SseDekProvider for TestSseDekProvider { + fn local_parent_key(&self) -> Option<[u8; 32]> { + Some(self.master_key) + } + async fn generate_sse_dek( &self, _context: &ObjectEncryptionContext, @@ -2113,6 +2241,9 @@ impl SseDekProvider for TestSseDekProvider { _kms_key_id: &str, _context: &ObjectEncryptionContext, ) -> Result<[u8; 32], ApiError> { + if encrypted_dek.is_empty() { + return Err(ApiError::from(StorageError::other("Local SSE-S3 envelope ciphertext must not be empty"))); + } // Decrypt data key with master key let encrypted_dek_str = std::str::from_utf8(encrypted_dek) .map_err(|_| ApiError::from(StorageError::other("Invalid UTF-8 in encrypted DEK")))?; @@ -2148,31 +2279,37 @@ impl Drop for TestKmsSseDekProviderGuard { /// Get or initialize the global SSE DEK provider /// -/// The requested encryption type selects the provider. SSE-S3 never selects -/// KMS, and SSE-KMS never falls back to the local SSE-S3 master key. +/// SSE-S3 prefers the running KMS service and falls back to the local SSE-S3 +/// master key only when KMS is unavailable. SSE-KMS never uses the local key. /// /// # Returns /// Arc to the global SSE DEK provider instance /// /// # Example /// ```rust,ignore -/// let provider = get_sse_dek_provider(SSEType::SseS3).await?; +/// let provider = get_sse_dek_provider(ManagedProviderMode::SseS3).await?; /// let (data_key, encrypted_dek) = provider /// .generate_sse_dek("bucket", "key", "kms-key-id") /// .await?; /// ``` -pub async fn get_sse_dek_provider(encryption_type: SSEType) -> Result, ApiError> { - if matches!(encryption_type, SSEType::SseKms) { - #[cfg(test)] - if let Some(provider) = TEST_KMS_SSE_DEK_PROVIDER - .read() - .map_err(|_| ApiError::from(StorageError::other("Failed to read test KMS SSE DEK provider")))? - .as_ref() - .cloned() - { - return Ok(provider); - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManagedProviderMode { + SseS3, + Kms, +} +async fn get_sse_dek_provider(mode: ManagedProviderMode) -> Result<(Arc, ManagedDekProvider), ApiError> { + #[cfg(test)] + if let Some(provider) = TEST_KMS_SSE_DEK_PROVIDER + .read() + .map_err(|_| ApiError::from(StorageError::other("Failed to read test KMS SSE DEK provider")))? + .as_ref() + .cloned() + { + return Ok((provider, ManagedDekProvider::Kms)); + } + + if matches!(mode, ManagedProviderMode::Kms) { debug!( event = "sse_dek_provider_selected", component = "storage", @@ -2182,12 +2319,22 @@ pub async fn get_sse_dek_provider(encryption_type: SSEType) -> Result) + .map(|provider| (Arc::new(provider) as Arc, ManagedDekProvider::Kms)) .map_err(|_| sse_not_configured("SSE-KMS requires a configured KMS service")); } - if matches!(encryption_type, SSEType::SseC) { - return Err(ApiError::from(StorageError::other("SSE-C cannot use a managed DEK provider"))); + if let Some(service) = runtime_sources::current_encryption_service().await { + debug!( + event = "sse_dek_provider_selected", + component = "storage", + subsystem = "sse_s3", + provider = "kms", + "SSE DEK provider selected" + ); + return Ok(( + Arc::new(KmsSseDekProvider { service }) as Arc, + ManagedDekProvider::Kms, + )); } // Check if already initialized @@ -2197,7 +2344,7 @@ pub async fn get_sse_dek_provider(encryption_type: SSEType) -> Result Result) -> TestK /// Check if the server_side_encryption is a managed SSE type (SSE-S3 or SSE-KMS) #[inline] pub fn is_managed_sse(server_side_encryption: &ServerSideEncryption) -> bool { - matches!(server_side_encryption.as_str(), "AES256" | "aws:kms") + matches!( + server_side_encryption.as_str(), + ServerSideEncryption::AES256 | ServerSideEncryption::AWS_KMS + ) } /// Strip managed encryption metadata from object metadata @@ -2287,9 +2437,7 @@ pub fn strip_managed_encryption_metadata(metadata: &mut HashMap) MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, ]; - for key in KEYS.iter() { - metadata.remove(*key); - } + metadata.retain(|candidate, _| !KEYS.iter().any(|key| candidate.eq_ignore_ascii_case(key))); } pub fn mark_encrypted_multipart_metadata(metadata: &mut HashMap) { @@ -2297,19 +2445,16 @@ pub fn mark_encrypted_multipart_metadata(metadata: &mut HashMap) } pub(crate) fn contains_managed_encryption_metadata(metadata: &HashMap) -> bool { - metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER) - || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER) - || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER) - || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER) - || metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER) -} - -#[cfg(feature = "rio-v2")] -fn is_legacy_rustfs_managed_metadata(metadata: &HashMap) -> bool { - metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER) - && metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER) - && !metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER) - && !metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER) + const MANAGED_KEYS: [&str; 5] = [ + INTERNAL_ENCRYPTION_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, + ]; + metadata + .keys() + .any(|candidate| MANAGED_KEYS.iter().any(|expected| candidate.eq_ignore_ascii_case(expected))) } #[cfg(feature = "rio-v2")] @@ -2317,7 +2462,11 @@ fn parse_minio_managed_sealed_key( metadata: &HashMap, sse_type: SSEType, ) -> Result, ApiError> { - let algorithm = match metadata.get(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER) { + let algorithm = match get_consistent_metadata_value(metadata, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER).map_err(|_| { + ApiError::from(StorageError::other(format!( + "Conflicting managed encryption metadata for {MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER}" + ))) + })? { Some(algorithm) => algorithm, None => return Ok(None), }; @@ -2325,7 +2474,11 @@ fn parse_minio_managed_sealed_key( return Ok(None); } - let iv = match metadata.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER) { + let iv = match get_consistent_metadata_value(metadata, MINIO_INTERNAL_ENCRYPTION_IV_HEADER).map_err(|_| { + ApiError::from(StorageError::other(format!( + "Conflicting managed encryption metadata for {MINIO_INTERNAL_ENCRYPTION_IV_HEADER}" + ))) + })? { Some(iv) => match try_decode_minio_sealing_iv(iv)? { Some(iv) => iv, None => return Ok(None), @@ -2338,7 +2491,9 @@ fn parse_minio_managed_sealed_key( SSEType::SseKms => MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, SSEType::SseC => MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, }; - let sealed_key = match metadata.get(header_key) { + let sealed_key = match get_consistent_metadata_value(metadata, header_key) + .map_err(|_| ApiError::from(StorageError::other(format!("Conflicting managed encryption metadata for {header_key}"))))? + { Some(value) => match try_decode_minio_sealed_key(value)? { Some(sealed_key) => sealed_key, None => return Ok(None), @@ -2349,49 +2504,6 @@ fn parse_minio_managed_sealed_key( Ok(Some(ManagedSealedKey { iv, sealed_key })) } -fn normalize_managed_metadata(metadata: &HashMap) -> HashMap { - let mut normalized = metadata.clone(); - - if !normalized.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER) - && let Some(value) = metadata - .get(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER) - .or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)) - .or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)) - .or_else(|| metadata.get(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)) - { - normalized.insert(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), value.clone()); - } - - if !normalized.contains_key(INTERNAL_ENCRYPTION_IV_HEADER) - && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER) - { - normalized.insert(INTERNAL_ENCRYPTION_IV_HEADER.to_string(), value.clone()); - } - - if !normalized.contains_key(INTERNAL_ENCRYPTION_ALGORITHM_HEADER) - && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER) - { - normalized.insert(INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), value.clone()); - } - - if !normalized.contains_key(INTERNAL_ENCRYPTION_KEY_ID_HEADER) - && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER) - { - normalized.insert(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), value.clone()); - } - - if !normalized.contains_key(RUSTFS_ENCRYPTION_CONTEXT_HEADER) - && let Some(value) = metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER) - && let Ok(decoded) = BASE64_STANDARD.decode(value) - && let Ok(context) = serde_json::from_slice::>(&decoded) - && let Ok(encoded) = serde_json::to_string(&context) - { - normalized.insert(RUSTFS_ENCRYPTION_CONTEXT_HEADER.to_string(), encoded); - } - - normalized -} - // ============================================================================ // SSE-C Functions // ============================================================================ @@ -2531,20 +2643,20 @@ mod tests { #[cfg(feature = "rio-v2")] use super::{ DARE_CIPHER_AES_256_GCM, DARE_CIPHER_CHACHA20_POLY1305, MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM, SEALED_KEY_IV_SIZE, - SEALED_KEY_SIZE, is_legacy_rustfs_managed_metadata, is_supported_sealed_object_key_cipher, + SEALED_KEY_SIZE, SSE_RIO_V2_WRITE_FORMAT_ENV, is_supported_sealed_object_key_cipher, }; use super::{ DecryptionRequest, EncryptionKeyKind, EncryptionMaterial, EncryptionRequest, INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER, INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER, KmsSseDekProvider, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER, MINIO_INTERNAL_ENCRYPTION_IV_HEADER, - MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER, - MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, - MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, - PrepareEncryptionRequest, RUSTFS_ENCRYPTION_CONTEXT_HEADER, SSEC_ORIGINAL_SIZE_HEADER, SSEType, SseDekProvider, - SsecParams, StorageError, TestSseDekProvider, encryption_material_to_metadata, - extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers, - generate_ssec_nonce, is_managed_sse, map_get_object_reader_error, mark_encrypted_multipart_metadata, - normalize_managed_metadata, reset_sse_dek_provider, resolve_effective_kms_key_id, sse_decryption, sse_encryption, + MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_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_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, + MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, PrepareEncryptionRequest, RUSTFS_ENCRYPTION_CONTEXT_HEADER, + SSEC_ORIGINAL_SIZE_HEADER, SSEType, SseDekProvider, SsecParams, StorageError, TestSseDekProvider, + encryption_material_to_metadata, extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, + extract_ssekms_context_from_headers, generate_ssec_nonce, is_managed_sse, map_get_object_reader_error, + mark_encrypted_multipart_metadata, reset_sse_dek_provider, resolve_effective_kms_key_id, sse_decryption, sse_encryption, sse_prepare_encryption, strip_managed_encryption_metadata, validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read, validate_ssec_params, verify_ssec_key_match, }; @@ -2598,14 +2710,37 @@ mod tests { BASE64_STANDARD.encode([0x24u8; 32]) } + #[cfg(feature = "rio-v2")] + #[tokio::test] + async fn test_rio_v2_write_format_is_legacy_by_default_and_rejects_unknown_values() { + let _guard = lock_sse_test_state().await; + async_with_vars([(SSE_RIO_V2_WRITE_FORMAT_ENV, None::<&str>)], async { + assert_eq!( + super::sse_write_format().expect("default write format"), + super::SseWriteFormat::LegacyDirect + ); + }) + .await; + async_with_vars([(SSE_RIO_V2_WRITE_FORMAT_ENV, Some("future-format"))], async { + let err = super::sse_write_format().expect_err("unknown write formats must fail closed"); + assert!(err.message.contains(SSE_RIO_V2_WRITE_FORMAT_ENV)); + }) + .await; + } + struct CountingKmsDekProvider { generate_calls: Arc, decrypt_calls: Arc, plaintext_key: [u8; 32], + default_key_id: Option<&'static str>, } #[async_trait::async_trait] impl SseDekProvider for CountingKmsDekProvider { + fn default_kms_key_id(&self) -> Option { + self.default_key_id.map(str::to_owned) + } + async fn generate_sse_dek( &self, _context: &ObjectEncryptionContext, @@ -3164,32 +3299,36 @@ mod tests { #[cfg(feature = "rio-v2")] #[tokio::test] async fn test_sse_prepare_encryption_ssec_with_customer_key_stores_sealed_key_metadata() { + let _guard = lock_sse_test_state().await; let bucket = "test-bucket"; let key = "test-key"; let customer_key_bytes = [0x24u8; 32]; let customer_key = BASE64_STANDARD.encode(customer_key_bytes); let sse_key_md5 = BASE64_STANDARD.encode(md5::compute(customer_key_bytes).0); - let request = PrepareEncryptionRequest { - bucket, - key, - server_side_encryption: None, - ssekms_key_id: None, - ssekms_context: None, - sse_customer_algorithm: Some("AES256".to_string()), - sse_customer_key: Some(customer_key), - sse_customer_key_md5: Some(sse_key_md5), - }; + async_with_vars([(SSE_RIO_V2_WRITE_FORMAT_ENV, Some("minio-object-key"))], async { + let request = PrepareEncryptionRequest { + bucket, + key, + server_side_encryption: None, + ssekms_key_id: None, + ssekms_context: None, + sse_customer_algorithm: Some("AES256".to_string()), + sse_customer_key: Some(customer_key), + sse_customer_key_md5: Some(sse_key_md5), + }; - let material = sse_prepare_encryption(request) - .await - .expect("prepare should accept full ssec headers") - .expect("ssec metadata should be generated"); - assert_eq!(material.key_kind, EncryptionKeyKind::Object); + let material = sse_prepare_encryption(request) + .await + .expect("prepare should accept full ssec headers") + .expect("ssec metadata should be generated"); + assert_eq!(material.key_kind, EncryptionKeyKind::Object); - let metadata = encryption_material_to_metadata(&material).expect("ssec metadata should serialize"); - assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_IV_HEADER)); - assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)); + let metadata = encryption_material_to_metadata(&material).expect("ssec metadata should serialize"); + assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_IV_HEADER)); + assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)); + }) + .await; } #[test] @@ -3198,6 +3337,7 @@ mod tests { sse_type: SSEType::SseC, server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AES256), kms_key_id: None, + managed_key_id: None, algorithm: SSECustomerAlgorithm::from("AES256".to_string()), key_bytes: [0u8; 32], base_nonce: [0u8; 12], @@ -3305,6 +3445,7 @@ mod tests { sse_type: SSEType::SseKms, server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS), kms_key_id: Some("test-key".to_string()), + managed_key_id: Some("test-key".to_string()), algorithm: SSECustomerAlgorithm::from(ServerSideEncryption::AWS_KMS.to_string()), key_bytes: [7u8; 32], base_nonce: [9u8; 12], @@ -3425,13 +3566,12 @@ mod tests { #[cfg(feature = "rio-v2")] #[test] - fn test_encryption_material_to_metadata_persists_minio_managed_headers() { - let encoded_nonce = BASE64_STANDARD.encode([9u8; 12]); - let encoded_key = BASE64_STANDARD.encode([1u8, 2, 3, 4]); + fn test_encryption_material_to_metadata_keeps_direct_format_legacy_readable() { let metadata = encryption_material_to_metadata(&EncryptionMaterial { sse_type: SSEType::SseKms, server_side_encryption: ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS), kms_key_id: Some("test-key".to_string()), + managed_key_id: Some("test-key".to_string()), algorithm: SSECustomerAlgorithm::from(ServerSideEncryption::AWS_KMS.to_string()), key_bytes: [7u8; 32], base_nonce: [9u8; 12], @@ -3445,29 +3585,24 @@ mod tests { .expect("managed SSE metadata should serialize"); assert_eq!( - metadata.get(MINIO_INTERNAL_ENCRYPTION_IV_HEADER).map(String::as_str), - Some(encoded_nonce.as_str()) + metadata.get(INTERNAL_ENCRYPTION_IV_HEADER).map(String::as_str), + Some(BASE64_STANDARD.encode([9u8; 12]).as_str()) ); assert_eq!( - metadata - .get(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER) - .map(String::as_str), - Some(encoded_key.as_str()) - ); - assert_eq!( - metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER).map(String::as_str), - Some("test-key") - ); - assert_eq!( - metadata.get(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER).map(String::as_str), - Some(MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM) + metadata.get(INTERNAL_ENCRYPTION_KEY_HEADER).map(String::as_str), + Some(BASE64_STANDARD.encode([1u8, 2, 3, 4]).as_str()) ); + assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)); + assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)); } #[tokio::test] async fn test_sse_encryption_omits_kms_header_for_sse_s3_objects() { let _guard = lock_sse_test_state().await; reset_sse_dek_provider(); + if let Some(manager) = rustfs_kms::get_global_kms_service_manager() { + let _ = manager.stop().await; + } let local_sse_master_key = local_sse_master_key_b64(); async_with_vars( @@ -3495,7 +3630,7 @@ mod tests { assert_eq!(material.kms_key_id, None); assert_eq!(metadata.get("x-amz-server-side-encryption").map(String::as_str), Some("AES256")); assert!(!metadata.contains_key("x-amz-server-side-encryption-aws-kms-key-id")); - assert_eq!(metadata.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER).map(String::as_str), Some("default")); + assert_eq!(metadata.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER).map(String::as_str), Some("__default__")); }, ) .await; @@ -3504,7 +3639,51 @@ mod tests { } #[tokio::test] - async fn test_sse_s3_never_calls_available_kms_provider() { + async fn test_sse_s3_does_not_fallback_when_kms_has_no_default_key() { + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + let generate_calls = Arc::new(AtomicUsize::new(0)); + let _provider_guard = super::set_sse_kms_dek_provider_for_test(Arc::new(CountingKmsDekProvider { + generate_calls: generate_calls.clone(), + decrypt_calls: Arc::new(AtomicUsize::new(0)), + plaintext_key: [0x54; 32], + default_key_id: None, + })); + let local_sse_master_key = local_sse_master_key_b64(); + + async_with_vars( + [ + ("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>), + ("RUSTFS_SSE_S3_MASTER_KEY", Some(local_sse_master_key.as_str())), + ], + async { + let err = sse_encryption(EncryptionRequest { + bucket: "test-bucket", + key: "test-key", + server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), + ssekms_key_id: None, + ssekms_context: None, + sse_customer_algorithm: None, + sse_customer_key: None, + sse_customer_key_md5: None, + content_size: 1024, + }) + .await + .expect_err("SSE-S3 must not fall back when KMS is running without a default key"); + + assert_eq!(err.code, S3ErrorCode::InvalidRequest); + assert!(err.message.contains("KMS default key")); + assert_eq!(generate_calls.load(Ordering::SeqCst), 0); + }, + ) + .await; + + reset_sse_dek_provider(); + } + + #[cfg(feature = "rio-v2")] + #[tokio::test] + async fn test_minio_sse_s3_prefers_kms_default_key_when_available() { let _guard = lock_sse_test_state().await; reset_sse_dek_provider(); let generate_calls = Arc::new(AtomicUsize::new(0)); @@ -3513,13 +3692,14 @@ mod tests { generate_calls: generate_calls.clone(), decrypt_calls: decrypt_calls.clone(), plaintext_key: [0x55; 32], + default_key_id: Some("kms-default"), })); - let local_sse_master_key = local_sse_master_key_b64(); async_with_vars( [ ("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>), - ("RUSTFS_SSE_S3_MASTER_KEY", Some(local_sse_master_key.as_str())), + ("RUSTFS_SSE_S3_MASTER_KEY", Some("not-base64")), + (SSE_RIO_V2_WRITE_FORMAT_ENV, Some("minio-object-key")), ], async { let material = sse_encryption(EncryptionRequest { @@ -3534,7 +3714,7 @@ mod tests { content_size: 1024, }) .await - .expect("SSE-S3 encryption should use the local provider") + .expect("MinIO-style SSE-S3 encryption should prefer KMS") .expect("SSE-S3 encryption should return material"); let metadata = encryption_material_to_metadata(&material).expect("SSE-S3 metadata should serialize"); let decrypted = sse_decryption(DecryptionRequest { @@ -3545,12 +3725,19 @@ mod tests { sse_customer_key_md5: None, }) .await - .expect("SSE-S3 decryption should use the local provider") + .expect("MinIO-style SSE-S3 decryption should use KMS") .expect("SSE-S3 decryption should return material"); assert_eq!(decrypted.key_bytes, material.key_bytes); - assert_eq!(generate_calls.load(Ordering::SeqCst), 0); - assert_eq!(decrypt_calls.load(Ordering::SeqCst), 0); + assert_eq!(material.key_kind, EncryptionKeyKind::Object); + assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)); + assert_eq!( + metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER).map(String::as_str), + Some("kms-default") + ); + assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)); + assert_eq!(generate_calls.load(Ordering::SeqCst), 1); + assert_eq!(decrypt_calls.load(Ordering::SeqCst), 1); }, ) .await; @@ -3558,6 +3745,188 @@ mod tests { reset_sse_dek_provider(); } + #[cfg(feature = "rio-v2")] + #[tokio::test] + async fn test_minio_sse_s3_write_succeeds_without_kms() { + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + if let Some(manager) = rustfs_kms::get_global_kms_service_manager() { + let _ = manager.stop().await; + } + let local_sse_master_key = local_sse_master_key_b64(); + + async_with_vars( + [ + ("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>), + ("RUSTFS_SSE_S3_MASTER_KEY", Some(local_sse_master_key.as_str())), + (SSE_RIO_V2_WRITE_FORMAT_ENV, Some("minio-object-key")), + ], + async { + let material = sse_encryption(EncryptionRequest { + bucket: "test-bucket", + key: "test-key", + server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), + ssekms_key_id: None, + ssekms_context: None, + sse_customer_algorithm: None, + sse_customer_key: None, + sse_customer_key_md5: None, + content_size: 1024, + }) + .await + .expect("MinIO-style SSE-S3 should not require KMS") + .expect("SSE-S3 encryption should return material"); + let metadata = encryption_material_to_metadata(&material).expect("SSE-S3 metadata should serialize"); + + assert_eq!(material.key_kind, EncryptionKeyKind::Object); + assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)); + assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)); + assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)); + }, + ) + .await; + + reset_sse_dek_provider(); + } + + #[tokio::test] + async fn test_sse_s3_prefers_kms_default_key_for_legacy_write_format() { + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + let generate_calls = Arc::new(AtomicUsize::new(0)); + let decrypt_calls = Arc::new(AtomicUsize::new(0)); + let _provider_guard = super::set_sse_kms_dek_provider_for_test(Arc::new(CountingKmsDekProvider { + generate_calls: generate_calls.clone(), + decrypt_calls: decrypt_calls.clone(), + plaintext_key: [0x56; 32], + default_key_id: Some("kms-default"), + })); + + async_with_vars( + [ + ("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>), + ("RUSTFS_SSE_S3_MASTER_KEY", Some("not-base64")), + ], + async { + let material = sse_encryption(EncryptionRequest { + bucket: "test-bucket", + key: "test-key", + server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)), + ssekms_key_id: None, + ssekms_context: None, + sse_customer_algorithm: None, + sse_customer_key: None, + sse_customer_key_md5: None, + content_size: 1024, + }) + .await + .expect("legacy SSE-S3 encryption should prefer KMS") + .expect("legacy SSE-S3 encryption should return material"); + let metadata = encryption_material_to_metadata(&material).expect("serialize legacy SSE-S3 metadata"); + + assert_eq!(material.key_kind, EncryptionKeyKind::Direct); + assert_eq!(material.kms_key_id, None); + assert!(metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER)); + assert!(metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER)); + assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER)); + assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)); + assert_eq!(generate_calls.load(Ordering::SeqCst), 1); + assert_eq!(decrypt_calls.load(Ordering::SeqCst), 0); + }, + ) + .await; + reset_sse_dek_provider(); + } + + #[cfg(feature = "rio-v2")] + #[tokio::test] + async fn test_rio_v2_write_gate_covers_sse_kms_legacy_and_minio_formats() { + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + let generate_calls = Arc::new(AtomicUsize::new(0)); + let _provider_guard = super::set_sse_kms_dek_provider_for_test(Arc::new(CountingKmsDekProvider { + generate_calls: generate_calls.clone(), + decrypt_calls: Arc::new(AtomicUsize::new(0)), + plaintext_key: [0x57; 32], + default_key_id: Some("kms-default"), + })); + + for (configured, expected_kind) in [ + (None, EncryptionKeyKind::Direct), + (Some("minio-object-key"), EncryptionKeyKind::Object), + ] { + async_with_vars([(SSE_RIO_V2_WRITE_FORMAT_ENV, configured)], async { + let material = sse_encryption(EncryptionRequest { + bucket: "test-bucket", + key: "test-key", + server_side_encryption: Some(ServerSideEncryption::from_static(ServerSideEncryption::AWS_KMS)), + ssekms_key_id: Some("kms-key".to_string()), + ssekms_context: None, + sse_customer_algorithm: None, + sse_customer_key: None, + sse_customer_key_md5: None, + content_size: 1024, + }) + .await + .expect("SSE-KMS encryption should succeed") + .expect("SSE-KMS encryption should return material"); + let metadata = encryption_material_to_metadata(&material).expect("serialize SSE-KMS metadata"); + + assert_eq!(material.key_kind, expected_kind); + if expected_kind == EncryptionKeyKind::Direct { + assert!(metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER)); + assert!(metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER)); + assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)); + } else { + assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)); + assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)); + assert_eq!( + metadata.get(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER).map(String::as_str), + Some("kms-key") + ); + assert!(!metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER)); + } + }) + .await; + } + + assert_eq!(generate_calls.load(Ordering::SeqCst), 2); + reset_sse_dek_provider(); + } + + #[cfg(feature = "rio-v2")] + #[tokio::test] + async fn test_rio_v2_write_gate_keeps_ssec_legacy_by_default() { + let _guard = lock_sse_test_state().await; + let customer_key_bytes = [0x43u8; 32]; + let customer_key = BASE64_STANDARD.encode(customer_key_bytes); + let customer_key_md5 = BASE64_STANDARD.encode(md5::compute(customer_key_bytes).0); + + async_with_vars([(SSE_RIO_V2_WRITE_FORMAT_ENV, None::<&str>)], async { + let material = sse_encryption(EncryptionRequest { + bucket: "bucket", + key: "object", + server_side_encryption: None, + ssekms_key_id: None, + ssekms_context: None, + sse_customer_algorithm: Some("AES256".to_string()), + sse_customer_key: Some(customer_key), + sse_customer_key_md5: Some(customer_key_md5), + content_size: 4096, + }) + .await + .expect("SSE-C encryption should succeed") + .expect("SSE-C encryption should return material"); + let metadata = encryption_material_to_metadata(&material).expect("serialize SSE-C metadata"); + + assert_eq!(material.key_kind, EncryptionKeyKind::Direct); + assert!(metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER)); + assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)); + assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER)); + }) + .await; + } + #[tokio::test] async fn test_sse_kms_never_falls_back_to_local_sse_s3_provider() { let _guard = lock_sse_test_state().await; @@ -3603,24 +3972,28 @@ mod tests { generate_calls: first_generate_calls.clone(), decrypt_calls: Arc::new(AtomicUsize::new(0)), plaintext_key: [0x51; 32], + default_key_id: Some("first-default"), })); let second_guard = super::set_sse_kms_dek_provider_for_test(Arc::new(CountingKmsDekProvider { generate_calls: second_generate_calls.clone(), decrypt_calls: Arc::new(AtomicUsize::new(0)), plaintext_key: [0x52; 32], + default_key_id: Some("second-default"), })); let context = ObjectEncryptionContext::new("bucket".to_string(), "object".to_string()); - super::get_sse_dek_provider(SSEType::SseKms) + super::get_sse_dek_provider(super::ManagedProviderMode::Kms) .await .expect("second test provider should be selected") + .0 .generate_sse_dek(&context, "key") .await .expect("second test provider should generate a DEK"); drop(second_guard); - super::get_sse_dek_provider(SSEType::SseKms) + super::get_sse_dek_provider(super::ManagedProviderMode::Kms) .await .expect("first test provider should be restored") + .0 .generate_sse_dek(&context, "key") .await .expect("restored test provider should generate a DEK"); @@ -3648,6 +4021,7 @@ mod tests { generate_calls: generate_calls.clone(), decrypt_calls: decrypt_calls.clone(), plaintext_key, + default_key_id: Some("kms-default"), })); let legacy_envelope = serde_json::to_vec(&serde_json::json!({ "key_id": "legacy-data-key", @@ -3714,12 +4088,64 @@ mod tests { reset_sse_dek_provider(); } + #[cfg(feature = "rio-v2")] + #[tokio::test] + async fn test_minio_kms_marker_routes_to_kms_without_local_fallback() { + let _guard = lock_sse_test_state().await; + reset_sse_dek_provider(); + let generate_calls = Arc::new(AtomicUsize::new(0)); + let decrypt_calls = Arc::new(AtomicUsize::new(0)); + let _provider_guard = super::set_sse_kms_dek_provider_for_test(Arc::new(CountingKmsDekProvider { + generate_calls: generate_calls.clone(), + decrypt_calls: decrypt_calls.clone(), + plaintext_key: [0x71; 32], + default_key_id: Some("kms-default"), + })); + let local_envelope = format!("{}:{}", BASE64_STANDARD.encode([0x21u8; 12]), BASE64_STANDARD.encode([0x22u8; 48])); + let metadata = HashMap::from([ + (super::AMZ_SERVER_SIDE_ENCRYPTION.to_string(), ServerSideEncryption::AWS_KMS.to_string()), + ( + MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_string(), + BASE64_STANDARD.encode(local_envelope), + ), + ( + MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER.to_string(), + BASE64_STANDARD.encode([0u8; SEALED_KEY_SIZE]), + ), + (MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([0x23u8; 32])), + ( + MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), + MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(), + ), + (MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "minio-kms-key".to_string()), + ]); + + let err = sse_decryption(DecryptionRequest { + bucket: "test-bucket", + key: "test-key", + metadata: &metadata, + sse_customer_key: None, + sse_customer_key_md5: None, + }) + .await + .expect_err("invalid sealed object-key bytes must fail after KMS routing"); + + assert!(err.message.contains("Unsupported sealed object-key DARE header")); + assert_eq!(generate_calls.load(Ordering::SeqCst), 0); + assert_eq!(decrypt_calls.load(Ordering::SeqCst), 1); + reset_sse_dek_provider(); + } + #[test] fn test_strip_managed_encryption_metadata() { let mut metadata = HashMap::new(); metadata.insert("x-amz-server-side-encryption".to_string(), "aws:kms".to_string()); metadata.insert("x-rustfs-encryption-key".to_string(), "encrypted_key".to_string()); metadata.insert(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER.to_string(), "sealed".to_string()); + metadata.insert( + MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_ascii_lowercase(), + "encrypted-data-key".to_string(), + ); metadata.insert("content-type".to_string(), "text/plain".to_string()); strip_managed_encryption_metadata(&mut metadata); @@ -3727,56 +4153,10 @@ mod tests { assert!(!metadata.contains_key("x-amz-server-side-encryption")); assert!(!metadata.contains_key("x-rustfs-encryption-key")); assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)); + assert!(!metadata.contains_key(&MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER.to_ascii_lowercase())); assert!(metadata.contains_key("content-type")); } - #[cfg(feature = "rio-v2")] - #[test] - fn test_legacy_managed_metadata_excludes_sealed_keys() { - let legacy_metadata = HashMap::from([ - (INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), "encrypted-dek".to_string()), - (INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "nonce".to_string()), - ]); - assert!(is_legacy_rustfs_managed_metadata(&legacy_metadata)); - - let sealed_metadata = HashMap::from([ - (INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), "encrypted-dek".to_string()), - (INTERNAL_ENCRYPTION_IV_HEADER.to_string(), "nonce".to_string()), - (MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(), "sealed-key".to_string()), - ]); - assert!(!is_legacy_rustfs_managed_metadata(&sealed_metadata)); - } - - #[cfg(feature = "rio-v2")] - #[test] - fn test_normalize_managed_metadata_accepts_minio_only_headers() { - let metadata = HashMap::from([ - ( - MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_string(), - BASE64_STANDARD.encode(b"encrypted-key"), - ), - (MINIO_INTERNAL_ENCRYPTION_IV_HEADER.to_string(), BASE64_STANDARD.encode([0x11u8; 12])), - ( - MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), - MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string(), - ), - (MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER.to_string(), "default".to_string()), - ]); - - let normalized = normalize_managed_metadata(&metadata); - - assert_eq!( - normalized.get(INTERNAL_ENCRYPTION_KEY_HEADER), - Some(&BASE64_STANDARD.encode(b"encrypted-key")) - ); - assert_eq!(normalized.get(INTERNAL_ENCRYPTION_IV_HEADER), Some(&BASE64_STANDARD.encode([0x11u8; 12]))); - assert_eq!( - normalized.get(INTERNAL_ENCRYPTION_ALGORITHM_HEADER), - Some(&MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM.to_string()) - ); - assert_eq!(normalized.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER), Some(&"default".to_string())); - } - #[cfg(feature = "rio-v2")] #[tokio::test] async fn test_managed_sse_rio_v2_uses_object_key_metadata_roundtrip() { @@ -3791,6 +4171,7 @@ mod tests { [ ("__RUSTFS_SSE_SIMPLE_CMK", None::<&str>), ("RUSTFS_SSE_S3_MASTER_KEY", Some(local_sse_master_key.as_str())), + (SSE_RIO_V2_WRITE_FORMAT_ENV, Some("minio-object-key")), ], async { let request = EncryptionRequest { @@ -3821,6 +4202,8 @@ mod tests { let sealed_key = metadata .get(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER) .expect("minio sealed key should be stored"); + assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER)); + assert!(!metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER)); assert_eq!(BASE64_STANDARD.decode(sealing_iv).expect("decode iv").len(), SEALED_KEY_IV_SIZE); assert_eq!(BASE64_STANDARD.decode(sealed_key).expect("decode sealed key").len(), SEALED_KEY_SIZE); @@ -3855,48 +4238,91 @@ mod tests { #[cfg(feature = "rio-v2")] #[tokio::test] async fn test_ssec_rio_v2_uses_sealed_object_key_metadata_roundtrip() { + let _guard = lock_sse_test_state().await; let customer_key_bytes = [0x42u8; 32]; let customer_key = BASE64_STANDARD.encode(customer_key_bytes); let customer_key_md5 = BASE64_STANDARD.encode(md5::compute(customer_key_bytes).0); - let material = sse_encryption(EncryptionRequest { - bucket: "bucket", - key: "object", - server_side_encryption: None, - ssekms_key_id: None, - ssekms_context: None, - sse_customer_algorithm: Some("AES256".to_string()), - sse_customer_key: Some(customer_key.clone()), - sse_customer_key_md5: Some(customer_key_md5.clone()), - content_size: 4096, + async_with_vars([(SSE_RIO_V2_WRITE_FORMAT_ENV, Some("minio-object-key"))], async { + let material = sse_encryption(EncryptionRequest { + bucket: "bucket", + key: "object", + server_side_encryption: None, + ssekms_key_id: None, + ssekms_context: None, + sse_customer_algorithm: Some("AES256".to_string()), + sse_customer_key: Some(customer_key.clone()), + sse_customer_key_md5: Some(customer_key_md5.clone()), + content_size: 4096, + }) + .await + .expect("sse-c encryption") + .expect("sse-c material"); + + assert_eq!(material.key_kind, EncryptionKeyKind::Object); + + let metadata = encryption_material_to_metadata(&material).expect("sse-c metadata should serialize"); + assert_eq!( + metadata.get(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER).map(String::as_str), + Some(MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM) + ); + assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_IV_HEADER)); + assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)); + + let decrypted = sse_decryption(DecryptionRequest { + bucket: "bucket", + key: "object", + metadata: &metadata, + sse_customer_key: Some(&customer_key), + sse_customer_key_md5: Some(&customer_key_md5), + }) + .await + .expect("sse-c decryption") + .expect("sse-c decryption material"); + + assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object); + assert_eq!(decrypted.key_bytes, material.key_bytes); }) - .await - .expect("sse-c encryption") - .expect("sse-c material"); + .await; + } - assert_eq!(material.key_kind, EncryptionKeyKind::Object); + #[cfg(feature = "rio-v2")] + #[tokio::test] + async fn test_ssec_rio_v2_rejects_partial_sealed_object_key_metadata() { + let customer_key_bytes = [0x42u8; 32]; + let customer_key = BASE64_STANDARD.encode(customer_key_bytes); + let customer_key_md5 = BASE64_STANDARD.encode(md5::compute(customer_key_bytes).0); - let metadata = encryption_material_to_metadata(&material).expect("sse-c metadata should serialize"); - assert_eq!( - metadata.get(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER).map(String::as_str), - Some(MINIO_INTERNAL_ENCRYPTION_SEAL_ALGORITHM) - ); - assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_IV_HEADER)); - assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)); + 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([ + ( + "x-amz-server-side-encryption-customer-algorithm".to_string(), + ServerSideEncryption::AES256.to_string(), + ), + ("x-amz-server-side-encryption-customer-key-md5".to_string(), customer_key_md5.clone()), + ]); + metadata.extend(partial); - let decrypted = sse_decryption(DecryptionRequest { - bucket: "bucket", - key: "object", - metadata: &metadata, - sse_customer_key: Some(&customer_key), - sse_customer_key_md5: Some(&customer_key_md5), - }) - .await - .expect("sse-c decryption") - .expect("sse-c decryption material"); - - assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object); - assert_eq!(decrypted.key_bytes, material.key_bytes); + let err = sse_decryption(DecryptionRequest { + bucket: "bucket", + key: "object", + metadata: &metadata, + sse_customer_key: Some(&customer_key), + sse_customer_key_md5: Some(&customer_key_md5), + }) + .await + .expect_err("partial MinIO SSE-C metadata must fail closed"); + assert!(err.message.contains("Incomplete or invalid")); + } } #[cfg(feature = "rio-v2")] diff --git a/rustfs/src/storage/storage_api.rs b/rustfs/src/storage/storage_api.rs index 8ad82439d..54829afa3 100644 --- a/rustfs/src/storage/storage_api.rs +++ b/rustfs/src/storage/storage_api.rs @@ -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 = ::GetObjectReader; +pub(crate) type GetObjectSse = ecstore_object::GetObjectSse; pub(crate) type ObjectInfo = ::ObjectInfo; pub(crate) type ObjectOptions = ::ObjectOptions; pub(crate) type PutObjReader = ::PutObjectReader;