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