mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 12:09:12 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4f015990b | |||
| d871f4b3d8 | |||
| 1d305aeb5b | |||
| fd26567cd7 | |||
| 035a6f431a | |||
| 0e4953aeea | |||
| 99ec65247a | |||
| 6feb573f74 | |||
| d6814af2bc | |||
| dbef072bfe | |||
| 420bfa859b |
@@ -630,7 +630,7 @@ impl ReadPlan {
|
|||||||
let material = resolved;
|
let material = resolved;
|
||||||
#[cfg(feature = "rio-v2")]
|
#[cfg(feature = "rio-v2")]
|
||||||
let uses_legacy_encryption = matches!(material.mode, ReadEncryptionMode::Direct { .. });
|
let uses_legacy_encryption = matches!(material.mode, ReadEncryptionMode::Direct { .. });
|
||||||
let is_multipart = is_multipart_encrypted_object(&oi.parts, oi.etag.as_deref());
|
let is_multipart = is_multipart_encrypted_object(&oi.parts, oi.etag.as_deref(), &oi.user_defined);
|
||||||
let recorded_plaintext_size = oi.encryption_original_size()?;
|
let recorded_plaintext_size = oi.encryption_original_size()?;
|
||||||
let plaintext_size = encrypted_plaintext_size(oi, is_multipart, is_compressed, recorded_plaintext_size)?;
|
let plaintext_size = encrypted_plaintext_size(oi, is_multipart, is_compressed, recorded_plaintext_size)?;
|
||||||
let full_plaintext_size =
|
let full_plaintext_size =
|
||||||
@@ -1285,14 +1285,59 @@ fn encrypted_plaintext_size(
|
|||||||
.unwrap_or(oi.size));
|
.unwrap_or(oi.size));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(recorded_plaintext_size.unwrap_or(oi.size))
|
if let Some(recorded) = recorded_plaintext_size {
|
||||||
|
return Ok(recorded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A MinIO single-part object records no plaintext size: MinIO writes
|
||||||
|
// `X-Minio-Internal-actual-size` only for multipart uploads and otherwise
|
||||||
|
// derives the size from the DARE stream itself. Falling back to `oi.size`
|
||||||
|
// hands back the *physical* size, so the reader waits for the encoding
|
||||||
|
// overhead as if it were payload and the body ends short by exactly that
|
||||||
|
// much (rustfs/backlog#1638).
|
||||||
|
if rustfs_utils::http::has_minio_internal_sse_metadata(&oi.user_defined)
|
||||||
|
&& let Some(plaintext) = rustfs_utils::http::dare_v2_decrypted_size(oi.size)
|
||||||
|
{
|
||||||
|
return Ok(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(oi.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_multipart_encrypted_object(parts: &[ObjectPartInfo], etag: Option<&str>) -> bool {
|
/// MinIO's explicit multipart marker, and the internal SSE prefix that
|
||||||
|
/// identifies an object as MinIO-written in the first place.
|
||||||
|
const MINIO_INTERNAL_ENCRYPTED_MULTIPART_KEY: &str = "X-Minio-Internal-Encrypted-Multipart";
|
||||||
|
const MINIO_INTERNAL_SSE_PREFIX: &str = "X-Minio-Internal-Server-Side-Encryption-";
|
||||||
|
|
||||||
|
/// Whether an encrypted object's stream is keyed per part.
|
||||||
|
///
|
||||||
|
/// `user_defined` is consulted before the ETag because MinIO records the answer
|
||||||
|
/// outright, in `X-Minio-Internal-Encrypted-Multipart`. The ETag heuristic
|
||||||
|
/// cannot stand in for it: MinIO stores an *encrypted* ETag for SSE objects —
|
||||||
|
/// 96 characters for a single-part upload, not the 32 of a plain MD5 — so the
|
||||||
|
/// length test reads such an object as multipart and derives a per-part key for
|
||||||
|
/// a stream that was sealed with the object key itself, which then fails
|
||||||
|
/// authentication (rustfs/backlog#1638).
|
||||||
|
fn is_multipart_encrypted_object(parts: &[ObjectPartInfo], etag: Option<&str>, user_defined: &HashMap<String, String>) -> bool {
|
||||||
if parts.len() > 1 {
|
if parts.len() > 1 {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user_defined
|
||||||
|
.keys()
|
||||||
|
.any(|key| key.eq_ignore_ascii_case(MINIO_INTERNAL_ENCRYPTED_MULTIPART_KEY))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// On a MinIO-written object the marker's absence is as informative as its
|
||||||
|
// presence, so the ETag is not consulted at all.
|
||||||
|
if user_defined
|
||||||
|
.keys()
|
||||||
|
.any(|key| rustfs_utils::http::starts_with_ignore_ascii_case(key, MINIO_INTERNAL_SSE_PREFIX))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
etag.map(|etag| etag.trim_matches('"').len() != 32).unwrap_or(false)
|
etag.map(|etag| etag.trim_matches('"').len() != 32).unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -614,7 +614,20 @@ impl ObjectInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn decrypted_size(&self) -> std::io::Result<i64> {
|
pub fn decrypted_size(&self) -> std::io::Result<i64> {
|
||||||
Ok(self.encryption_original_size()?.unwrap_or(self.size))
|
if let Some(recorded) = self.encryption_original_size()? {
|
||||||
|
return Ok(recorded);
|
||||||
|
}
|
||||||
|
// A MinIO single-part object records no plaintext size — MinIO writes one
|
||||||
|
// only for multipart uploads — so `self.size` here is the *physical*
|
||||||
|
// size, encoding overhead included. Reporting that as the object's
|
||||||
|
// length overstates it by exactly that overhead, which is what a client
|
||||||
|
// sees as Content-Length (rustfs/backlog#1638).
|
||||||
|
if rustfs_utils::http::has_minio_internal_sse_metadata(&self.user_defined)
|
||||||
|
&& let Some(plaintext) = rustfs_utils::http::dare_v2_decrypted_size(self.size)
|
||||||
|
{
|
||||||
|
return Ok(plaintext);
|
||||||
|
}
|
||||||
|
Ok(self.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_actual_size(&self) -> std::io::Result<i64> {
|
pub fn get_actual_size(&self) -> std::io::Result<i64> {
|
||||||
|
|||||||
@@ -324,11 +324,18 @@ impl Default for LocalConfig {
|
|||||||
/// wraps data encryption keys — there is no HMAC-SHA256 derivation step — and
|
/// wraps data encryption keys — there is no HMAC-SHA256 derivation step — and
|
||||||
/// each wrapped DEK is serialized as a RustFS `DataKeyEnvelope` JSON blob.
|
/// each wrapped DEK is serialized as a RustFS `DataKeyEnvelope` JSON blob.
|
||||||
///
|
///
|
||||||
/// This mirrors the *concept* of MinIO's builtin/static single-key KMS, but is
|
/// This mirrors the *concept* of MinIO's builtin/static single-key KMS but is
|
||||||
/// not wire-compatible with it: MinIO wraps DEKs in a different (`{"aead": ...}`)
|
/// not wire-compatible with it. MinIO seals a DEK as `sealed || iv[16] ||
|
||||||
/// blob that this backend neither produces nor accepts, so KMS ciphertext
|
/// nonce[12]` under a per-ciphertext key derived from the master secret, with
|
||||||
/// written by MinIO cannot be opened here. Reading MinIO-written SSE objects is
|
/// a legacy JSON encoding of the same layout; this backend neither produces
|
||||||
/// tracked separately in rustfs/backlog#1638.
|
/// nor accepts either, so pointing it at MinIO's master key does **not** make
|
||||||
|
/// MinIO-written objects readable through it.
|
||||||
|
///
|
||||||
|
/// Reading MinIO-written SSE objects is a property of the object read path, not
|
||||||
|
/// of this backend: that path decodes MinIO's format directly, keyed by
|
||||||
|
/// `RUSTFS_SSE_S3_MASTER_KEY`. See the migration section of
|
||||||
|
/// `docs/operations/kms-backend-security.md` for which object shapes are
|
||||||
|
/// covered, and rustfs/backlog#1638 for the remainder.
|
||||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct StaticConfig {
|
pub struct StaticConfig {
|
||||||
/// Key identifier (name) for the single configured key
|
/// Key identifier (name) for the single configured key
|
||||||
|
|||||||
@@ -255,6 +255,10 @@ pub use cache::KmsCacheStats;
|
|||||||
pub use config::*;
|
pub use config::*;
|
||||||
pub use deletion_worker::DeletionReferenceChecker;
|
pub use deletion_worker::DeletionReferenceChecker;
|
||||||
pub use encryption::is_data_key_envelope;
|
pub use encryption::is_data_key_envelope;
|
||||||
|
// Re-exported so the object layer binds encryption context exactly the way the
|
||||||
|
// KMS backends do. A second canonicalization is how the object layer once
|
||||||
|
// serialized a HashMap directly while the Static backend already sorted keys.
|
||||||
|
pub use encryption::context_aad;
|
||||||
pub use error::{KmsError, KmsUnavailableError, Result};
|
pub use error::{KmsError, KmsUnavailableError, Result};
|
||||||
pub use key_impact::{KeyImpactReport, KeyReference, KeyReferenceKind, ReferenceCompleteness, ReferenceCoverage, ReferenceScope};
|
pub use key_impact::{KeyImpactReport, KeyReference, KeyReferenceKind, ReferenceCompleteness, ReferenceCoverage, ReferenceScope};
|
||||||
pub use manager::KmsManager;
|
pub use manager::KmsManager;
|
||||||
|
|||||||
@@ -53,6 +53,50 @@ pub const MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER: &str = "X-Minio-Internal-
|
|||||||
pub const MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key";
|
pub const MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key";
|
||||||
pub const MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Context";
|
pub const MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER: &str = "X-Minio-Internal-Server-Side-Encryption-Context";
|
||||||
|
|
||||||
|
/// Plaintext length of a DARE v2 stream of `ciphertext_size` bytes.
|
||||||
|
///
|
||||||
|
/// The stream is a sequence of packages, each a 16-byte header, up to 64 KiB of
|
||||||
|
/// payload, and a 16-byte tag; only the last may be short. This is the same
|
||||||
|
/// arithmetic as MinIO's `sio.DecryptedSize`, and it is the only way to size a
|
||||||
|
/// MinIO single-part object: MinIO writes an explicit plaintext size only for
|
||||||
|
/// multipart uploads and otherwise derives it from the stream.
|
||||||
|
///
|
||||||
|
/// Returns `None` for a size no DARE stream can have — a final package carrying
|
||||||
|
/// overhead but no payload — so a malformed object is not silently assigned a
|
||||||
|
/// plausible length.
|
||||||
|
pub fn dare_v2_decrypted_size(ciphertext_size: i64) -> Option<i64> {
|
||||||
|
const HEADER_LEN: i64 = 16;
|
||||||
|
const TAG_LEN: i64 = 16;
|
||||||
|
const MAX_PAYLOAD: i64 = 64 * 1024;
|
||||||
|
const PACKAGE_LEN: i64 = HEADER_LEN + MAX_PAYLOAD + TAG_LEN;
|
||||||
|
|
||||||
|
if ciphertext_size < 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if ciphertext_size == 0 {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let full_packages = ciphertext_size / PACKAGE_LEN;
|
||||||
|
let remainder = ciphertext_size % PACKAGE_LEN;
|
||||||
|
if remainder == 0 {
|
||||||
|
return Some(full_packages * MAX_PAYLOAD);
|
||||||
|
}
|
||||||
|
if remainder <= HEADER_LEN + TAG_LEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(full_packages * MAX_PAYLOAD + remainder - HEADER_LEN - TAG_LEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the metadata was written by MinIO's SSE path.
|
||||||
|
pub fn has_minio_internal_sse_metadata<S: std::hash::BuildHasher>(
|
||||||
|
metadata: &std::collections::HashMap<String, String, S>,
|
||||||
|
) -> bool {
|
||||||
|
metadata
|
||||||
|
.keys()
|
||||||
|
.any(|key| super::starts_with_ignore_ascii_case(key, "x-minio-internal-server-side-encryption-"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Reserved RustFS-branded twin of the MinIO-internal SSE key family.
|
/// Reserved RustFS-branded twin of the MinIO-internal SSE key family.
|
||||||
///
|
///
|
||||||
/// No RustFS writer emits these keys today — the SSE writer persists the
|
/// No RustFS writer emits these keys today — the SSE writer persists the
|
||||||
@@ -323,6 +367,31 @@ mod tests {
|
|||||||
assert!(!format!("{projected:?}").contains("secret-key"));
|
assert!(!format!("{projected:?}").contains("secret-key"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dare_v2_size_inverts_the_package_layout() {
|
||||||
|
const PACKAGE: i64 = 16 + 64 * 1024 + 16;
|
||||||
|
|
||||||
|
// Exactly one full package, and exactly two.
|
||||||
|
assert_eq!(dare_v2_decrypted_size(PACKAGE), Some(64 * 1024));
|
||||||
|
assert_eq!(dare_v2_decrypted_size(2 * PACKAGE), Some(128 * 1024));
|
||||||
|
|
||||||
|
// The shape that motivated this: a 64 KiB object stored as 65568 bytes.
|
||||||
|
assert_eq!(dare_v2_decrypted_size(65568), Some(65536));
|
||||||
|
|
||||||
|
// A short trailing package carries its own header and tag.
|
||||||
|
assert_eq!(dare_v2_decrypted_size(PACKAGE + 16 + 1 + 16), Some(64 * 1024 + 1));
|
||||||
|
assert_eq!(dare_v2_decrypted_size(16 + 1 + 16), Some(1));
|
||||||
|
|
||||||
|
assert_eq!(dare_v2_decrypted_size(0), Some(0));
|
||||||
|
|
||||||
|
// Sizes no DARE stream can have: overhead with no payload behind it.
|
||||||
|
// Refused rather than rounded into a plausible length.
|
||||||
|
assert_eq!(dare_v2_decrypted_size(1), None);
|
||||||
|
assert_eq!(dare_v2_decrypted_size(32), None);
|
||||||
|
assert_eq!(dare_v2_decrypted_size(PACKAGE + 32), None);
|
||||||
|
assert_eq!(dare_v2_decrypted_size(-1), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transport_metadata_roundtrip_restores_stored_keys() {
|
fn transport_metadata_roundtrip_restores_stored_keys() {
|
||||||
let mut headers = http::HeaderMap::new();
|
let mut headers = http::HeaderMap::new();
|
||||||
|
|||||||
@@ -14,24 +14,43 @@ For how the Vault backends authenticate (static token, AppRole, Kubernetes, Vaul
|
|||||||
| Vault Transit | `VaultTransit` | Key-encryption keys never leave Vault; only Transit ciphertext is visible outside | Vault Transit engine (cryptographic isolation) | Delegated to Vault storage | Via Vault Transit key versioning | Deployments that need key material to be unreadable through storage APIs |
|
| Vault Transit | `VaultTransit` | Key-encryption keys never leave Vault; only Transit ciphertext is visible outside | Vault Transit engine (cryptographic isolation) | Delegated to Vault storage | Via Vault Transit key versioning | Deployments that need key material to be unreadable through storage APIs |
|
||||||
| AWS KMS | `AWS` (alias `AwsKms`) | Key material never leaves AWS KMS; RustFS mirrors no key state | AWS KMS (cryptographic isolation) + IAM | Delegated to AWS | On-demand `RotateKeyOnDemand`; prior backing keys stay usable for decryption | Deployments already rooted in AWS IAM that want AWS as the cryptographic root — read [AWS KMS: deviations from the shared backend contract](#aws-kms-deviations-from-the-shared-backend-contract) first |
|
| AWS KMS | `AWS` (alias `AwsKms`) | Key material never leaves AWS KMS; RustFS mirrors no key state | AWS KMS (cryptographic isolation) + IAM | Delegated to AWS | On-demand `RotateKeyOnDemand`; prior backing keys stay usable for decryption | Deployments already rooted in AWS IAM that want AWS as the cryptographic root — read [AWS KMS: deviations from the shared backend contract](#aws-kms-deviations-from-the-shared-backend-contract) first |
|
||||||
|
|
||||||
## Migrating from MinIO: encrypted objects do not carry over
|
## Migrating from MinIO: what carries over, and what does not
|
||||||
|
|
||||||
> **Warning: RustFS does not currently support reading objects that MinIO encrypted.**
|
> **Read this before moving data.** Some MinIO-encrypted objects are readable by RustFS and some are not, and the boundary is not where you would guess. Verify against a sample of your own objects rather than assuming either answer. Tracked in rustfs/backlog#1638.
|
||||||
> This applies to SSE-S3, SSE-KMS, and SSE-C, in every released binary and container image, and it holds regardless of which KMS backend you configure. Configuring the `Static` backend with the same key material MinIO used does **not** make those objects readable — MinIO wraps data keys in a different envelope format that no RustFS backend produces or accepts (`crates/kms/src/config.rs:304-308`). Plan for this **before** moving data. Tracked in rustfs/backlog#1638.
|
|
||||||
|
|
||||||
The read does fail closed — ciphertext is never served as plaintext. MinIO's internal encryption headers mark the object as encrypted (`crates/utils/src/http/header_compat.rs:50-67`), so the read path demands encryption material and refuses when none resolves (`crates/ecstore/src/object_api/readers.rs:559-568`). Two properties still make the problem easy to discover late:
|
Support is stated per shape below because that is how far it has been *measured* — against fixtures captured from a real MinIO server (`minio/minio:RELEASE.2025-09-07T16-13-09Z`), not inferred from the code:
|
||||||
|
|
||||||
|
| MinIO object | RustFS read | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSE-S3, multipart | **Yes** | `reads_minio_generated_sse_s3_multipart_fixture` |
|
||||||
|
| SSE-KMS, multipart | **Yes** | `reads_minio_generated_sse_kms_multipart_fixture` |
|
||||||
|
| SSE-C, multipart | **Yes** | `reads_minio_generated_sse_c_multipart_fixture` |
|
||||||
|
| SSE-S3, single-part | **Yes** | `reads_minio_generated_sse_s3_singlepart_fixture` |
|
||||||
|
| SSE-KMS, single-part | **Yes** | `reads_minio_generated_sse_kms_singlepart_fixture` |
|
||||||
|
| SSE-C, single-part | **Unverified** | No fixture coverage |
|
||||||
|
| Sealed by KES, a KMS plugin, or MinKMS | **No**, and not planned | Re-encrypt at the source before migrating |
|
||||||
|
|
||||||
|
SSE-C needs no KMS at all: the customer supplies the key on each request, exactly as against MinIO. Note that a MinIO SSE-C object stores no customer-key MD5, so the usual early "these parameters do not match" rejection cannot fire for it — a wrong key is refused by the decryption itself instead, which is a different error but the same outcome.
|
||||||
|
|
||||||
|
Reading a supported *managed* object (SSE-S3, SSE-KMS) requires RustFS to hold the same master key MinIO used, supplied through `RUSTFS_SSE_S3_MASTER_KEY` (the production entry point, exercised by `reads_minio_generated_sse_s3_fixture_through_production_master_key_env`). MinIO's builtin KMS derives a per-ciphertext sealing key from that master secret, so the *same* secret is required — not merely an equivalently configured backend.
|
||||||
|
|
||||||
|
**"Unverified" means unknown, not broken.** The remaining row has no fixture coverage, so it has never been read in a test either way. Do not read the table's "Yes" rows as covering it.
|
||||||
|
|
||||||
|
Whatever the table says, verify before you commit: **read a sample of encrypted objects, not just their listings.** A read that is not supported fails closed — ciphertext is never served as plaintext — but two properties still make it easy to discover late:
|
||||||
|
|
||||||
- **The error does not say what happened.** It surfaces as a 500 `InternalError`, which reads as a RustFS fault rather than "another implementation encrypted this object".
|
- **The error does not say what happened.** It surfaces as a 500 `InternalError`, which reads as a RustFS fault rather than "another implementation encrypted this object".
|
||||||
- **Surrounding metadata migrates fine.** The object's `xl.meta` parses, so encrypted objects list and HEAD normally and report plausible sizes. The failure appears only when something reads the payload.
|
- **Surrounding metadata migrates fine.** The object's `xl.meta` parses, so encrypted objects list and HEAD normally and report plausible sizes. The failure appears only when something reads the payload.
|
||||||
|
|
||||||
Read a sample of encrypted objects, not just their listings, before decommissioning the MinIO deployment.
|
For any shape that does not read, the options are unchanged:
|
||||||
|
|
||||||
Current options for a migration whose source contains encrypted objects:
|
|
||||||
|
|
||||||
- Decrypt on the MinIO side first, migrate plaintext, then let RustFS re-encrypt with its own KMS.
|
- Decrypt on the MinIO side first, migrate plaintext, then let RustFS re-encrypt with its own KMS.
|
||||||
- Copy through the S3 API rather than moving drives — MinIO decrypts on read, and RustFS encrypts on write. This re-encrypts rather than preserving ciphertext and costs a full data transfer.
|
- Copy through the S3 API rather than moving drives — MinIO decrypts on read, and RustFS encrypts on write. This re-encrypts rather than preserving ciphertext and costs a full data transfer.
|
||||||
- Leave encrypted objects on MinIO and migrate only unencrypted data.
|
- Leave encrypted objects on MinIO and migrate only unencrypted data.
|
||||||
|
|
||||||
|
### The reverse direction does not work
|
||||||
|
|
||||||
|
MinIO cannot read objects RustFS encrypted, and that is a deliberate, documented position rather than a gap awaiting a fix. RustFS fills MinIO's metadata slots — the sealed-key and IV headers are MinIO-shaped — but the data key in `X-Minio-Internal-Server-Side-Encryption-S3-Kms-Sealed-Key` is a RustFS envelope, which MinIO's KMS cannot open. **Treat the MinIO-branded headers on a RustFS-written object as RustFS-internal.** Their presence is not a statement that MinIO can read the object, and no coexistence plan should assume two-way reads.
|
||||||
|
|
||||||
Inventory the source before choosing: bucket default-encryption settings mean objects can be encrypted without any client having sent SSE headers.
|
Inventory the source before choosing: bucket default-encryption settings mean objects can be encrypted without any client having sent SSE headers.
|
||||||
|
|
||||||
The same limitation applies in reverse — objects RustFS encrypts are not readable by MinIO. For the code-level breakdown of which seams block each SSE mode, see [MinIO file-format interoperability, Part C](../architecture/minio-file-format-compat.md#part-c--server-side-encryption-sse).
|
The same limitation applies in reverse — objects RustFS encrypts are not readable by MinIO. For the code-level breakdown of which seams block each SSE mode, see [MinIO file-format interoperability, Part C](../architecture/minio-file-format-compat.md#part-c--server-side-encryption-sse).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::fs;
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use super::sse::SseObjectEncryptionResolver;
|
use super::sse::{SseObjectEncryptionResolver, reset_sse_dek_provider};
|
||||||
use super::storage_api::ecstore_test_support::{
|
use super::storage_api::ecstore_test_support::{
|
||||||
DiskAPI as _, DiskOption, Endpoint, Erasure, GetObjectReader, ObjectInfo, ObjectOptions, create_bitrot_reader, new_disk,
|
DiskAPI as _, DiskOption, Endpoint, Erasure, GetObjectReader, ObjectInfo, ObjectOptions, create_bitrot_reader, new_disk,
|
||||||
};
|
};
|
||||||
@@ -92,6 +92,37 @@ fn object_xl_meta_path(case_dir: &Path, manifest: &ManifestRecord) -> PathBuf {
|
|||||||
case_dir.join("backend").join(relative)
|
case_dir.join("backend").join(relative)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Metadata as each disk stored it, indexed by disk position.
|
||||||
|
///
|
||||||
|
/// The object's primary `FileInfo` is read from one disk, but inline shard data
|
||||||
|
/// and bitrot checksums are per disk, so both have to come from the disk they
|
||||||
|
/// belong to.
|
||||||
|
fn load_per_disk_file_info(case_dir: &Path, manifest: &ManifestRecord, disk_count: usize) -> Vec<Option<FileInfo>> {
|
||||||
|
(0..disk_count)
|
||||||
|
.map(|idx| {
|
||||||
|
let path = case_dir
|
||||||
|
.join("backend")
|
||||||
|
.join(format!("disk{}", idx + 1))
|
||||||
|
.join(&manifest.bucket)
|
||||||
|
.join(&manifest.object)
|
||||||
|
.join("xl.meta");
|
||||||
|
let bytes = fs::read(&path).ok()?;
|
||||||
|
get_file_info(
|
||||||
|
&bytes,
|
||||||
|
&manifest.bucket,
|
||||||
|
&manifest.object,
|
||||||
|
"",
|
||||||
|
FileInfoOpts {
|
||||||
|
data: true,
|
||||||
|
include_free_versions: true,
|
||||||
|
include_part_checksums: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn load_file_info(case_dir: &Path, manifest: &ManifestRecord) -> FileInfo {
|
fn load_file_info(case_dir: &Path, manifest: &ManifestRecord) -> FileInfo {
|
||||||
let xl_meta_path = object_xl_meta_path(case_dir, manifest);
|
let xl_meta_path = object_xl_meta_path(case_dir, manifest);
|
||||||
let xl_meta = fs::read(&xl_meta_path).unwrap_or_else(|err| panic!("read {}: {err}", xl_meta_path.display()));
|
let xl_meta = fs::read(&xl_meta_path).unwrap_or_else(|err| panic!("read {}: {err}", xl_meta_path.display()));
|
||||||
@@ -131,6 +162,13 @@ async fn load_fixture_reader_input(case_id: &str) -> (ObjectInfo, Vec<u8>, Strin
|
|||||||
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, kms_key_b64: String) -> Result<Vec<u8>, String> {
|
||||||
let object_size = object_info.size;
|
let object_size = object_info.size;
|
||||||
|
|
||||||
|
// The DEK provider is cached process-wide once built, so without this reset
|
||||||
|
// a case that ran earlier in the same binary keeps serving its master key to
|
||||||
|
// every later case — which silently turned the wrong-key negative below into
|
||||||
|
// a test that could not fail. Reset before each read so the provider is
|
||||||
|
// built from the key this case actually configured.
|
||||||
|
reset_sse_dek_provider();
|
||||||
|
|
||||||
async_with_vars(
|
async_with_vars(
|
||||||
[
|
[
|
||||||
("__RUSTFS_SSE_SIMPLE_CMK", Some(kms_key_b64)),
|
("__RUSTFS_SSE_SIMPLE_CMK", Some(kms_key_b64)),
|
||||||
@@ -187,10 +225,20 @@ async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, fil
|
|||||||
.unwrap_or_else(|err| panic!("open fixture disk {disk_number}: {err}"));
|
.unwrap_or_else(|err| panic!("open fixture disk {disk_number}: {err}"));
|
||||||
disks.push(disk);
|
disks.push(disk);
|
||||||
}
|
}
|
||||||
|
// Pair each disk with the metadata that disk stored, then place both at the
|
||||||
|
// erasure block slot that metadata claims — the shuffle the read path does
|
||||||
|
// before it reads anything (`shuffle_disks_and_parts_metadata_by_index`).
|
||||||
|
// The harness needs the per-disk metadata, not just the per-disk disk: below
|
||||||
|
// the small-file threshold an object has no part file and each disk carries
|
||||||
|
// its shard inline in its own xl.meta, and the bitrot checksums are per disk
|
||||||
|
// too.
|
||||||
|
let per_disk_meta = load_per_disk_file_info(case_dir, manifest, disks.len());
|
||||||
let mut disk_order = vec![None; disks.len()];
|
let mut disk_order = vec![None; disks.len()];
|
||||||
|
let mut meta_order: Vec<Option<FileInfo>> = vec![None; disks.len()];
|
||||||
for (idx, disk) in disks.iter().enumerate() {
|
for (idx, disk) in disks.iter().enumerate() {
|
||||||
let block_index = file_info.erasure.distribution[idx];
|
let block_index = file_info.erasure.distribution[idx];
|
||||||
disk_order[block_index - 1] = Some(disk);
|
disk_order[block_index - 1] = Some(disk);
|
||||||
|
meta_order[block_index - 1] = per_disk_meta.get(idx).cloned().flatten();
|
||||||
}
|
}
|
||||||
let data_dir = file_info
|
let data_dir = file_info
|
||||||
.data_dir
|
.data_dir
|
||||||
@@ -199,13 +247,21 @@ async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, fil
|
|||||||
|
|
||||||
let mut encrypted = Vec::new();
|
let mut encrypted = Vec::new();
|
||||||
for part in &file_info.parts {
|
for part in &file_info.parts {
|
||||||
let checksum_info = file_info.erasure.get_checksum_info(part.number);
|
let primary_checksum_info = file_info.erasure.get_checksum_info(part.number);
|
||||||
let path = format!("{}/{}/part.{}", manifest.object, data_dir, part.number);
|
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 = file_info.erasure.shard_file_size(part.size as i64);
|
||||||
let mut readers = Vec::with_capacity(disks.len());
|
let mut readers = Vec::with_capacity(disks.len());
|
||||||
for (idx, disk) in disk_order.iter().enumerate() {
|
for (idx, disk) in disk_order.iter().enumerate() {
|
||||||
|
// Below MinIO's small-file threshold there is no part file at all:
|
||||||
|
// each disk keeps its erasure shard inline in its own xl.meta, with
|
||||||
|
// the same bitrot framing the reader expects from a file.
|
||||||
|
let disk_meta = meta_order[idx].as_ref();
|
||||||
|
let inline_shard = disk_meta.and_then(|meta| meta.data.as_deref());
|
||||||
|
let checksum_info = disk_meta
|
||||||
|
.map(|meta| meta.erasure.get_checksum_info(part.number))
|
||||||
|
.unwrap_or_else(|| primary_checksum_info.clone());
|
||||||
let reader = create_bitrot_reader(
|
let reader = create_bitrot_reader(
|
||||||
None,
|
inline_shard,
|
||||||
*disk,
|
*disk,
|
||||||
&manifest.bucket,
|
&manifest.bucket,
|
||||||
&path,
|
&path,
|
||||||
@@ -253,6 +309,133 @@ async fn reads_minio_generated_sse_kms_multipart_fixture() {
|
|||||||
assert_fixture_round_trip("sse-kms-multipart-8m", 8 * 1024 * 1024).await;
|
assert_fixture_round_trip("sse-kms-multipart-8m", 8 * 1024 * 1024).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read an SSE-C fixture, supplying the customer key the way a client does.
|
||||||
|
///
|
||||||
|
/// SSE-C needs no KMS at all — the key arrives on the request — so this path
|
||||||
|
/// shares nothing with the managed-SSE reads above beyond the fixture loader.
|
||||||
|
async fn read_ssec_fixture_plaintext(
|
||||||
|
encrypted: Vec<u8>,
|
||||||
|
object_info: ObjectInfo,
|
||||||
|
customer_key_b64: &str,
|
||||||
|
customer_key_md5_b64: &str,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
let object_size = object_info.size;
|
||||||
|
reset_sse_dek_provider();
|
||||||
|
|
||||||
|
let mut headers = http::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
http::HeaderName::from_static("x-amz-server-side-encryption-customer-algorithm"),
|
||||||
|
http::HeaderValue::from_static("AES256"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
http::HeaderName::from_static("x-amz-server-side-encryption-customer-key"),
|
||||||
|
http::HeaderValue::from_str(customer_key_b64).expect("fixture customer key is a header value"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
http::HeaderName::from_static("x-amz-server-side-encryption-customer-key-md5"),
|
||||||
|
http::HeaderValue::from_str(customer_key_md5_b64).expect("fixture customer key md5 is a header value"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let resolver = SseObjectEncryptionResolver;
|
||||||
|
let (mut reader, offset, length) = GetObjectReader::new_with_resolver(
|
||||||
|
Box::new(Cursor::new(encrypted)),
|
||||||
|
None,
|
||||||
|
&object_info,
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
&headers,
|
||||||
|
Some(&resolver),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("construct GetObjectReader from MinIO SSE-C fixture: {err:?}"))?;
|
||||||
|
|
||||||
|
if offset != 0 || length != object_size {
|
||||||
|
return Err(format!("unexpected fixture range offset={offset} length={length} size={object_size}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut plaintext = Vec::new();
|
||||||
|
reader
|
||||||
|
.read_to_end(&mut plaintext)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("read plaintext from MinIO SSE-C fixture: {err}"))?;
|
||||||
|
Ok(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The interop claim must hold on the production key entry point, not only on
|
||||||
|
/// the test-only injection channel every other case here uses.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
|
||||||
|
async fn reads_minio_generated_sse_s3_fixture_through_production_master_key_env() {
|
||||||
|
let (object_info, encrypted, expected_sha256) = load_fixture_reader_input("sse-s3-multipart-8m").await;
|
||||||
|
|
||||||
|
let plaintext = read_fixture_plaintext_via_production_env(encrypted, object_info, minio_static_kms_key_b64())
|
||||||
|
.await
|
||||||
|
.expect("fixture must restore through RUSTFS_SSE_S3_MASTER_KEY");
|
||||||
|
|
||||||
|
assert_eq!(sha256_hex(&plaintext), expected_sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Objects small enough that MinIO inlined them into xl.meta instead of writing
|
||||||
|
/// a part file — the ordinary shape for everyday small objects, and the one
|
||||||
|
/// whose encrypted ETag misleads the multipart heuristic.
|
||||||
|
#[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_singlepart_fixture() {
|
||||||
|
assert_fixture_round_trip("sse-kms-singlepart-64k", 64 * 1024).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSE-C is the one managed shape needing no KMS: the customer supplies the key
|
||||||
|
/// on every request, so this measures the read path alone.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires generated MinIO fixture data"]
|
||||||
|
async fn reads_minio_generated_sse_c_multipart_fixture() {
|
||||||
|
// The fixture lab's fixed SSE-C key; recorded in the case's request.json.
|
||||||
|
const SSEC_KEY_B64: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
|
||||||
|
const SSEC_KEY_MD5_B64: &str = "tP/LI3N87DFaSk0aoqYgzg==";
|
||||||
|
|
||||||
|
let (object_info, encrypted, expected_sha256) = load_fixture_reader_input("sse-c-multipart-8m").await;
|
||||||
|
|
||||||
|
let plaintext = read_ssec_fixture_plaintext(encrypted, object_info, SSEC_KEY_B64, SSEC_KEY_MD5_B64)
|
||||||
|
.await
|
||||||
|
.expect("MinIO SSE-C fixture must restore with the customer key");
|
||||||
|
|
||||||
|
assert_eq!(sha256_hex(&plaintext), expected_sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The read path skips the stored-MD5 comparison for MinIO SSE-C objects,
|
||||||
|
/// which store no MD5. This holds the line that made that safe: the customer
|
||||||
|
/// key is still proven by the object-key unseal, so a wrong key must fail even
|
||||||
|
/// with nothing to compare it against.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires generated MinIO fixture data"]
|
||||||
|
async fn sse_c_wrong_customer_key_still_fails_without_a_stored_md5() {
|
||||||
|
// A well-formed 32-byte key that is not the one the fixture was sealed
|
||||||
|
// with, sent with its own correct MD5 so the request itself is valid.
|
||||||
|
const WRONG_KEY_B64: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
|
||||||
|
const WRONG_KEY_MD5_B64: &str = "0YB4bMPPCf9SNlqiKmM0uQ==";
|
||||||
|
|
||||||
|
let (object_info, encrypted, expected_sha256) = load_fixture_reader_input("sse-c-multipart-8m").await;
|
||||||
|
|
||||||
|
let result = read_ssec_fixture_plaintext(encrypted, object_info, WRONG_KEY_B64, WRONG_KEY_MD5_B64).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Err(_) => {}
|
||||||
|
// Never reached today, and asserted rather than assumed: if a future
|
||||||
|
// change let a wrong key through, returning the real plaintext would be
|
||||||
|
// the worst possible outcome.
|
||||||
|
Ok(plaintext) => assert_ne!(
|
||||||
|
sha256_hex(&plaintext),
|
||||||
|
expected_sha256,
|
||||||
|
"a wrong SSE-C customer key must never restore the original plaintext"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
|
#[ignore = "requires generated MinIO fixture data and a local static KMS key"]
|
||||||
async fn rejects_minio_generated_sse_s3_fixture_with_wrong_kms_key() {
|
async fn rejects_minio_generated_sse_s3_fixture_with_wrong_kms_key() {
|
||||||
@@ -281,6 +464,55 @@ async fn rejects_minio_generated_sse_s3_fixture_with_truncated_ciphertext() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a fixture through the **production** provider selection.
|
||||||
|
///
|
||||||
|
/// [`read_fixture_plaintext`] injects the master key through
|
||||||
|
/// `__RUSTFS_SSE_SIMPLE_CMK`, which is `#[cfg(test)]`-only, so on its own it
|
||||||
|
/// proves nothing about a deployment: it never reaches
|
||||||
|
/// `LocalSseDekProvider::new_from_env`. This variant sets only
|
||||||
|
/// `RUSTFS_SSE_S3_MASTER_KEY` — the sole production entry point — so the
|
||||||
|
/// interop claim rests on the path operators actually run (backlog#1638).
|
||||||
|
async fn read_fixture_plaintext_via_production_env(
|
||||||
|
encrypted: Vec<u8>,
|
||||||
|
object_info: ObjectInfo,
|
||||||
|
master_key_b64: String,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
let object_size = object_info.size;
|
||||||
|
reset_sse_dek_provider();
|
||||||
|
|
||||||
|
async_with_vars(
|
||||||
|
[
|
||||||
|
("__RUSTFS_SSE_SIMPLE_CMK", None::<String>),
|
||||||
|
("RUSTFS_SSE_S3_MASTER_KEY", Some(master_key_b64)),
|
||||||
|
],
|
||||||
|
async move {
|
||||||
|
let resolver = SseObjectEncryptionResolver;
|
||||||
|
let (mut reader, offset, length) = GetObjectReader::new_with_resolver(
|
||||||
|
Box::new(Cursor::new(encrypted)),
|
||||||
|
None,
|
||||||
|
&object_info,
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
&http::HeaderMap::new(),
|
||||||
|
Some(&resolver),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("construct GetObjectReader from MinIO raw fixture: {err:?}"))?;
|
||||||
|
|
||||||
|
if offset != 0 || length != object_size {
|
||||||
|
return Err(format!("unexpected fixture range offset={offset} length={length} size={object_size}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut plaintext = Vec::new();
|
||||||
|
reader
|
||||||
|
.read_to_end(&mut plaintext)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("read plaintext from MinIO raw fixture: {err}"))?;
|
||||||
|
Ok(plaintext)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn assert_fixture_round_trip(case_id: &str, expected_size: i64) {
|
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) = load_fixture_reader_input(case_id).await;
|
||||||
// `ObjectInfo.size` is the on-disk size. For SSE objects that is the
|
// `ObjectInfo.size` is the on-disk size. For SSE objects that is the
|
||||||
|
|||||||
+330
-13
@@ -1460,6 +1460,16 @@ fn managed_sse_domain(sse_type: SSEType) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The public `x-amz-server-side-encryption` value a managed scheme reports.
|
||||||
|
fn managed_sse_public_header(sse_type: SSEType) -> &'static str {
|
||||||
|
match sse_type {
|
||||||
|
SSEType::SseKms => ServerSideEncryption::AWS_KMS,
|
||||||
|
// SSE-C never reaches the managed path; reporting AES256 keeps this
|
||||||
|
// total without inventing a third public value.
|
||||||
|
SSEType::SseS3 | SSEType::SseC => ServerSideEncryption::AES256,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn canonical_kms_bucket_path(bucket: &str, key: &str) -> String {
|
fn canonical_kms_bucket_path(bucket: &str, key: &str) -> String {
|
||||||
path_join_buf(&[bucket, key])
|
path_join_buf(&[bucket, key])
|
||||||
}
|
}
|
||||||
@@ -2051,10 +2061,20 @@ pub async fn sse_prepare_encryption(request: PrepareEncryptionRequest<'_>) -> Re
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
pub async fn sse_decryption(request: DecryptionRequest<'_>) -> Result<Option<DecryptionMaterial>, ApiError> {
|
pub async fn sse_decryption(request: DecryptionRequest<'_>) -> Result<Option<DecryptionMaterial>, ApiError> {
|
||||||
// Check for SSE-C encryption
|
// Check for SSE-C encryption.
|
||||||
|
//
|
||||||
|
// The stored customer-algorithm marker is what RustFS writes, but a
|
||||||
|
// MinIO-written object has only the internal sealed-key slot: MinIO keeps
|
||||||
|
// the customer algorithm on the request and synthesizes it back onto the
|
||||||
|
// response, never persisting it. Recognizing that slot as well is what lets
|
||||||
|
// a migrated SSE-C object be read at all; the customer key still has to be
|
||||||
|
// supplied, and is still checked against the stored MD5 below.
|
||||||
if request
|
if request
|
||||||
.metadata
|
.metadata
|
||||||
.contains_key("x-amz-server-side-encryption-customer-algorithm")
|
.contains_key("x-amz-server-side-encryption-customer-algorithm")
|
||||||
|
|| request
|
||||||
|
.metadata
|
||||||
|
.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER)
|
||||||
{
|
{
|
||||||
let (key, key_md5) = match (request.sse_customer_key, request.sse_customer_key_md5) {
|
let (key, key_md5) = match (request.sse_customer_key, request.sse_customer_key_md5) {
|
||||||
(Some(k), Some(md5)) => (k, md5),
|
(Some(k), Some(md5)) => (k, md5),
|
||||||
@@ -2068,7 +2088,21 @@ pub async fn sse_decryption(request: DecryptionRequest<'_>) -> Result<Option<Dec
|
|||||||
|
|
||||||
// Verify that the provided key MD5 matches the stored MD5 for security
|
// Verify that the provided key MD5 matches the stored MD5 for security
|
||||||
let stored_md5 = request.metadata.get("x-amz-server-side-encryption-customer-key-md5");
|
let stored_md5 = request.metadata.get("x-amz-server-side-encryption-customer-key-md5");
|
||||||
verify_ssec_key_match(key_md5, stored_md5)?;
|
// MinIO stores no customer-key MD5 — it keeps that header on the request
|
||||||
|
// and returns it on the response — so requiring one would make every
|
||||||
|
// migrated SSE-C object unreadable. Skipping the comparison when there is
|
||||||
|
// nothing to compare against does not weaken the check it performs: the
|
||||||
|
// stored MD5 is an early, friendlier rejection, while the key itself is
|
||||||
|
// proven by the object-key unseal below, whose AEAD fails on a wrong key.
|
||||||
|
// `sse_c_wrong_customer_key_still_fails_without_a_stored_md5` holds that
|
||||||
|
// line. Objects that *do* carry a stored MD5 are unaffected.
|
||||||
|
let minio_ssec_without_stored_md5 = stored_md5.is_none()
|
||||||
|
&& request
|
||||||
|
.metadata
|
||||||
|
.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER);
|
||||||
|
if !minio_ssec_without_stored_md5 {
|
||||||
|
verify_ssec_key_match(key_md5, stored_md5)?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut material = apply_ssec_decryption_material(request.bucket, request.key, request.metadata, key, key_md5).await?;
|
let mut material = apply_ssec_decryption_material(request.bucket, request.key, request.metadata, key, key_md5).await?;
|
||||||
material.customer_key_md5 = Some(key_md5.clone());
|
material.customer_key_md5 = Some(key_md5.clone());
|
||||||
@@ -2445,20 +2479,42 @@ async fn apply_managed_decryption_material_inner(
|
|||||||
) -> Result<Option<DecryptionMaterial>, ApiError> {
|
) -> Result<Option<DecryptionMaterial>, ApiError> {
|
||||||
#[cfg(not(feature = "rio-v2"))]
|
#[cfg(not(feature = "rio-v2"))]
|
||||||
let _ = (bucket, key);
|
let _ = (bucket, key);
|
||||||
if !contains_managed_encryption_metadata(metadata) || !metadata.contains_key("x-amz-server-side-encryption") {
|
if !contains_managed_encryption_metadata(metadata) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safe: presence is guaranteed by the contains_key check above.
|
let encryption_type = match metadata.get("x-amz-server-side-encryption").map(String::as_str) {
|
||||||
let server_side_encryption = metadata.get("x-amz-server-side-encryption").cloned().unwrap_or_default();
|
Some(ServerSideEncryption::AWS_KMS) => SSEType::SseKms,
|
||||||
let normalized_metadata = normalize_managed_metadata(metadata, Some(recode_minio_kms_context));
|
Some(_) => SSEType::SseS3,
|
||||||
|
// MinIO never persists the public scheme header: `crypto.S3.CreateMetadata`
|
||||||
let encryption_type = match server_side_encryption.as_str() {
|
// writes only the `X-Minio-Internal-*` family and the public header is
|
||||||
ServerSideEncryption::AES256 => SSEType::SseS3,
|
// synthesized onto the response by `DecryptObjectInfo`. Requiring it here
|
||||||
ServerSideEncryption::AWS_KMS => SSEType::SseKms,
|
// is what made every MinIO-encrypted object unreadable (backlog#1638).
|
||||||
_ => SSEType::SseS3,
|
//
|
||||||
|
// Inferring from the sealed-key slot is self-consistent by construction:
|
||||||
|
// the slot decides which header the unseal reads AND which domain string
|
||||||
|
// the sealing key is derived under, so a scheme that disagrees with the
|
||||||
|
// slot cannot silently derive a wrong key — it finds no key at all.
|
||||||
|
// Inferring from the KMS key id would NOT be safe: MinIO writes
|
||||||
|
// `-S3-Kms-Key-Id` on SSE-S3 objects too.
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
None => match infer_minio_managed_sse_type(metadata) {
|
||||||
|
Some(sse_type) => sse_type,
|
||||||
|
// Still fail-closed, and deliberately not an error raised here: the
|
||||||
|
// read plan independently classifies the object as encrypted from
|
||||||
|
// its markers and refuses to serve it without material, so an
|
||||||
|
// object whose scheme cannot be established never degrades into a
|
||||||
|
// plaintext read.
|
||||||
|
None => return Ok(None),
|
||||||
|
},
|
||||||
|
// Without the rio-v2 reader there is no MinIO-format read path to serve
|
||||||
|
// such an object with, so it stays on the fail-closed branch.
|
||||||
|
#[cfg(not(feature = "rio-v2"))]
|
||||||
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let normalized_metadata = normalize_managed_metadata(metadata, Some(recode_minio_kms_context));
|
||||||
|
|
||||||
// Extract KMS key ID from metadata (optional, used for provider context)
|
// Extract KMS key ID from metadata (optional, used for provider context)
|
||||||
let kms_key_id = normalized_metadata
|
let kms_key_id = normalized_metadata
|
||||||
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
|
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
|
||||||
@@ -2556,8 +2612,19 @@ async fn apply_managed_decryption_material_inner(
|
|||||||
} else {
|
} else {
|
||||||
get_local_sse_dek_provider().await?
|
get_local_sse_dek_provider().await?
|
||||||
};
|
};
|
||||||
|
// A MinIO sealed key alone does not mean MinIO wrote the object: RustFS's own
|
||||||
|
// writer fills MinIO's metadata slots too, while still storing a RustFS
|
||||||
|
// envelope in them, so neither the slot nor the header name distinguishes the
|
||||||
|
// two. The data key's own shape does. RustFS envelopes are strictly-parsed
|
||||||
|
// JSON; MinIO's builtin-KMS ciphertext is opaque bytes that match neither, so
|
||||||
|
// recognizing RustFS positively — and treating only the remainder as MinIO —
|
||||||
|
// keeps a RustFS envelope from ever reaching MinIO's decoder.
|
||||||
#[cfg(feature = "rio-v2")]
|
#[cfg(feature = "rio-v2")]
|
||||||
let decrypted_data_key = if is_legacy_rustfs_managed_metadata(&normalized_metadata) {
|
let decrypted_data_key = if minio_sealed_key.is_some() && !is_rustfs_managed_data_key(&encrypted_data_key) {
|
||||||
|
provider
|
||||||
|
.decrypt_minio_sse_dek(&encrypted_data_key, &kms_key_id, &object_context)
|
||||||
|
.await
|
||||||
|
} else if is_legacy_rustfs_managed_metadata(&normalized_metadata) {
|
||||||
provider
|
provider
|
||||||
.decrypt_legacy_sse_dek(&encrypted_data_key, &kms_key_id, &object_context)
|
.decrypt_legacy_sse_dek(&encrypted_data_key, &kms_key_id, &object_context)
|
||||||
.await
|
.await
|
||||||
@@ -2592,7 +2659,11 @@ async fn apply_managed_decryption_material_inner(
|
|||||||
|
|
||||||
Ok(Some(DecryptionMaterial {
|
Ok(Some(DecryptionMaterial {
|
||||||
sse_type: encryption_type,
|
sse_type: encryption_type,
|
||||||
server_side_encryption: ServerSideEncryption::from(server_side_encryption),
|
// Synthesized from the resolved scheme rather than read back from
|
||||||
|
// metadata: a MinIO-written object has no stored scheme header, which is
|
||||||
|
// exactly why the gate above had to infer it. MinIO synthesizes the same
|
||||||
|
// header onto its own responses.
|
||||||
|
server_side_encryption: ServerSideEncryption::from(managed_sse_public_header(encryption_type).to_string()),
|
||||||
kms_key_id: Some(SSEKMSKeyId::from(kms_key_id)),
|
kms_key_id: Some(SSEKMSKeyId::from(kms_key_id)),
|
||||||
algorithm,
|
algorithm,
|
||||||
customer_key_md5: None,
|
customer_key_md5: None,
|
||||||
@@ -2659,6 +2730,30 @@ pub trait SseDekProvider: Send + Sync {
|
|||||||
) -> Result<[u8; 32], ApiError> {
|
) -> Result<[u8; 32], ApiError> {
|
||||||
self.decrypt_sse_dek(encrypted_dek, kms_key_id, context).await
|
self.decrypt_sse_dek(encrypted_dek, kms_key_id, context).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unwrap a data key that MinIO's builtin KMS sealed.
|
||||||
|
///
|
||||||
|
/// A separate entry point rather than a shape sniff inside
|
||||||
|
/// [`Self::decrypt_sse_dek`]: the caller already knows the object carries a
|
||||||
|
/// MinIO sealed key, and MinIO's raw ciphertext is unstructured bytes that
|
||||||
|
/// no parser can reliably tell apart from anything else. Routing on the
|
||||||
|
/// caller's knowledge keeps a RustFS envelope from ever reaching MinIO's
|
||||||
|
/// decoder, and vice versa.
|
||||||
|
///
|
||||||
|
/// Defaults to refusing: only a provider holding the MinIO master secret
|
||||||
|
/// can serve these, and a provider that cannot must fail rather than fall
|
||||||
|
/// back to a decoder that would misread the bytes.
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
async fn decrypt_minio_sse_dek(
|
||||||
|
&self,
|
||||||
|
_encrypted_dek: &[u8],
|
||||||
|
_kms_key_id: &str,
|
||||||
|
_context: &ObjectEncryptionContext,
|
||||||
|
) -> Result<[u8; 32], ApiError> {
|
||||||
|
Err(ApiError::from(StorageError::other(
|
||||||
|
"This KMS provider cannot unwrap a data key sealed by MinIO's builtin KMS",
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2797,6 +2892,163 @@ pub(crate) struct LocalSseDekProvider {
|
|||||||
|
|
||||||
const LOCAL_SSE_DEK_FORMAT_VERSION: u8 = 1;
|
const LOCAL_SSE_DEK_FORMAT_VERSION: u8 = 1;
|
||||||
|
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
/// Returns true when a managed-SSE data key is one RustFS itself wrote.
|
||||||
|
///
|
||||||
|
/// Both RustFS envelope shapes are strict JSON — the KMS envelope
|
||||||
|
/// ([`rustfs_kms::is_data_key_envelope`]) and the local provider's
|
||||||
|
/// [`LocalSseDekEnvelope`], whose `deny_unknown_fields` keeps it from accepting
|
||||||
|
/// anything else. Recognition is deliberately positive: an unrecognized payload
|
||||||
|
/// is left to MinIO's decoder rather than guessed at, and neither decoder is
|
||||||
|
/// ever handed the other's format.
|
||||||
|
fn is_rustfs_managed_data_key(encrypted_dek: &[u8]) -> bool {
|
||||||
|
if rustfs_kms::is_data_key_envelope(encrypted_dek) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::str::from_utf8(encrypted_dek)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|text| serde_json::from_str::<LocalSseDekEnvelope<'_>>(text).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
/// Associated data MinIO binds when sealing a data key.
|
||||||
|
///
|
||||||
|
/// MinIO passes the object's encryption context as the AEAD's associated data,
|
||||||
|
/// serialized as canonical JSON with sorted keys — the same canonicalization
|
||||||
|
/// [`rustfs_kms::context_aad`] performs, which is why the context RustFS
|
||||||
|
/// already rebuilds for the read can be reused verbatim. For SSE-S3 that
|
||||||
|
/// context is `{bucket: "bucket/object"}`; for SSE-KMS it is whatever the
|
||||||
|
/// request supplied, recovered from the stored MinIO context header.
|
||||||
|
fn minio_kms_associated_data(context: &ObjectEncryptionContext) -> Result<Vec<u8>, ApiError> {
|
||||||
|
let mut ctx = context.encryption_context.clone();
|
||||||
|
ctx.entry(context.bucket.clone())
|
||||||
|
.or_insert_with(|| canonical_kms_bucket_path(&context.bucket, &context.object_key));
|
||||||
|
rustfs_kms::context_aad(&ctx)
|
||||||
|
.map_err(|e| ApiError::from(StorageError::other(format!("Failed to canonicalize MinIO KMS context: {e}"))))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
/// MinIO's builtin-KMS ciphertext in its JSON encoding.
|
||||||
|
///
|
||||||
|
/// Deliberately its own type rather than a relaxation of
|
||||||
|
/// [`LocalSseDekEnvelope`]: widening that envelope's `deny_unknown_fields`
|
||||||
|
/// to admit this shape would also admit malformed RustFS envelopes, which
|
||||||
|
/// backlog#1567 requires to keep failing closed.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct MinioKmsCiphertextJson {
|
||||||
|
aead: String,
|
||||||
|
#[allow(
|
||||||
|
dead_code,
|
||||||
|
reason = "present in MinIO's encoding; the key is identified by metadata instead"
|
||||||
|
)]
|
||||||
|
#[serde(default)]
|
||||||
|
id: String,
|
||||||
|
iv: String,
|
||||||
|
nonce: String,
|
||||||
|
bytes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes of trailing randomness every MinIO builtin-KMS ciphertext carries:
|
||||||
|
/// a 16-byte IV followed by a 12-byte nonce, *after* the sealed bytes.
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
const MINIO_KMS_RANDOM_LEN: usize = 28;
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
const MINIO_KMS_IV_LEN: usize = 16;
|
||||||
|
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
const MINIO_KMS_AEAD_AES_GCM: &str = "AES-256-GCM-HMAC-SHA-256";
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
const MINIO_KMS_AEAD_CHACHA20: &str = "ChaCha20Poly1305";
|
||||||
|
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
/// Unwrap a data key sealed by MinIO's builtin (static-secret) KMS.
|
||||||
|
///
|
||||||
|
/// The wire format is `sealed_bytes || iv[16] || nonce[12]` — the randomness
|
||||||
|
/// trails the ciphertext rather than leading it, and MinIO's own decoder
|
||||||
|
/// normalizes its legacy JSON encoding into exactly that byte order before
|
||||||
|
/// opening it (`internal/kms/secret-key.go`, `parseCiphertext`). A raw
|
||||||
|
/// (non-JSON) ciphertext is AES-256-GCM by definition there; the JSON form
|
||||||
|
/// names its algorithm.
|
||||||
|
///
|
||||||
|
/// The sealing key is derived per ciphertext rather than being the master key:
|
||||||
|
/// `HMAC-SHA256(master, iv)` for AES-256-GCM, `HChaCha20(master, iv)` for
|
||||||
|
/// ChaCha20-Poly1305. The encryption context is bound as associated data.
|
||||||
|
fn decrypt_minio_kms_data_key(encrypted_dek: &[u8], master_key: &[u8; 32], aad: &[u8]) -> Result<[u8; 32], ApiError> {
|
||||||
|
let (body, algorithm) = match std::str::from_utf8(encrypted_dek) {
|
||||||
|
// MinIO only treats a payload as JSON when it both starts and ends like
|
||||||
|
// an object, and falls back to the raw layout when it does not parse —
|
||||||
|
// mirrored here so a ciphertext that merely looks like JSON is not
|
||||||
|
// rejected outright.
|
||||||
|
Ok(text)
|
||||||
|
if text.starts_with('{')
|
||||||
|
&& text.ends_with('}')
|
||||||
|
&& let Ok(json) = serde_json::from_str::<MinioKmsCiphertextJson>(text) =>
|
||||||
|
{
|
||||||
|
let decode = |what: &str, value: &str| -> Result<Vec<u8>, ApiError> {
|
||||||
|
BASE64_STANDARD
|
||||||
|
.decode(value)
|
||||||
|
.map_err(|e| ApiError::from(StorageError::other(format!("Invalid MinIO KMS {what}: {e}"))))
|
||||||
|
};
|
||||||
|
let mut body = decode("ciphertext", &json.bytes)?;
|
||||||
|
body.extend_from_slice(&decode("iv", &json.iv)?);
|
||||||
|
body.extend_from_slice(&decode("nonce", &json.nonce)?);
|
||||||
|
(body, json.aead)
|
||||||
|
}
|
||||||
|
_ => (encrypted_dek.to_vec(), MINIO_KMS_AEAD_AES_GCM.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if body.len() <= MINIO_KMS_RANDOM_LEN {
|
||||||
|
return Err(ApiError::from(StorageError::other(
|
||||||
|
"MinIO KMS ciphertext is too short to carry its IV and nonce",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let (sealed, random) = body.split_at(body.len() - MINIO_KMS_RANDOM_LEN);
|
||||||
|
let (iv, nonce) = random.split_at(MINIO_KMS_IV_LEN);
|
||||||
|
|
||||||
|
let plaintext = match algorithm.as_str() {
|
||||||
|
MINIO_KMS_AEAD_AES_GCM => {
|
||||||
|
use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
|
||||||
|
let mut mac = HmacSha256::new_from_slice(master_key)
|
||||||
|
.map_err(|_| ApiError::from(StorageError::other("MinIO KMS sealing key derivation failed")))?;
|
||||||
|
mac.update(iv);
|
||||||
|
let sealing_key: [u8; 32] = mac.finalize().into_bytes().into();
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(&sealing_key)
|
||||||
|
.map_err(|_| ApiError::from(StorageError::other("MinIO KMS sealing key is not a valid AES-256 key")))?;
|
||||||
|
let nonce = aes_gcm::Nonce::try_from(nonce)
|
||||||
|
.map_err(|_| ApiError::from(StorageError::other("MinIO KMS nonce is not 12 bytes")))?;
|
||||||
|
cipher.decrypt(&nonce, aes_gcm::aead::Payload { msg: sealed, aad })
|
||||||
|
}
|
||||||
|
MINIO_KMS_AEAD_CHACHA20 => {
|
||||||
|
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, aead::Aead};
|
||||||
|
// MinIO derives this branch's key with HChaCha20 over the 16-byte
|
||||||
|
// IV, which is exactly XChaCha20-Poly1305's own construction, so the
|
||||||
|
// extended-nonce cipher does the derivation rather than hand-rolling it.
|
||||||
|
let mut extended = Vec::with_capacity(MINIO_KMS_IV_LEN + nonce.len());
|
||||||
|
extended.extend_from_slice(iv);
|
||||||
|
extended.extend_from_slice(nonce);
|
||||||
|
let cipher = XChaCha20Poly1305::new_from_slice(master_key)
|
||||||
|
.map_err(|_| ApiError::from(StorageError::other("MinIO KMS master key is not a valid ChaCha20 key")))?;
|
||||||
|
let nonce = chacha20poly1305::XNonce::try_from(extended.as_slice())
|
||||||
|
.map_err(|_| ApiError::from(StorageError::other("MinIO KMS extended nonce is not 24 bytes")))?;
|
||||||
|
cipher.decrypt(&nonce, chacha20poly1305::aead::Payload { msg: sealed, aad })
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(ApiError::from(StorageError::other(format!(
|
||||||
|
"Unsupported MinIO KMS AEAD algorithm: {other}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An AEAD failure here is authentication, not a decode slip: a wrong master
|
||||||
|
// key, a tampered ciphertext, and an encryption context that does not match
|
||||||
|
// what sealed it all land here and must all fail closed.
|
||||||
|
.map_err(|_| ApiError::from(StorageError::other("MinIO KMS data key failed authentication")))?;
|
||||||
|
|
||||||
|
plaintext.try_into().map_err(|value: Vec<u8>| {
|
||||||
|
ApiError::from(StorageError::other(format!("MinIO KMS data key must be 32 bytes, got {}", value.len())))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct LocalSseDekEnvelope<'a> {
|
struct LocalSseDekEnvelope<'a> {
|
||||||
@@ -3013,6 +3265,17 @@ impl SseDekProvider for LocalSseDekProvider {
|
|||||||
let dek = Self::decrypt_dek(encrypted_dek_str, self.master_key)?;
|
let dek = Self::decrypt_dek(encrypted_dek_str, self.master_key)?;
|
||||||
Ok(dek)
|
Ok(dek)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
async fn decrypt_minio_sse_dek(
|
||||||
|
&self,
|
||||||
|
encrypted_dek: &[u8],
|
||||||
|
_kms_key_id: &str,
|
||||||
|
context: &ObjectEncryptionContext,
|
||||||
|
) -> Result<[u8; 32], ApiError> {
|
||||||
|
let aad = minio_kms_associated_data(context)?;
|
||||||
|
decrypt_minio_kms_data_key(encrypted_dek, &self.master_key, &aad)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -3201,6 +3464,23 @@ fn is_legacy_rustfs_managed_metadata(metadata: &HashMap<String, String>) -> bool
|
|||||||
&& !metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)
|
&& !metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
#[cfg(feature = "rio-v2")]
|
||||||
|
/// Infer the managed SSE scheme from the MinIO sealed-key slot that is present.
|
||||||
|
///
|
||||||
|
/// Returns `None` when no managed MinIO slot is present, which keeps callers on
|
||||||
|
/// their fail-closed path. SSE-C is not a managed scheme and is handled by the
|
||||||
|
/// SSE-C read path, so its slot is not considered here.
|
||||||
|
fn infer_minio_managed_sse_type(metadata: &HashMap<String, String>) -> Option<SSEType> {
|
||||||
|
if metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER) {
|
||||||
|
Some(SSEType::SseS3)
|
||||||
|
} else if metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER) {
|
||||||
|
Some(SSEType::SseKms)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "rio-v2")]
|
#[cfg(feature = "rio-v2")]
|
||||||
fn parse_minio_managed_sealed_key(
|
fn parse_minio_managed_sealed_key(
|
||||||
metadata: &HashMap<String, String>,
|
metadata: &HashMap<String, String>,
|
||||||
@@ -4483,6 +4763,43 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "rio-v2")]
|
#[cfg(feature = "rio-v2")]
|
||||||
|
/// A stored customer-key MD5 must still be compared against the one the
|
||||||
|
/// request presents.
|
||||||
|
///
|
||||||
|
/// The read path skips that comparison for MinIO SSE-C objects, which store
|
||||||
|
/// no MD5. Nothing pinned the check for objects that *do* store one —
|
||||||
|
/// disabling it outright left this file's 115 tests green — so a later
|
||||||
|
/// widening of that skip would have gone unnoticed. The mismatch has to be
|
||||||
|
/// refused here, at the request boundary, rather than surfacing later as a
|
||||||
|
/// decryption failure.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ssec_stored_md5_mismatch_is_refused_when_an_md5_is_stored() {
|
||||||
|
let key = SSECustomerKey::from(BASE64_STANDARD.encode([0x11u8; 32]));
|
||||||
|
let provided_md5 = SSECustomerKeyMD5::from(md5_base64(&[0x11u8; 32]));
|
||||||
|
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string()),
|
||||||
|
// A stored MD5 that belongs to a different key.
|
||||||
|
("x-amz-server-side-encryption-customer-key-md5".to_string(), md5_base64(&[0x22u8; 32])),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let error = sse_decryption(DecryptionRequest {
|
||||||
|
bucket: "bucket",
|
||||||
|
key: "object",
|
||||||
|
metadata: &metadata,
|
||||||
|
sse_customer_key: Some(&key),
|
||||||
|
sse_customer_key_md5: Some(&provided_md5),
|
||||||
|
principal: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("a stored MD5 that does not match the request must be refused");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
format!("{error:?}").contains("did not match"),
|
||||||
|
"expected the parameter-mismatch refusal, got {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_sse_kms_roundtrip_persists_and_uses_minio_context() {
|
async fn test_sse_kms_roundtrip_persists_and_uses_minio_context() {
|
||||||
use rustfs_kms::types::{CreateKeyRequest, KeyUsage};
|
use rustfs_kms::types::{CreateKeyRequest, KeyUsage};
|
||||||
|
|||||||
Reference in New Issue
Block a user