mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-21 03:46:37 +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;
|
||||
#[cfg(feature = "rio-v2")]
|
||||
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 plaintext_size = encrypted_plaintext_size(oi, is_multipart, is_compressed, recorded_plaintext_size)?;
|
||||
let full_plaintext_size =
|
||||
@@ -1285,14 +1285,59 @@ fn encrypted_plaintext_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 {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -614,7 +614,20 @@ impl ObjectInfo {
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -324,11 +324,18 @@ impl Default for LocalConfig {
|
||||
/// wraps data encryption keys — there is no HMAC-SHA256 derivation step — and
|
||||
/// 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
|
||||
/// not wire-compatible with it: MinIO wraps DEKs in a different (`{"aead": ...}`)
|
||||
/// blob that this backend neither produces nor accepts, so KMS ciphertext
|
||||
/// written by MinIO cannot be opened here. Reading MinIO-written SSE objects is
|
||||
/// tracked separately in rustfs/backlog#1638.
|
||||
/// This mirrors the *concept* of MinIO's builtin/static single-key KMS but is
|
||||
/// not wire-compatible with it. MinIO seals a DEK as `sealed || iv[16] ||
|
||||
/// nonce[12]` under a per-ciphertext key derived from the master secret, with
|
||||
/// a legacy JSON encoding of the same layout; this backend neither produces
|
||||
/// 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)]
|
||||
pub struct StaticConfig {
|
||||
/// Key identifier (name) for the single configured key
|
||||
|
||||
@@ -255,6 +255,10 @@ pub use cache::KmsCacheStats;
|
||||
pub use config::*;
|
||||
pub use deletion_worker::DeletionReferenceChecker;
|
||||
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 key_impact::{KeyImpactReport, KeyReference, KeyReferenceKind, ReferenceCompleteness, ReferenceCoverage, ReferenceScope};
|
||||
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_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.
|
||||
///
|
||||
/// No RustFS writer emits these keys today — the SSE writer persists the
|
||||
@@ -323,6 +367,31 @@ mod tests {
|
||||
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]
|
||||
fn transport_metadata_roundtrip_restores_stored_keys() {
|
||||
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 |
|
||||
| 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.**
|
||||
> 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.
|
||||
> **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.
|
||||
|
||||
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".
|
||||
- **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.
|
||||
|
||||
Current options for a migration whose source contains encrypted objects:
|
||||
For any shape that does not read, the options are unchanged:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
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::path::{Path, PathBuf};
|
||||
|
||||
use super::sse::SseObjectEncryptionResolver;
|
||||
use super::sse::{SseObjectEncryptionResolver, reset_sse_dek_provider};
|
||||
use super::storage_api::ecstore_test_support::{
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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()));
|
||||
@@ -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> {
|
||||
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(
|
||||
[
|
||||
("__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}"));
|
||||
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 meta_order: Vec<Option<FileInfo>> = vec![None; disks.len()];
|
||||
for (idx, disk) in disks.iter().enumerate() {
|
||||
let block_index = file_info.erasure.distribution[idx];
|
||||
disk_order[block_index - 1] = Some(disk);
|
||||
meta_order[block_index - 1] = per_disk_meta.get(idx).cloned().flatten();
|
||||
}
|
||||
let data_dir = file_info
|
||||
.data_dir
|
||||
@@ -199,13 +247,21 @@ 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 primary_checksum_info = file_info.erasure.get_checksum_info(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 mut readers = Vec::with_capacity(disks.len());
|
||||
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(
|
||||
None,
|
||||
inline_shard,
|
||||
*disk,
|
||||
&manifest.bucket,
|
||||
&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;
|
||||
}
|
||||
|
||||
/// 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]
|
||||
#[ignore = "requires generated MinIO fixture data and a local static 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) {
|
||||
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
|
||||
|
||||
+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 {
|
||||
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> {
|
||||
// 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
|
||||
.metadata
|
||||
.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) {
|
||||
(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
|
||||
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?;
|
||||
material.customer_key_md5 = Some(key_md5.clone());
|
||||
@@ -2445,20 +2479,42 @@ async fn apply_managed_decryption_material_inner(
|
||||
) -> Result<Option<DecryptionMaterial>, ApiError> {
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
let _ = (bucket, key);
|
||||
if !contains_managed_encryption_metadata(metadata) || !metadata.contains_key("x-amz-server-side-encryption") {
|
||||
if !contains_managed_encryption_metadata(metadata) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Safe: presence is guaranteed by the contains_key check above.
|
||||
let server_side_encryption = metadata.get("x-amz-server-side-encryption").cloned().unwrap_or_default();
|
||||
let normalized_metadata = normalize_managed_metadata(metadata, Some(recode_minio_kms_context));
|
||||
|
||||
let encryption_type = match server_side_encryption.as_str() {
|
||||
ServerSideEncryption::AES256 => SSEType::SseS3,
|
||||
ServerSideEncryption::AWS_KMS => SSEType::SseKms,
|
||||
_ => SSEType::SseS3,
|
||||
let encryption_type = match metadata.get("x-amz-server-side-encryption").map(String::as_str) {
|
||||
Some(ServerSideEncryption::AWS_KMS) => SSEType::SseKms,
|
||||
Some(_) => SSEType::SseS3,
|
||||
// MinIO never persists the public scheme header: `crypto.S3.CreateMetadata`
|
||||
// writes only the `X-Minio-Internal-*` family and the public header is
|
||||
// synthesized onto the response by `DecryptObjectInfo`. Requiring it here
|
||||
// is what made every MinIO-encrypted object unreadable (backlog#1638).
|
||||
//
|
||||
// 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)
|
||||
let kms_key_id = normalized_metadata
|
||||
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
|
||||
@@ -2556,8 +2612,19 @@ async fn apply_managed_decryption_material_inner(
|
||||
} else {
|
||||
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")]
|
||||
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
|
||||
.decrypt_legacy_sse_dek(&encrypted_data_key, &kms_key_id, &object_context)
|
||||
.await
|
||||
@@ -2592,7 +2659,11 @@ async fn apply_managed_decryption_material_inner(
|
||||
|
||||
Ok(Some(DecryptionMaterial {
|
||||
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)),
|
||||
algorithm,
|
||||
customer_key_md5: None,
|
||||
@@ -2659,6 +2730,30 @@ pub trait SseDekProvider: Send + Sync {
|
||||
) -> Result<[u8; 32], ApiError> {
|
||||
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;
|
||||
|
||||
#[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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LocalSseDekEnvelope<'a> {
|
||||
@@ -3013,6 +3265,17 @@ impl SseDekProvider for LocalSseDekProvider {
|
||||
let dek = Self::decrypt_dek(encrypted_dek_str, self.master_key)?;
|
||||
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)
|
||||
}
|
||||
|
||||
#[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")]
|
||||
fn parse_minio_managed_sealed_key(
|
||||
metadata: &HashMap<String, String>,
|
||||
@@ -4483,6 +4763,43 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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]
|
||||
async fn test_sse_kms_roundtrip_persists_and_uses_minio_context() {
|
||||
use rustfs_kms::types::{CreateKeyRequest, KeyUsage};
|
||||
|
||||
Reference in New Issue
Block a user