fix(sse): diagnose unresolvable encrypted metadata on reads (#6784)

This commit is contained in:
唐小鸭
2026-08-28 19:49:58 +08:00
committed by GitHub
parent 64705d7589
commit eb6b617ca2
6 changed files with 432 additions and 102 deletions
+5
View File
@@ -7,6 +7,11 @@
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 }, { "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 }, { "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 }, { "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
{
"workflow": ".github/workflows/minio-interop.yml",
"max_age_hours": 36,
"never_ran_grace_until": "2026-09-08T00:00:00Z"
},
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 }, { "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 }, { "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
{ {
+16 -16
View File
@@ -20,27 +20,27 @@
# each run with Docker and then runs the `#[ignore]` reader tests in # each run with Docker and then runs the `#[ignore]` reader tests in
# rustfs/src/storage/minio_generated_read_test.rs. # rustfs/src/storage/minio_generated_read_test.rs.
# #
# Scope: end-to-end MinIO-to-RustFS SSE interop is NOT implemented yet. Both # Scope: MinIO-to-RustFS SSE read interop is implemented behind the `rio-v2`
# envelope parsers reject MinIO's own wrapped-DEK shape — see # feature for MinIO's builtin static-KMS deployments — SSE-S3 and SSE-KMS
# `is_data_key_envelope` in crates/kms/src/encryption/dek.rs and the # (single- and multipart) since rustfs/rustfs#6191, SSE-C detection since the
# `deny_unknown_fields` `LocalSseDekEnvelope` in rustfs/src/storage/sse.rs — and # rustfs/backlog#1638 D2 close-out. This job is the standing evidence: it
# closing that gap is tracked in rustfs/backlog#1638. Treat this job as the # regenerates real MinIO backend trees and proves byte-identical plaintext
# harness for #1638, not as standing evidence that a MinIO migration reads back. # reconstruction. KES/MinKMS-backed MinIO objects remain unreadable by design
# (their envelopes are sealed by the KES service, not by a key RustFS can
# hold), and default RustFS builds do not include the read path — it is a
# special-purpose migration capability, not a default-build feature.
# #
# Runner: GitHub-hosted `ubuntu-latest`. It reliably ships Docker + Python, # Runner: GitHub-hosted `ubuntu-latest`. It reliably ships Docker + Python,
# unlike the self-hosted fleet, whose pods drift in Docker/pip availability # unlike the self-hosted fleet, whose pods drift in Docker/pip availability
# (see the infra note in e2e-s3tests.yml). Nightly + manual only. # (see the infra note in e2e-s3tests.yml). Nightly + manual only.
# DISABLED. This workflow is switched off in the repository's Actions settings
# (state: disabled_manually) and does not run on any trigger, including its cron
# and workflow_dispatch. That state lives in GitHub's UI and is invisible when
# reading this file, which has already misled at least one audit — hence this
# banner. Re-enabling is a UI action; anyone doing so should first check that the
# workflow still matches the current CI layout. See rustfs/backlog#1603.
# #
# While disabled, this workflow is deliberately absent from # Enablement: this workflow was long disabled in the repository's Actions
# .github/scheduled-validations.json — a disabled workflow can never satisfy the # settings (state: disabled_manually — a state that lives in GitHub's UI and is
# freshness check. Whoever re-enables it must re-add the entry in the same # invisible in this file). The change that updated this banner also re-added
# change so the freshness gate covers it again. # the .github/scheduled-validations.json entry; both only make sense together
# with re-enabling the workflow in the Actions settings. If it is ever disabled
# again, remove the scheduled-validations entry in the same change — a disabled
# workflow can never satisfy the freshness check. See rustfs/backlog#1603.
# #
name: minio-interop name: minio-interop
+12 -1
View File
@@ -730,7 +730,18 @@ impl ReadPlan {
}) })
.await .await
.map_err(Error::other)? .map_err(Error::other)?
.ok_or_else(|| Error::other("encrypted object metadata is incomplete"))?; .ok_or_else(|| {
// The resolver saw no encryption it recognizes, yet the
// object's markers say it is encrypted. Keep failing closed,
// but as a typed, non-retryable error: the condition is a
// permanent property of the stored metadata, not a fault a
// retry can fix.
Error::other(EncryptionResolutionError::new(
EncryptionResolutionErrorKind::InvalidMetadata,
"object is marked encrypted, but no decryption material could be resolved from its metadata; \
the encryption metadata is incomplete or in a format this server cannot read",
))
})?;
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 { .. });
+1 -1
View File
@@ -52,4 +52,4 @@ It checks that:
- KMS key ids are derived from each fixture's own `manifest.json`, so local static-KMS runs are not tied to one hard-coded key name - KMS key ids are derived from each fixture's own `manifest.json`, so local static-KMS runs are not tied to one hard-coded key name
- SSE-C `HEAD` responses round-trip the expected customer algorithm and customer-key MD5 - SSE-C `HEAD` responses round-trip the expected customer algorithm and customer-key MD5
These tests do not yet validate full plaintext reconstruction from MinIO-written encrypted data. These tests do not validate full plaintext reconstruction from MinIO-written encrypted data — that lives in the reader suite at `rustfs/src/storage/minio_generated_read_test.rs`, run with `--features rio-v2` over the same fixture captures.
+37 -39
View File
@@ -224,63 +224,59 @@ missing piece is a source adapter that points the importer at a MinIO
## Part C — Server-Side Encryption (SSE) ## Part C — Server-Side Encryption (SSE)
Container-format parity does **not** extend to encrypted object payloads. RustFS currently does not support reading objects that MinIO wrote with server-side encryption — SSE-S3, SSE-KMS, or SSE-C. This is true of every released binary and container image. Tracked in rustfs/backlog#1638. Reading MinIO-written SSE objects is implemented, with a deliberate build boundary. The read path lives behind the `rio-v2` feature and is a **special-purpose migration capability**: it is not compiled into released binaries or container images, and there is no short-term plan to promote it into default builds. A default build fails such reads closed with a diagnosed error (see "How default builds fail" below); a `rio-v2` build reads them, within the scenario matrix below. The read-path work was tracked in rustfs/backlog#1638 (landed across rustfs/rustfs#6191, #6784, #6785).
### Scope boundary: KMS wire protocols and the production gate ### Scope boundary: KMS wire protocols and the production gate
This document covers MinIO on-disk metadata and object-encryption seams only. The **AWS KMS wire protocol** and the **MinIO KES wire protocol** are explicit non-targets: RustFS's AWS backend uses the AWS SDK's `awsJson1_1` client path (`crates/kms/src/backends/aws.rs:830`), while KES compatibility is outside this interop work. Those ecosystem evaluations remain separate work in the [#1562 Production Ready exit gate](https://github.com/rustfs/backlog/issues/1562), whose compatibility criterion covers MinIO/RustFS SSE data and rolling upgrades. Closing #1638 does not by itself close that gate. This document covers MinIO on-disk metadata and object-encryption seams only. The **AWS KMS wire protocol** and the **MinIO KES wire protocol** are explicit non-targets: RustFS's AWS backend uses the AWS SDK's `awsJson1_1` client path (`crates/kms/src/backends/aws.rs:830`), while KES compatibility is outside this interop work. Those ecosystem evaluations remain separate work in the [#1562 Production Ready exit gate](https://github.com/rustfs/backlog/issues/1562), whose compatibility criterion covers MinIO/RustFS SSE data and rolling upgrades. Closing #1638 does not by itself close that gate.
Note the asymmetry with Parts A and B: the `xl.meta` around a MinIO SSE object parses fine, so such objects list, HEAD, and report plausible sizes. Only the payload is unreadable. Note the asymmetry with Parts A and B: the `xl.meta` around a MinIO SSE object parses fine, so such objects list, HEAD, and report plausible sizes. Only payload readability depends on the build and the scenario.
### What can and cannot be migrated ### What can and cannot be migrated
| Object class | Readable after moving the drives / copying via S3 | Notes | | Object class | Default build | `rio-v2` build | Notes |
|---|:--:|---| |---|:--:|:--:|---|
| Unencrypted objects | ✅ | Parts A and B apply. | | Unencrypted objects | ✅ | ✅ | Parts A and B apply. |
| Bucket metadata, IAM config | ✅ | Via the importer, once a `.minio.sys` source adapter exists (see Part B). | | Bucket metadata, IAM config | ✅ | ✅ | Via the importer, once a `.minio.sys` source adapter exists (see Part B). |
| Bucket-level default-encryption *configuration* | ✅ | The `encryption` config blob round-trips as a blob; it does not make existing ciphertext readable. | | Bucket-level default-encryption *configuration* | ✅ | ✅ | The `encryption` config blob round-trips as a blob; it does not make existing ciphertext readable. |
| MinIO-written SSE-S3 objects | ❌ | Seams 1 and 2 below. | | SSE-S3 / SSE-KMS, MinIO builtin static KMS (`MINIO_KMS_SECRET_KEY`), single- and multipart | ❌ diagnosed | ✅ | Requires `RUSTFS_SSE_S3_MASTER_KEY` set to the same 32-byte key material as MinIO's static secret. Proven against real MinIO fixtures (rustfs/rustfs#6191). |
| MinIO-written SSE-KMS objects | | Seams 1 and 2 below. | | SSE-C, MinIO-written | ❌ diagnosed | | Detection via MinIO's sealed-key slot; the customer key is proven by the AEAD unseal, since MinIO stores no key MD5 (rustfs/rustfs#6785). |
| MinIO-written SSE-C objects | ❌ | Seam 3 below. | | Any SSE, MinIO backed by KES / KMS plugin / MinKMS | ❌ | ❌ **not planned** | The wrapped DEK is sealed by the KES service itself; it is not a Vault/Transit ciphertext RustFS could be pointed at. Re-encrypt on the MinIO side before migrating. |
| RustFS-written SSE objects read back by MinIO | ❌ | See "Reverse direction". | | Objects sealed with legacy `DARE-SHA256` (`InsecureSealAlgorithm`) | ❌ | ❌ out of scope | Pre-DAREv2-HMAC MinIO; `parse_minio_managed_sealed_key` rejects the algorithm and the read fails closed. |
| RustFS-written SSE objects read back by MinIO | ❌ | ❌ | See "Reverse direction". |
### Where the read path stops ### The seams, and where they closed
The primitives match — RustFS implements the same DARE V2 stream format and the same object-key derivation and sealing, and a MinIO sealed-key parser exists (`parse_minio_managed_sealed_key`, `rustfs/src/storage/sse.rs:3195`). Three seams above the cryptography still reject MinIO-written objects. The cryptographic primitives were never the gap — RustFS implements the same DARE V2 stream format, object-key derivation, and sealing. Three seams above the cryptography rejected MinIO-written objects; all three are closed in `rio-v2` builds.
| # | Seam | Evidence | | # | Seam | Resolution |
|---|---|---| |---|---|---|
| 1 | The managed-SSE (SSE-S3 / SSE-KMS) read path returns "not encrypted" unless the object's *persisted* metadata carries the S3 response key `x-amz-server-side-encryption`. RustFS writes that key into metadata on PUT; MinIO's internal sealed-key headers alone do not satisfy the gate. | Gate: `rustfs/src/storage/sse.rs:2432`. RustFS write side: `rustfs/src/storage/sse.rs:391-400`. | | 1 | Managed-SSE detection required the *persisted* public `x-amz-server-side-encryption` key, which MinIO synthesizes at response time and never stores. | Closed by rustfs/rustfs#6191: `infer_minio_managed_sse_type` infers the scheme from which MinIO sealed-key slot is present (the slot also selects the sealing-key domain, so a wrong inference cannot silently derive a wrong key). Inference from the KMS key id would misclassify — MinIO writes `-S3-Kms-Key-Id` on SSE-S3 objects too. |
| 2 | MinIO's wrapped-DEK blob (`{"aead": ...}`) is neither produced nor accepted. `is_data_key_envelope` classifies that shape as not a RustFS envelope, and `LocalSseDekEnvelope` is `deny_unknown_fields`. | `crates/kms/src/encryption/dek.rs:425`, `:443`; `rustfs/src/storage/sse.rs:2784-2790`. Already documented for the static backend at `crates/kms/src/config.rs:304-308`. | | 2 | MinIO's wrapped-DEK ciphertext was not accepted by any envelope parser. | Closed by rustfs/rustfs#6191: `decrypt_minio_kms_data_key` implements MinIO's builtin-KMS sealing (`sealingKey = HMAC-SHA256(master, iv)`), accepting both the raw `sealed‖iv‖nonce` layout and the legacy `{"aead": ...}` JSON. Routing is by the data key's own byte shape — RustFS's strict JSON envelopes are recognized positively, everything else goes to the MinIO decoder — because slot names cannot distinguish the writer. `LocalSseDekEnvelope` keeps `deny_unknown_fields`. |
| 3 | SSE-C detection keys on `x-amz-server-side-encryption-customer-algorithm`, and `contains_managed_encryption_metadata` omits MinIO's SSE-C sealed-key header, so a MinIO SSE-C object matches neither detection branch. The unsealing code it would need is already written. | Detection: `rustfs/src/storage/sse.rs:2059` and `:3178-3184`; the omitted constant is `rustfs/src/storage/sse.rs:124`. Unsealing: `rustfs/src/storage/sse.rs:2236-2245`. | | 3 | SSE-C detection keyed on the stored customer-algorithm header, which MinIO also never persists, and the early key check demanded a stored key MD5 MinIO does not write. | Closed by rustfs/rustfs#6785: `stored_ssec_metadata` also accepts MinIO's SSE-C sealed-key slot (rio-v2 builds only), and `verify_ssec_key_match` tolerates a missing stored MD5 for exactly that shape — the AEAD unseal remains the key proof, and a wrong key still fails there. |
### How it fails Two further single-part defects were fixed on the way (both rustfs/rustfs#6191 follow-ups): multipart classification now trusts MinIO's own `X-Minio-Internal-Encrypted-Multipart` marker instead of an ETag-length heuristic (MinIO stores *encrypted* ETags, so every single-part SSE object mis-classified as multipart), and single-part plaintext sizes are recovered by DARE reverse-size arithmetic (`dare_v2_decrypted_size`) since MinIO records an explicit size only for multipart uploads.
The read fails closed: ciphertext is never served as plaintext. Seams 1 and 3 return `Ok(None)`, but that value does not reach the data path. `is_object_encryption_marker` matches the whole `x-minio-internal-server-side-encryption-` prefix (`crates/utils/src/http/header_compat.rs:50-67`), so `ObjectInfo::is_encrypted()` is true for these objects, and `crates/ecstore/src/object_api/readers.rs:559-568` turns the `Ok(None)` into `encrypted object metadata is incomplete` while constructing the reader. GET, CopyObject, replication and multipart sources all build the reader through that path. The inline fast path and the body cache both exclude encrypted objects explicitly, so neither bypasses it. ### How default builds fail
What migrates badly is the *diagnosis*, not the data. That error is not recognised by `map_get_object_reader_error` (`rustfs/src/storage/sse.rs:667`), so it surfaces as a 500 `InternalError` — which reads as a RustFS fault rather than "this object was encrypted by another implementation". List and HEAD still succeed, because `xl.meta` itself parses normally, so the object looks healthy until something reads it. The read fails closed: ciphertext is never served as plaintext. `is_object_encryption_marker` matches the whole `x-minio-internal-server-side-encryption-` prefix, so `ObjectInfo::is_encrypted()` is true for these objects, and the read plan refuses to construct a reader without decryption material. Since rustfs/rustfs#6784 the refusal is diagnosed: the resolver raises a typed error naming the condition — in default builds it points at the MinIO-compatible sealed format and the `rio-v2` read path it would require — and it surfaces as S3 `InvalidObjectState` (non-retryable) instead of the former undiagnosed 500 `InternalError`. List and HEAD still succeed, because `xl.meta` parses normally.
Seam 2 surfaces its own error, but only for objects that got past seam 1. ### What a `rio-v2` migration build needs
### The `rio-v2` feature does not change this - A binary built with `--features rio-v2`. The feature is deliberately absent from `default` and `full` in `rustfs/Cargo.toml`; released binaries and images never include it.
- For SSE-S3/SSE-KMS objects: `RUSTFS_SSE_S3_MASTER_KEY` (base64, 32 bytes) set to the same key material as the source MinIO's `MINIO_KMS_SECRET_KEY`. For SSE-C objects: nothing server-side — the client supplies the customer key per request, as on MinIO.
- The interop harness is the evidence chain: `rustfs/src/storage/minio_generated_read_test.rs` (`#[ignore]` reader tests over real MinIO-generated fixtures, run with `--features rio-v2`), the fixture lab under `crates/rio-v2/tests/minio_fixture_lab/`, and the `minio-interop` workflow. The SSE-C lane of that harness (customer-key handout from a fixture capture to the reader test) is not wired yet; SSE-C coverage currently lives in the unit suite, which builds the MinIO shape with the same sealing primitives the fixture suite proved byte-compatible.
`rustfs/src/storage/sse.rs` contains MinIO-interop code behind `#[cfg(feature = "rio-v2")]`, which can give the impression that enabling the feature closes the gap. It does not, for two independent reasons. Known unverified edge: MinIO seals ETags on SSE objects (`SealETag`); RustFS does not unseal them, so ETag display and `If-Match` semantics on migrated SSE objects are not guaranteed to match MinIO's.
- The feature is not compiled into anything that ships. `rio-v2` is absent from both `default` and `full` in `rustfs/Cargo.toml:39`, `:48`, `:51`; release binaries are built with no `--features` flag, and the published images install that binary rather than compiling their own.
- Seam 1 is not feature-gated and runs *before* the MinIO parser is consulted (`rustfs/src/storage/sse.rs:2432` precedes `:2458`). Even with `rio-v2` enabled, a MinIO-written managed-SSE object returns at the gate and never reaches `parse_minio_managed_sealed_key`.
The interop harness reflects this. The reader tests are `#[ignore]` (`rustfs/src/storage/minio_generated_read_test.rs:244`, `:250`), the workflow that would run them is disabled at the GitHub Actions level and states in its own header that end-to-end MinIO-to-RustFS SSE interop is not implemented (`.github/workflows/minio-interop.yml:24-29`, `:34-39`), and the fixture suite's scope note says the tests "do not yet validate full plaintext reconstruction from MinIO-written encrypted data" (`crates/rio-v2/tests/README.md:55`).
### Reverse direction ### Reverse direction
Migrating back is also unsupported. Under `rio-v2` RustFS writes its own DEK envelope into MinIO's sealed-key metadata slots and labels it with MinIO's seal algorithm (`rustfs/src/storage/sse.rs:1830-1852`), so the metadata is MinIO-shaped while the key bytes are not MinIO-openable. Default builds do not populate those slots at all (`rustfs/src/storage/sse.rs:1796-1798`). Treat RustFS-written SSE objects as readable only by RustFS. Migrating back is also unsupported. Under `rio-v2` RustFS writes its own DEK envelope into MinIO's sealed-key metadata slots and labels it with MinIO's seal algorithm (`rustfs/src/storage/sse.rs:1830-1852`), so the metadata is MinIO-shaped while the key bytes are not MinIO-openable. Default builds do not populate those slots at all (`rustfs/src/storage/sse.rs:1796-1798`). Treat RustFS-written SSE objects as readable only by RustFS.
### Working around the limitation ### Migration options
Until rustfs/backlog#1638 lands, the options are: - For static-KMS MinIO sources: run the migration through a `rio-v2` build with the shared master key (see above), either serving reads in place or copying objects out into a default-build cluster (the copy re-encrypts under RustFS's own KMS).
- For KES/MinKMS-backed sources, or when a special-purpose build is not wanted: decrypt on the MinIO side first — rewrite the affected objects as plaintext, or copy them out through MinIO's S3 endpoint, which decrypts on read — and let RustFS apply its own encryption on ingest.
- Decrypt on the MinIO side first: rewrite the affected objects as plaintext (or copy them out through MinIO's S3 endpoint, which decrypts on read) and migrate the plaintext, applying RustFS-side encryption afterwards.
- Copy through the S3 API rather than moving drives: a client that reads from MinIO and writes to RustFS gets plaintext from the source and lets RustFS encrypt with its own KMS. 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.
Inventory the source first — bucket default-encryption settings mean objects can be encrypted without the uploader having asked for it, so "we never set SSE headers" is not sufficient evidence that a bucket has no encrypted objects. Inventory the source first — bucket default-encryption settings mean objects can be encrypted without the uploader having asked for it, so "we never set SSE headers" is not sufficient evidence that a bucket has no encrypted objects.
@@ -291,13 +287,15 @@ Inventory the source first — bucket default-encryption settings mean objects c
|---|:--:|:--:|:--:| |---|:--:|:--:|:--:|
| DARE V2 stream format parity | ✅ | | | | DARE V2 stream format parity | ✅ | | |
| Object-key derivation / sealing parity | ✅ | | | | Object-key derivation / sealing parity | ✅ | | |
| MinIO sealed-key parser exists (behind `rio-v2`) | | ⚠️ | | | Managed-SSE detection accepts MinIO-written metadata (`rio-v2`) | ✅ | | |
| Managed-SSE detection accepts MinIO-written metadata | | | | | MinIO builtin-KMS wrapped-DEK parser (raw + legacy JSON) | ✅ | | |
| MinIO `{"aead": ...}` wrapped-DEK parser | | | | | SSE-C detection accepts MinIO-written metadata (`rio-v2`) | ✅ | | |
| SSE-C detection accepts MinIO-written metadata | | | | | Read MinIO-written SSE-S3 / SSE-KMS end to end, single- and multipart | | | |
| Read MinIO-written SSE-S3 / SSE-KMS / SSE-C objects end to end | | | ❌ | | Read MinIO-written SSE-C end to end | | ⚠️ unit-proven; fixture-lab lane unwired | |
| Migrated-object sealed-ETag semantics | | | ❌ unverified |
| KES / MinKMS / legacy `DARE-SHA256` sources | | | ❌ not planned |
| RustFS-written SSE objects readable by MinIO | | | ❌ | | RustFS-written SSE objects readable by MinIO | | | ❌ |
| CI proof of SSE read parity | | | ❌ | | CI proof of SSE read parity | | ⚠️ `minio-interop` workflow; nightly once re-enabled | |
--- ---
+357 -41
View File
@@ -490,7 +490,9 @@ impl EncryptionRequest<'_> {
"The provided encryption parameters did not match the multipart upload.", "The provided encryption parameters did not match the multipart upload.",
)); ));
} }
verify_ssec_key_match(&validated.key_md5, stored_key_md5) // Multipart session metadata is written by our own CreateMultipartUpload,
// which always records the MD5 — the MinIO no-stored-MD5 shape cannot occur.
verify_ssec_key_match(&validated.key_md5, stored_key_md5, false)
} }
} }
@@ -645,7 +647,7 @@ pub(crate) fn validate_sse_headers_for_read(metadata: &HashMap<String, String>,
|| headers.contains_key("x-amz-server-side-encryption-aws-kms-key-id") || headers.contains_key("x-amz-server-side-encryption-aws-kms-key-id")
|| headers.contains_key("x-amz-server-side-encryption-context"); || headers.contains_key("x-amz-server-side-encryption-context");
let is_object_ssec = metadata.contains_key("x-amz-server-side-encryption-customer-algorithm"); let is_object_ssec = stored_ssec_metadata(metadata);
let is_object_sse = metadata.contains_key("x-amz-server-side-encryption"); let is_object_sse = metadata.contains_key("x-amz-server-side-encryption");
if is_object_ssec { if is_object_ssec {
@@ -687,6 +689,10 @@ pub(crate) fn map_get_object_reader_error(err: StorageError) -> ApiError {
let code = match resolution_error.kind() { let code = match resolution_error.kind() {
EncryptionResolutionErrorKind::InvalidRequest => S3ErrorCode::InvalidRequest, EncryptionResolutionErrorKind::InvalidRequest => S3ErrorCode::InvalidRequest,
EncryptionResolutionErrorKind::ServiceUnavailable => S3ErrorCode::ServiceUnavailable, EncryptionResolutionErrorKind::ServiceUnavailable => S3ErrorCode::ServiceUnavailable,
// A permanent property of the stored object, not a transient server
// fault: 5xx would invite client retry storms against an object
// this server can never decrypt.
EncryptionResolutionErrorKind::InvalidMetadata => S3ErrorCode::InvalidObjectState,
_ => S3ErrorCode::InternalError, _ => S3ErrorCode::InternalError,
}; };
return ApiError { return ApiError {
@@ -1382,7 +1388,29 @@ impl ObjectEncryptionResolver for SseObjectEncryptionResolver {
.await .await
.map_err(map_encryption_resolution_error)?; .map_err(map_encryption_resolution_error)?;
Ok(material.map(|material| ReadEncryptionMaterial { let Some(material) = material else {
// Fail closed with a diagnosis instead of a bare `None`: the read
// plan classifies the object as encrypted from these same markers
// and would refuse to serve it anyway, but its generic error names
// neither the object's format nor what the operator can do about it.
if metadata
.keys()
.any(|key| rustfs_utils::http::is_object_encryption_marker(key))
{
#[cfg(not(feature = "rio-v2"))]
let message = "object is stored encrypted, but its decryption material could not be resolved: the \
encryption metadata is incomplete, or it is in the MinIO-compatible sealed format, \
which this server build has no read path for (reading it requires a build with the \
`rio-v2` feature)";
#[cfg(feature = "rio-v2")]
let message = "object is stored encrypted, but its decryption material could not be resolved: the \
encryption metadata is incomplete or in an unrecognized sealed format";
return Err(EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidMetadata, message));
}
return Ok(None);
};
Ok(Some(ReadEncryptionMaterial {
key_bytes: material.key_bytes, key_bytes: material.key_bytes,
mode: match material.key_kind { mode: match material.key_kind {
EncryptionKeyKind::Direct => ReadEncryptionMode::Direct { EncryptionKeyKind::Direct => ReadEncryptionMode::Direct {
@@ -2111,10 +2139,7 @@ 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
if request if stored_ssec_metadata(request.metadata) {
.metadata
.contains_key("x-amz-server-side-encryption-customer-algorithm")
{
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),
_ => { _ => {
@@ -2127,7 +2152,7 @@ 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)?; verify_ssec_key_match(key_md5, stored_md5, minio_sealed_ssec_metadata(request.metadata))?;
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());
@@ -2174,10 +2199,7 @@ pub struct SseReadResponseHeaders {
/// be established in this build — classify as `None`, exactly where /// be established in this build — classify as `None`, exactly where
/// [`sse_decryption`] returned `None`. /// [`sse_decryption`] returned `None`.
pub async fn classify_sse_read_response(request: DecryptionRequest<'_>) -> Result<Option<SseReadResponseHeaders>, ApiError> { pub async fn classify_sse_read_response(request: DecryptionRequest<'_>) -> Result<Option<SseReadResponseHeaders>, ApiError> {
if request if stored_ssec_metadata(request.metadata) {
.metadata
.contains_key("x-amz-server-side-encryption-customer-algorithm")
{
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),
_ => { _ => {
@@ -2189,7 +2211,7 @@ pub async fn classify_sse_read_response(request: DecryptionRequest<'_>) -> Resul
}; };
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)?; verify_ssec_key_match(key_md5, stored_md5, minio_sealed_ssec_metadata(request.metadata))?;
let algorithm = request let algorithm = request
.metadata .metadata
@@ -2229,11 +2251,13 @@ pub async fn classify_sse_read_response(request: DecryptionRequest<'_>) -> Resul
// Same key-id resolution chain as the unwrap path, so authorization and the // Same key-id resolution chain as the unwrap path, so authorization and the
// response header name the same key. // response header name the same key.
let normalized_metadata = normalize_managed_metadata(request.metadata, Some(recode_minio_kms_context)); let normalized_metadata = normalize_managed_metadata(request.metadata, Some(recode_minio_kms_context));
let kms_key_id = normalized_metadata let kms_key_id = resolve_stored_kms_key_id(
normalized_metadata
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER) .get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
.or_else(|| request.metadata.get("x-amz-server-side-encryption-aws-kms-key-id")) .or_else(|| request.metadata.get("x-amz-server-side-encryption-aws-kms-key-id"))
.cloned() .cloned(),
.unwrap_or_else(|| "default".to_string()); )
.await;
// Ahead of every other failure mode, so a denied caller learns nothing // Ahead of every other failure mode, so a denied caller learns nothing
// about the key beyond "not yours". // about the key beyond "not yours".
@@ -2619,6 +2643,27 @@ async fn apply_managed_decryption_material(
result result
} }
/// Key id an SSE-KMS read authorizes and reports when the stored metadata
/// carries none (legacy envelopes and MinIO-written objects predate the
/// explicit key-id headers).
///
/// The KMS service's configured default key is the key such envelopes were
/// actually wrapped under, so per-key authorization must target it — the bare
/// literal `default` would silently escape any policy narrowed to real key
/// ids. The literal remains only as the last resort when no service is
/// running, matching what the legacy write path recorded implicitly.
async fn resolve_stored_kms_key_id(stored: Option<String>) -> String {
if let Some(key_id) = stored {
return key_id;
}
if let Some(service) = runtime_sources::current_encryption_service().await
&& let Some(key_id) = service.get_default_key_id()
{
return key_id.clone();
}
"default".to_string()
}
async fn apply_managed_decryption_material_inner( async fn apply_managed_decryption_material_inner(
bucket: &str, bucket: &str,
key: &str, key: &str,
@@ -2664,11 +2709,13 @@ async fn apply_managed_decryption_material_inner(
let normalized_metadata = normalize_managed_metadata(metadata, Some(recode_minio_kms_context)); 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 = resolve_stored_kms_key_id(
normalized_metadata
.get(INTERNAL_ENCRYPTION_KEY_ID_HEADER) .get(INTERNAL_ENCRYPTION_KEY_ID_HEADER)
.or_else(|| metadata.get("x-amz-server-side-encryption-aws-kms-key-id")) .or_else(|| metadata.get("x-amz-server-side-encryption-aws-kms-key-id"))
.cloned() .cloned(),
.unwrap_or_else(|| "default".to_string()); )
.await;
// Ahead of every other failure mode below, so a denied caller learns nothing about the // Ahead of every other failure mode below, so a denied caller learns nothing about the
// key beyond "not yours" — not whether it is disabled, pending deletion, or unreadable. // key beyond "not yours" — not whether it is disabled, pending deletion, or unreadable.
@@ -3764,7 +3811,27 @@ 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")] /// True when the stored metadata marks the object as SSE-C protected.
///
/// RustFS persists the public `x-amz-server-side-encryption-customer-algorithm`
/// key on SSE-C writes. MinIO persists only its internal sealed-key slot and
/// synthesizes the public header onto responses (backlog#1638), so the slot is
/// the only durable evidence on a MinIO-written object. That shape is readable
/// solely through the rio-v2 sealed-object-key path, so it participates in
/// detection only in builds that can actually read it — default builds keep
/// the resolver's fail-closed diagnosis instead of demanding customer keys
/// they could not use.
fn stored_ssec_metadata(metadata: &HashMap<String, String>) -> bool {
metadata.contains_key("x-amz-server-side-encryption-customer-algorithm") || minio_sealed_ssec_metadata(metadata)
}
/// True when the object carries MinIO's SSE-C sealed-key slot — the shape that
/// stores no customer-key MD5 and proves the key through the AEAD unseal
/// instead.
fn minio_sealed_ssec_metadata(metadata: &HashMap<String, String>) -> bool {
cfg!(feature = "rio-v2") && metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_SSEC_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. /// Infer the managed SSE scheme from the MinIO sealed-key slot that is present.
/// ///
@@ -3897,12 +3964,18 @@ pub fn generate_ssec_nonce(bucket: &str, key: &str) -> [u8; 12] {
/// ///
/// Used during GetObject/HeadObject to ensure the client provided the correct key. /// Used during GetObject/HeadObject to ensure the client provided the correct key.
/// Returns 400 InvalidRequest on mismatch, consistent with AWS S3 behavior. /// Returns 400 InvalidRequest on mismatch, consistent with AWS S3 behavior.
pub fn verify_ssec_key_match(provided_md5: &str, stored_md5: Option<&String>) -> Result<(), ApiError> { pub fn verify_ssec_key_match(provided_md5: &str, stored_md5: Option<&String>, minio_sealed_ssec: bool) -> Result<(), ApiError> {
match stored_md5 { match stored_md5 {
Some(stored) if stored == provided_md5 => Ok(()), Some(stored) if stored == provided_md5 => Ok(()),
Some(_) => Err(ssec_invalid_request( Some(_) => Err(ssec_invalid_request(
"The provided encryption parameters did not match the ones used originally to encrypt the object.", "The provided encryption parameters did not match the ones used originally to encrypt the object.",
)), )),
// MinIO never persists the customer-key MD5, so this early check has
// nothing to compare against on a migrated object. The key itself is
// still proven: a wrong key fails the AEAD unseal of the sealed object
// key. The check exists only for the friendlier early error, so its
// absence is tolerated for exactly that shape and no other.
None if minio_sealed_ssec => Ok(()),
None => Err(ssec_invalid_request("Object has no stored SSE-C key metadata.")), None => Err(ssec_invalid_request("Object has no stored SSE-C key metadata.")),
} }
} }
@@ -3924,8 +3997,7 @@ pub fn validate_ssec_for_read(
sse_customer_key: Option<&SSECustomerKey>, sse_customer_key: Option<&SSECustomerKey>,
sse_customer_key_md5: Option<&SSECustomerKeyMD5>, sse_customer_key_md5: Option<&SSECustomerKeyMD5>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let stored_algorithm = metadata.get("x-amz-server-side-encryption-customer-algorithm"); if !stored_ssec_metadata(metadata) {
if stored_algorithm.is_none() {
return Ok(()); return Ok(());
} }
@@ -3941,7 +4013,10 @@ pub fn validate_ssec_for_read(
// Full param validation: decode key, verify 32 bytes, recompute MD5 // Full param validation: decode key, verify 32 bytes, recompute MD5
// from actual key bytes and compare to the client-provided MD5 header. // from actual key bytes and compare to the client-provided MD5 header.
let algorithm = stored_algorithm.cloned().unwrap_or_else(|| DEFAULT_SSE_ALGORITHM.to_string()); let algorithm = metadata
.get("x-amz-server-side-encryption-customer-algorithm")
.cloned()
.unwrap_or_else(|| DEFAULT_SSE_ALGORITHM.to_string());
let validated = validate_ssec_params(SsecParams { let validated = validate_ssec_params(SsecParams {
algorithm, algorithm,
key: key.to_string(), key: key.to_string(),
@@ -3949,7 +4024,7 @@ pub fn validate_ssec_for_read(
})?; })?;
let stored_md5 = metadata.get("x-amz-server-side-encryption-customer-key-md5"); let stored_md5 = metadata.get("x-amz-server-side-encryption-customer-key-md5");
verify_ssec_key_match(&validated.key_md5, stored_md5) verify_ssec_key_match(&validated.key_md5, stored_md5, minio_sealed_ssec_metadata(metadata))
} }
/// Build an `ApiError` with `InvalidRequest` (HTTP 400) for SSE-C related errors. /// Build an `ApiError` with `InvalidRequest` (HTTP 400) for SSE-C related errors.
@@ -3966,21 +4041,21 @@ fn ssec_invalid_request(message: &str) -> ApiError {
mod tests { mod tests {
use super::{ use super::{
ApiError, DataKey, DecryptionRequest, EncryptionKeyKind, EncryptionMaterial, EncryptionRequest, ApiError, DataKey, DecryptionRequest, EncryptionKeyKind, EncryptionMaterial, EncryptionRequest,
EncryptionResolutionErrorKind, INTERNAL_ENCRYPTION_ALGORITHM_HEADER, INTERNAL_ENCRYPTION_IV_HEADER, EncryptionResolutionError, EncryptionResolutionErrorKind, INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER, KmsAction, KmsKeyAuthorizer, KmsSseDekProvider, INTERNAL_ENCRYPTION_IV_HEADER, INTERNAL_ENCRYPTION_KEY_HEADER, INTERNAL_ENCRYPTION_KEY_ID_HEADER, KmsAction,
KmsUnavailableError, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER, MINIO_INTERNAL_ENCRYPTION_IV_HEADER, KmsKeyAuthorizer, KmsSseDekProvider, KmsUnavailableError, MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER,
MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_IV_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER,
MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_DATA_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_KEY_ID_HEADER,
MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER,
MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, ObjectDekRewrapOutcome, ObjectEncryptionResolver, MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER, ObjectDekRewrapOutcome,
PrepareEncryptionRequest, ReadEncryptionMode, ReadEncryptionRequest, SSEC_ORIGINAL_SIZE_HEADER, SSEType, SseDekProvider, ObjectEncryptionResolver, PrepareEncryptionRequest, ReadEncryptionMode, ReadEncryptionRequest, SSEC_ORIGINAL_SIZE_HEADER,
SseKmsPrincipal, SseObjectEncryptionResolver, SsecParams, StorageError, TestSseDekProvider, SSEType, SseDekProvider, SseKmsPrincipal, SseObjectEncryptionResolver, SsecParams, StorageError, TestSseDekProvider,
apply_managed_decryption_material, apply_managed_encryption_material, authorize_sse_kms_object_read, apply_managed_decryption_material, apply_managed_encryption_material, authorize_sse_kms_object_read,
build_kms_request_context, classify_sse_read_response, encode_minio_kms_context, encryption_material_to_metadata, build_kms_request_context, classify_sse_read_response, encode_minio_kms_context, encryption_material_to_metadata,
extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers, extract_server_side_encryption_from_headers, extract_ssec_params_from_headers, extract_ssekms_context_from_headers,
generate_ssec_nonce, is_managed_sse, kms_operation_error, map_get_object_reader_error, mark_encrypted_multipart_metadata, generate_ssec_nonce, is_managed_sse, kms_operation_error, map_get_object_reader_error, mark_encrypted_multipart_metadata,
md5_base64, normalize_managed_metadata, recode_minio_kms_context, reset_sse_dek_provider, resolve_effective_kms_key_id, md5_base64, normalize_managed_metadata, recode_minio_kms_context, reset_sse_dek_provider, resolve_effective_kms_key_id,
rewrap_object_encryption_metadata, sse_decryption, sse_encryption, sse_prepare_encryption, resolve_stored_kms_key_id, rewrap_object_encryption_metadata, sse_decryption, sse_encryption, sse_prepare_encryption,
strip_managed_encryption_metadata, validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read, strip_managed_encryption_metadata, validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read,
validate_ssec_params, verify_ssec_key_match, validate_ssec_params, verify_ssec_key_match,
}; };
@@ -4243,6 +4318,79 @@ mod tests {
assert_eq!(error.kind(), EncryptionResolutionErrorKind::InvalidMetadata); assert_eq!(error.kind(), EncryptionResolutionErrorKind::InvalidMetadata);
} }
#[tokio::test]
async fn object_encryption_resolver_diagnoses_unresolvable_encrypted_metadata() {
// A MinIO-written SSE-C object carries only internal sealed-key slots:
// no public scheme header, no customer-algorithm metadata. The read
// must fail closed either way, but the failure differs by build: a
// default build has no read path for the shape and diagnoses that; a
// rio-v2 build recognizes it as SSE-C and asks for the customer key.
let metadata = HashMap::from([(
MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER.to_string(),
"c2VhbGVkLWtleQ==".to_string(),
)]);
let result = SseObjectEncryptionResolver
.resolve_read_material(ReadEncryptionRequest {
bucket: "bucket",
object: "object",
metadata: &metadata,
headers: &HeaderMap::new(),
})
.await;
let error = match result {
Err(error) => error,
Ok(_) => panic!("encryption markers without resolvable material must fail closed"),
};
#[cfg(not(feature = "rio-v2"))]
{
assert_eq!(error.kind(), EncryptionResolutionErrorKind::InvalidMetadata);
assert!(
error.to_string().contains("could not be resolved"),
"error must diagnose the unresolvable metadata, got: {error}"
);
}
#[cfg(feature = "rio-v2")]
{
assert_eq!(error.kind(), EncryptionResolutionErrorKind::InvalidRequest);
assert!(
error.to_string().contains("must be provided"),
"error must ask for the SSE-C parameters, got: {error}"
);
}
}
#[tokio::test]
async fn object_encryption_resolver_keeps_plaintext_objects_unresolved() {
let metadata = HashMap::from([("content-type".to_string(), "text/plain".to_string())]);
let material = SseObjectEncryptionResolver
.resolve_read_material(ReadEncryptionRequest {
bucket: "bucket",
object: "object",
metadata: &metadata,
headers: &HeaderMap::new(),
})
.await
.expect("plaintext metadata resolves without error");
assert!(material.is_none(), "plaintext objects carry no read material");
}
#[test]
fn map_get_object_reader_error_maps_unresolvable_metadata_to_invalid_object_state() {
let error = StorageError::Io(std::io::Error::other(EncryptionResolutionError::new(
EncryptionResolutionErrorKind::InvalidMetadata,
"object is marked encrypted, but no decryption material could be resolved from its metadata",
)));
let api_error = map_get_object_reader_error(error);
assert_eq!(api_error.code, S3ErrorCode::InvalidObjectState);
assert!(api_error.message.contains("no decryption material could be resolved"));
}
#[tokio::test]
async fn resolve_stored_kms_key_id_prefers_the_stored_id() {
assert_eq!(resolve_stored_kms_key_id(Some("app-key".to_string())).await, "app-key");
}
#[test] #[test]
fn normalize_encryption_metadata_case_accepts_lowercase_minio_internal_keys() { fn normalize_encryption_metadata_case_accepts_lowercase_minio_internal_keys() {
let lowercase_key = MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_ascii_lowercase(); let lowercase_key = MINIO_INTERNAL_ENCRYPTION_S3_SEALED_KEY_HEADER.to_ascii_lowercase();
@@ -5511,7 +5659,7 @@ mod tests {
/// single leftover marker makes a plaintext destination report itself encrypted. /// single leftover marker makes a plaintext destination report itself encrypted.
/// `object_api::readers` then takes its encrypted branch and demands read material, /// `object_api::readers` then takes its encrypted branch and demands read material,
/// but the material itself was stripped, so `sse_decryption` reports no encryption /// but the material itself was stripped, so `sse_decryption` reports no encryption
/// and the read fails with "encrypted object metadata is incomplete" — a destination /// and the read fails closed on unresolvable encryption metadata — a destination
/// that CopyObject wrote successfully becomes permanently unreadable. /// that CopyObject wrote successfully becomes permanently unreadable.
#[test] #[test]
fn test_strip_managed_encryption_metadata_clears_encryption_markers() { fn test_strip_managed_encryption_metadata_clears_encryption_markers() {
@@ -5837,6 +5985,163 @@ mod tests {
assert_eq!(resolved.key_bytes, material.key_bytes); assert_eq!(resolved.key_bytes, material.key_bytes);
} }
/// Rewrites rio-v2 SSE-C metadata into the shape MinIO actually persists:
/// only the internal sealed-key slot, IV, and seal algorithm — the public
/// scheme, customer-algorithm, and customer-key-MD5 keys are synthesized
/// onto responses by MinIO and never hit disk (backlog#1638).
#[cfg(feature = "rio-v2")]
fn strip_to_minio_ssec_shape(metadata: &mut HashMap<String, String>) {
metadata.remove("x-amz-server-side-encryption");
metadata.remove("x-amz-server-side-encryption-customer-algorithm");
metadata.remove("x-amz-server-side-encryption-customer-key-md5");
}
#[cfg(feature = "rio-v2")]
#[tokio::test]
async fn test_minio_shaped_ssec_metadata_decrypts_with_the_right_key_only() {
let customer_key_bytes = [0x42u8; 32];
let customer_key = BASE64_STANDARD.encode_to_string(customer_key_bytes);
let customer_key_md5 = md5_base64(customer_key_bytes);
let material = sse_encryption(EncryptionRequest {
bucket: "bucket",
key: "object",
server_side_encryption: None,
ssekms_key_id: None,
ssekms_context: None,
sse_customer_algorithm: Some("AES256".to_string()),
sse_customer_key: Some(customer_key.clone()),
sse_customer_key_md5: Some(customer_key_md5.clone()),
content_size: 4096,
principal: None,
})
.await
.expect("sse-c encryption")
.expect("sse-c material");
let mut metadata = encryption_material_to_metadata(&material).expect("sse-c metadata should serialize");
strip_to_minio_ssec_shape(&mut metadata);
assert!(
!metadata.contains_key("x-amz-server-side-encryption-customer-key-md5"),
"the MinIO shape stores no customer-key MD5"
);
// Correct key: detected via the sealed slot, proven by the unseal.
let decrypted = sse_decryption(DecryptionRequest {
bucket: "bucket",
key: "object",
metadata: &metadata,
sse_customer_key: Some(&customer_key),
sse_customer_key_md5: Some(&customer_key_md5),
principal: None,
})
.await
.expect("minio-shaped sse-c decryption")
.expect("minio-shaped sse-c material");
assert_eq!(decrypted.key_kind, EncryptionKeyKind::Object);
assert_eq!(decrypted.key_bytes, material.key_bytes);
// Wrong key with a self-consistent MD5: the missing stored MD5 cannot
// catch it, so the AEAD unseal must — the relaxation is not a bypass.
let wrong_key_bytes = [0x43u8; 32];
let wrong_key = BASE64_STANDARD.encode_to_string(wrong_key_bytes);
let wrong_key_md5 = md5_base64(wrong_key_bytes);
let result = sse_decryption(DecryptionRequest {
bucket: "bucket",
key: "object",
metadata: &metadata,
sse_customer_key: Some(&wrong_key),
sse_customer_key_md5: Some(&wrong_key_md5),
principal: None,
})
.await;
assert!(result.is_err(), "a wrong customer key must fail the sealed-key unseal");
// No key at all: detected as SSE-C, so the caller is asked for the
// parameters instead of getting an opaque unreadable-object error.
let result = sse_decryption(DecryptionRequest {
bucket: "bucket",
key: "object",
metadata: &metadata,
sse_customer_key: None,
sse_customer_key_md5: None,
principal: None,
})
.await;
let error = result.expect_err("missing SSE-C parameters must fail closed");
assert!(error.message.contains("must be provided"), "got: {}", error.message);
}
#[cfg(feature = "rio-v2")]
#[tokio::test]
async fn test_minio_shaped_ssec_metadata_resolves_and_classifies() {
let customer_key_bytes = [0x51u8; 32];
let customer_key = BASE64_STANDARD.encode_to_string(customer_key_bytes);
let customer_key_md5 = md5_base64(customer_key_bytes);
let material = sse_encryption(EncryptionRequest {
bucket: "bucket",
key: "object",
server_side_encryption: None,
ssekms_key_id: None,
ssekms_context: None,
sse_customer_algorithm: Some("AES256".to_string()),
sse_customer_key: Some(customer_key.clone()),
sse_customer_key_md5: Some(customer_key_md5.clone()),
content_size: 4096,
principal: None,
})
.await
.expect("sse-c encryption")
.expect("sse-c material");
let mut metadata = encryption_material_to_metadata(&material).expect("sse-c metadata should serialize");
strip_to_minio_ssec_shape(&mut metadata);
let mut headers = HeaderMap::new();
headers.insert("x-amz-server-side-encryption-customer-algorithm", HeaderValue::from_static("AES256"));
headers.insert(
"x-amz-server-side-encryption-customer-key",
HeaderValue::from_str(&customer_key).expect("customer key header"),
);
headers.insert(
"x-amz-server-side-encryption-customer-key-md5",
HeaderValue::from_str(&customer_key_md5).expect("customer key MD5 header"),
);
let resolved = SseObjectEncryptionResolver
.resolve_read_material(ReadEncryptionRequest {
bucket: "bucket",
object: "object",
metadata: &metadata,
headers: &headers,
})
.await
.expect("minio-shaped SSE-C resolver")
.expect("minio-shaped SSE-C material");
assert_eq!(resolved.mode, ReadEncryptionMode::Object);
assert_eq!(resolved.key_bytes, material.key_bytes);
// HEAD-path key validation: no stored MD5 to compare against, so a
// self-consistent key passes here and the unseal remains the proof.
super::validate_ssec_for_read(&metadata, Some(&customer_key), Some(&customer_key_md5))
.expect("self-consistent SSE-C parameters validate");
super::validate_ssec_for_read(&metadata, None, None).expect_err("missing SSE-C parameters must be rejected");
// Response classification reports SSE-C, not managed SSE.
let classified = classify_sse_read_response(DecryptionRequest {
bucket: "bucket",
key: "object",
metadata: &metadata,
sse_customer_key: Some(&customer_key),
sse_customer_key_md5: Some(&customer_key_md5),
principal: None,
})
.await
.expect("minio-shaped sse-c classification")
.expect("minio-shaped sse-c response headers");
assert!(classified.sse_customer_algorithm.is_some());
assert!(classified.ssekms_key_id.is_none());
}
#[cfg(feature = "rio-v2")] #[cfg(feature = "rio-v2")]
#[test] #[test]
fn test_mark_encrypted_multipart_metadata_sets_minio_marker() { fn test_mark_encrypted_multipart_metadata_sets_minio_marker() {
@@ -5845,23 +6150,34 @@ mod tests {
assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER)); assert!(metadata.contains_key(MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER));
} }
#[test]
fn test_verify_ssec_key_match_tolerates_missing_md5_only_for_minio_sealed_ssec() {
// MinIO SSE-C objects store no customer-key MD5; the AEAD unseal is the
// real key proof, so the early check stands down for that shape only.
assert!(verify_ssec_key_match("provided_md5", None, true).is_ok());
// A stored MD5 always wins, even on the MinIO shape: a mismatch is a
// mismatch.
let stored = "stored_md5".to_string();
assert!(verify_ssec_key_match("provided_md5", Some(&stored), true).is_err());
}
#[test] #[test]
fn test_verify_ssec_key_match_success() { fn test_verify_ssec_key_match_success() {
let md5 = "test_md5".to_string(); let md5 = "test_md5".to_string();
let result = verify_ssec_key_match("test_md5", Some(&md5)); let result = verify_ssec_key_match("test_md5", Some(&md5), false);
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn test_verify_ssec_key_match_mismatch() { fn test_verify_ssec_key_match_mismatch() {
let md5 = "stored_md5".to_string(); let md5 = "stored_md5".to_string();
let result = verify_ssec_key_match("provided_md5", Some(&md5)); let result = verify_ssec_key_match("provided_md5", Some(&md5), false);
assert!(result.is_err()); assert!(result.is_err());
} }
#[test] #[test]
fn test_verify_ssec_key_match_no_stored() { fn test_verify_ssec_key_match_no_stored() {
let result = verify_ssec_key_match("provided_md5", None); let result = verify_ssec_key_match("provided_md5", None, false);
assert!(result.is_err()); assert!(result.is_err());
} }
@@ -6572,13 +6888,13 @@ mod tests {
#[test] #[test]
fn test_verify_ssec_key_match_returns_invalid_request() { fn test_verify_ssec_key_match_returns_invalid_request() {
let stored = "stored_md5".to_string(); let stored = "stored_md5".to_string();
let err = verify_ssec_key_match("wrong_md5", Some(&stored)).unwrap_err(); let err = verify_ssec_key_match("wrong_md5", Some(&stored), false).unwrap_err();
assert_eq!(err.code, S3ErrorCode::InvalidRequest); assert_eq!(err.code, S3ErrorCode::InvalidRequest);
} }
#[test] #[test]
fn test_verify_ssec_key_match_no_stored_returns_invalid_request() { fn test_verify_ssec_key_match_no_stored_returns_invalid_request() {
let err = verify_ssec_key_match("any_md5", None).unwrap_err(); let err = verify_ssec_key_match("any_md5", None, false).unwrap_err();
assert_eq!(err.code, S3ErrorCode::InvalidRequest); assert_eq!(err.code, S3ErrorCode::InvalidRequest);
} }