Files
rustfs/crates/kms/tests/behavior_objects.rs
T
唐小鸭 62cc19e937 fix(kms): repair unopenable ciphertext and cover the Vault backends (#5668)
* Add black-box behavior tests for KMS resilience and serialization

* fix(kms): repair unopenable ciphertext across backends

Black-box testing of the KMS crate surfaced several defects that make
encrypted data permanently unreadable.

Symmetric envelopes. The Local and Vault Transit backends returned raw
cipher output from `encrypt` while `decrypt` parsed a JSON envelope, so
anything sealed through the master-key path could never be opened again.
Local also discarded the AES-GCM nonce. Both now emit the same envelope
`decrypt` consumes, matching the Static backend.

Deterministic AAD. The object layer derived AEAD additional data by
serializing a `HashMap` directly. Iteration order differs per instance,
so a context rebuilt from storage produced different AAD bytes than the
one used to seal and the object stopped opening. Ordering by key removes
that dependency, matching the Static backend's existing `context_aad`.
Objects written with the default single-key context are unaffected,
since a one-entry map has only one serialization.

Cipher in the header projection. `metadata_to_headers` recorded the SSE
mode (`AES256` / `aws:kms`), which cannot represent ChaCha20-Poly1305,
so a ChaCha-sealed object came back claiming `aws:kms` and was opened
with the wrong cipher. The cipher now travels in
`x-rustfs-encryption-algorithm` — the header the storage layer already
reads but nothing ever wrote. Objects without it fall back as before.

Also: the Static backend ignored `key_spec` and always issued 256-bit
data keys; Local `list_keys` hardcoded `truncated: false`, ignored
`marker`, and paginated over unordered `read_dir`, so a paginating
client silently saw a partial key list; and Local and Vault KV2 reported
`key_id: "unknown"` from `decrypt` despite the envelope naming the
master key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(kms): cover both Vault backends and key rotation

The behavior suite ran only against Local and Static, and its own harness
documented the gap: the Vault backends had no business-capability
coverage at all. Setting `RUSTFS_KMS_VAULT_TOKEN` now adds Vault KV2 and
Vault Transit to every `for_each_backend` spec against a live server.
That lane is what surfaced the Transit envelope defect fixed in the
previous commit.

`rotate` and `versioning` are advertised only by the Vault backends, so
until now every capability-gated branch for them took the
`UnsupportedCapability` side and the working half was never asserted — a
rotation that dropped prior key versions would have gone green. The new
`behavior_rotation.rs` pins that half: material sealed before a rotation
still opens after it, repeated rotations accumulate versions rather than
overwriting a single spare, and the history survives a restart.

Two test defects fixed. `objects_round_trip_across_sizes_and_algorithms`
asserted a 1-byte object differs from its own ciphertext, which collides
once every 256 runs; the assertion now applies only where a collision is
not realistic, and small objects stay covered by the tag check and the
decrypt round-trip. `test_from_env_selects_token_file` depended on
`RUSTFS_KMS_VAULT_TOKEN` being absent from the caller's environment and
now clears it explicitly.

The snapshots directory was also removed from `.gitignore`: insta
snapshots are the assertions themselves, so leaving them untracked gives
CI nothing to compare against. Only `.snap.new` scratch files are
ignored now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(kms): adapt behavior suite to current key APIs

Rebasing onto main brought four API changes the suite predates.

`DeleteKeyRequest` gained `confirm_key_id`, and immediate deletion is now
gated on the server's `allow_immediate_deletion`. Scheduled deletions pass
`None`; the four specs that destroy a key outright echo the key id back
and opt the harness config in, which is what the gate asks of a real
caller.

`LocalBackupExportRequest` gained `sanitized_config`. These specs cover
the key-material path, so they seal no configuration and pass `None`.

`KmsCacheStats` became a named struct with real hit, miss, and eviction
counters. `cache_stats_returns_an_entry_count_and_no_hit_or_miss_data`
existed to pin the old placeholder behavior — that the second tuple
element was always zero — which main has since fixed, so it is now
`cache_stats_reports_hits_and_misses_separately` and asserts the counters
actually move.

Starting the service provisions the reserved probe key, so it shows up in
listings and backup bundles. Exact-set assertions filter it through a new
`without_probe_key` helper rather than naming it, keeping those specs
about the keys they seeded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(kms): bind the AAD to the stored context bytes

Review caught that canonicalizing the AAD on decrypt breaks objects sealed
before canonicalization existed, and it was right. The AAD is the
*serialization* of the encryption context, and `x-rustfs-encryption-context`
stores that exact byte sequence: `encrypt_object` fed one `HashMap` to the
AEAD and then moved the same map into the metadata the header is written
from, so the stored string is byte-identical to the AAD the object was
sealed under. Those objects are therefore recoverable — but only while
nothing round-trips the value through a `HashMap` and re-serializes it.

Recomputing sorted AAD on decrypt would have turned a readable object into
a permanently unreadable one. The previous behavior was worse than the
first analysis credited: it did not merely fail intermittently, it made
the failure deterministic.

`EncryptionMetadata` now carries `context_aad`, the bytes the object was
actually sealed with. Encryption records what it fed the AEAD, the header
projection stores those bytes verbatim (and preserves a legacy ordering
across a re-projection rather than rewriting it into sorted form), and
`headers_to_metadata` carries the stored string through untouched. Both
decrypt paths, SSE-KMS and SSE-C, prefer it and fall back to canonical
serialization only when no stored serialization exists. Canonicalization
still applies to everything newly sealed, so the original ordering bug
cannot recur.

Two tests pin this: a legacy record whose sealed bytes are non-canonical
must survive a full header round trip unchanged, and a context header
rewritten to an equivalent-but-reordered serialization must fail
authentication rather than silently re-deriving a working AAD. Both were
mutation-checked against the reinstated bug on each side.

Also from review: the lifecycle churn test asserted only that every
request was accounted for, which holds whether the state gate exists or
not, so both branches are now pinned deterministically after the churn
(asserting `refused > 0` on the concurrent phase would only trade the hole
for a scheduling flake). And the Local and Vault KV2 envelopes compare
`encryption_context` without authenticating it — `DekCrypto` seals only
the plaintext — which is now documented at both sites; closing it needs a
versioned envelope, since existing ciphertext was sealed without AAD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:33:08 +08:00

928 lines
35 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Black-box behavior: object encryption (SSE-S3, SSE-KMS, SSE-C).
//!
//! This is the surface `rustfs/src/storage/ecfs.rs` calls on every PUT and GET,
//! so the contract is unusually load-bearing: a break here is unreadable
//! objects, not a failed request. The suite covers
//!
//! * round-tripping across sizes and both AEAD algorithms, including the
//! degenerate empty object and block-boundary sizes;
//! * the header projection — `metadata_to_headers` is what actually reaches
//! disk, and `headers_to_metadata` is what a GET has to rebuild from it, so
//! the pair must compose into a working decrypt;
//! * tamper and context-mismatch rejection;
//! * SSE-C, where the key never touches the KMS at all.
mod common;
use std::collections::HashMap;
use common::{
TestKms, assert_context_mismatch, assert_invalid_key_size, assert_invalid_operation, ctx, discard, flip_middle_bit, payload,
};
use rustfs_kms::{EncryptionAlgorithm, EncryptionMetadata, KmsError, ObjectEncryptionService};
use tokio::io::AsyncReadExt as _;
const BUCKET: &str = "objects-behavior";
/// Sizes chosen to straddle the AEAD block boundary and the empty case.
const SIZES: &[usize] = &[0, 1, 15, 16, 17, 4096, 65_537];
async fn read_all(mut reader: Box<dyn tokio::io::AsyncRead + Send + Sync + Unpin>) -> Vec<u8> {
let mut out = Vec::new();
reader.read_to_end(&mut out).await.expect("reading plaintext should succeed");
out
}
async fn service_with_key(key_id: &str) -> (TestKms, std::sync::Arc<ObjectEncryptionService>) {
let kms = TestKms::local_with(|config| config.default_key_id = Some(key_id.to_string())).await;
kms.create_key(key_id).await;
let service = kms.service().await;
(kms, service)
}
#[tokio::test]
async fn objects_round_trip_across_sizes_and_algorithms() {
let (_kms, service) = service_with_key("sse-round-trip").await;
for algorithm in [EncryptionAlgorithm::Aes256, EncryptionAlgorithm::ChaCha20Poly1305] {
for &size in SIZES {
let object_key = format!("{}/{size}.bin", algorithm.as_str());
let data = payload(size);
let encrypted = service
.encrypt_object(BUCKET, &object_key, data.as_slice(), &algorithm, None, None)
.await
.unwrap_or_else(|error| panic!("encrypting {size} bytes with {algorithm:?} failed: {error:?}"));
assert_eq!(
encrypted.metadata.original_size, size as u64,
"metadata must record the plaintext size for {algorithm:?}/{size}"
);
assert_eq!(
encrypted.metadata.algorithm,
algorithm.as_str(),
"metadata must record the algorithm actually used"
);
assert_eq!(
encrypted.metadata.iv.len(),
algorithm.iv_size(),
"the IV must be the algorithm's nonce size"
);
assert!(encrypted.metadata.tag.is_some(), "an AEAD algorithm must produce a tag");
assert!(
!encrypted.metadata.encrypted_data_key.is_empty(),
"the wrapped DEK must be stored with the object"
);
// Only assert this where a collision is not a realistic outcome. A
// 1-byte object matches its own ciphertext once every 256 runs, so
// asserting it there would make the suite flaky rather than strict.
// Small objects are still covered: the tag is checked above and the
// decrypt round-trip below is what actually proves the encryption.
if size >= 8 {
assert_ne!(encrypted.ciphertext, data, "ciphertext must differ from plaintext");
}
// The context the server binds must name the object unambiguously.
let context = &encrypted.metadata.encryption_context;
assert_eq!(context.get("bucket").map(String::as_str), Some(BUCKET));
assert_eq!(context.get("object_key").map(String::as_str), Some(object_key.as_str()));
assert_eq!(
context.get("object").map(String::as_str),
Some(object_key.as_str()),
"the legacy `object` key must stay populated for older readers"
);
assert_eq!(context.get("algorithm").map(String::as_str), Some(algorithm.as_str()));
let decrypted = read_all(
service
.decrypt_object(BUCKET, &object_key, encrypted.ciphertext.clone(), &encrypted.metadata, None)
.await
.unwrap_or_else(|error| panic!("decrypting {size} bytes with {algorithm:?} failed: {error:?}")),
)
.await;
assert_eq!(decrypted, data, "round-trip must return the original {size} bytes");
}
}
}
#[tokio::test]
async fn two_objects_never_share_a_data_key() {
let (_kms, service) = service_with_key("sse-per-object").await;
let data = payload(512);
let first = service
.encrypt_object(BUCKET, "first.bin", data.as_slice(), &EncryptionAlgorithm::Aes256, None, None)
.await
.expect("encrypt first");
let second = service
.encrypt_object(BUCKET, "second.bin", data.as_slice(), &EncryptionAlgorithm::Aes256, None, None)
.await
.expect("encrypt second");
assert_ne!(
first.metadata.encrypted_data_key, second.metadata.encrypted_data_key,
"each object must carry its own wrapped data key"
);
assert_ne!(first.metadata.iv, second.metadata.iv, "each object must get a fresh IV");
assert_ne!(
first.ciphertext, second.ciphertext,
"identical plaintext under different objects must not produce identical ciphertext"
);
// Re-encrypting the *same* object also produces fresh material: a repeated
// PUT must not reuse the previous version's key or IV.
let again = service
.encrypt_object(BUCKET, "first.bin", data.as_slice(), &EncryptionAlgorithm::Aes256, None, None)
.await
.expect("re-encrypt first");
assert_ne!(
first.metadata.encrypted_data_key, again.metadata.encrypted_data_key,
"a repeated PUT of the same object must mint a new data key"
);
assert_ne!(first.ciphertext, again.ciphertext, "a repeated PUT must not be deterministic");
}
#[tokio::test]
async fn cross_object_ciphertext_and_metadata_do_not_interchange() {
let (_kms, service) = service_with_key("sse-cross-object").await;
let alpha = service
.encrypt_object(BUCKET, "alpha.bin", payload(300).as_slice(), &EncryptionAlgorithm::Aes256, None, None)
.await
.expect("encrypt alpha");
let beta = service
.encrypt_object(BUCKET, "beta.bin", payload(300).as_slice(), &EncryptionAlgorithm::Aes256, None, None)
.await
.expect("encrypt beta");
// Alpha's bytes under beta's metadata must fail authentication rather than
// return beta's plaintext or garbage.
assert!(
service
.decrypt_object(BUCKET, "beta.bin", alpha.ciphertext.clone(), &beta.metadata, None)
.await
.is_err(),
"one object's ciphertext must not open under another's metadata"
);
// A spliced metadata record — beta's wrapped key grafted onto alpha's
// record — must not decrypt either.
let mut spliced = alpha.metadata.clone();
spliced.encrypted_data_key = beta.metadata.encrypted_data_key.clone();
assert!(
service
.decrypt_object(BUCKET, "alpha.bin", alpha.ciphertext.clone(), &spliced, None)
.await
.is_err(),
"grafting another object's wrapped key must not yield a working decrypt"
);
}
#[tokio::test]
async fn tampered_ciphertext_and_metadata_are_rejected() {
let (_kms, service) = service_with_key("sse-tamper").await;
let data = payload(1024);
let encrypted = service
.encrypt_object(BUCKET, "victim.bin", data.as_slice(), &EncryptionAlgorithm::Aes256, None, None)
.await
.expect("encrypt");
let attempt = |ciphertext: Vec<u8>, metadata: EncryptionMetadata| {
let service = service.clone();
async move {
service
.decrypt_object(BUCKET, "victim.bin", ciphertext, &metadata, None)
.await
}
};
assert!(
attempt(flip_middle_bit(&encrypted.ciphertext), encrypted.metadata.clone())
.await
.is_err(),
"a single flipped ciphertext bit must fail authentication"
);
assert!(
attempt(
encrypted.ciphertext[..encrypted.ciphertext.len() - 1].to_vec(),
encrypted.metadata.clone()
)
.await
.is_err(),
"a truncated object must fail authentication"
);
let mut bad_iv = encrypted.metadata.clone();
bad_iv.iv = flip_middle_bit(&encrypted.metadata.iv);
assert!(
attempt(encrypted.ciphertext.clone(), bad_iv).await.is_err(),
"a tampered IV must fail authentication"
);
let mut bad_tag = encrypted.metadata.clone();
bad_tag.tag = Some(flip_middle_bit(encrypted.metadata.tag.as_ref().expect("tag")));
assert!(
attempt(encrypted.ciphertext.clone(), bad_tag).await.is_err(),
"a tampered tag must fail authentication"
);
let mut no_tag = encrypted.metadata.clone();
no_tag.tag = None;
assert_invalid_operation(discard(attempt(encrypted.ciphertext.clone(), no_tag).await), "Missing authentication tag");
let mut bad_algorithm = encrypted.metadata.clone();
bad_algorithm.algorithm = "ROT13".to_string();
match discard(attempt(encrypted.ciphertext.clone(), bad_algorithm).await) {
Err(KmsError::UnsupportedAlgorithm { algorithm }) => assert_eq!(algorithm, "ROT13"),
other => panic!("expected UnsupportedAlgorithm, got {other:?}"),
}
// A rewritten context breaks the AAD, so the object stops opening — this is
// what makes the stored context tamper-evident rather than advisory.
let mut rewritten_context = encrypted.metadata.clone();
rewritten_context
.encryption_context
.insert("bucket".to_string(), "attacker-bucket".to_string());
assert!(
attempt(encrypted.ciphertext.clone(), rewritten_context).await.is_err(),
"rewriting the bound context must break decryption"
);
// The untouched original still decrypts, proving the failures above are
// caused by the tampering and not by a broken fixture.
let recovered = read_all(
attempt(encrypted.ciphertext.clone(), encrypted.metadata.clone())
.await
.expect("the pristine object must still decrypt"),
)
.await;
assert_eq!(recovered, data);
}
#[tokio::test]
async fn expected_context_validation_catches_a_relocated_object() {
let (_kms, service) = service_with_key("sse-context-check").await;
let encrypted = service
.encrypt_object(
BUCKET,
"docs/report.pdf",
payload(64).as_slice(),
&EncryptionAlgorithm::Aes256,
None,
None,
)
.await
.expect("encrypt");
// Matching expectations pass through.
read_all(
service
.decrypt_object(
BUCKET,
"docs/report.pdf",
encrypted.ciphertext.clone(),
&encrypted.metadata,
Some(&ctx(&[("bucket", BUCKET), ("object_key", "docs/report.pdf")])),
)
.await
.expect("a matching expected context must be accepted"),
)
.await;
// A caller expecting a different object refuses before touching the KMS:
// this is the guard against a ciphertext being served under another key.
assert_context_mismatch(discard(
service
.decrypt_object(
BUCKET,
"docs/report.pdf",
encrypted.ciphertext.clone(),
&encrypted.metadata,
Some(&ctx(&[("object_key", "docs/other.pdf")])),
)
.await,
));
assert_context_mismatch(discard(
service
.decrypt_object(
BUCKET,
"docs/report.pdf",
encrypted.ciphertext.clone(),
&encrypted.metadata,
Some(&ctx(&[("bucket", "another-bucket")])),
)
.await,
));
// A key that was never bound cannot be satisfied.
assert_context_mismatch(discard(
service
.decrypt_object(
BUCKET,
"docs/report.pdf",
encrypted.ciphertext.clone(),
&encrypted.metadata,
Some(&ctx(&[("never-bound", "value")])),
)
.await,
));
}
/// The projection an object actually survives on: metadata is written to disk
/// as headers and rebuilt from them on the next GET.
#[tokio::test]
async fn metadata_survives_the_header_projection() {
let (_kms, service) = service_with_key("sse-headers").await;
for algorithm in [EncryptionAlgorithm::Aes256, EncryptionAlgorithm::ChaCha20Poly1305] {
let object_key = format!("headers/{}.bin", algorithm.as_str());
let data = payload(2048);
let encrypted = service
.encrypt_object(BUCKET, &object_key, data.as_slice(), &algorithm, None, None)
.await
.expect("encrypt");
let headers = service.metadata_to_headers(&encrypted.metadata);
// The S3-visible header must reflect the mode the object was written in.
match algorithm {
EncryptionAlgorithm::Aes256 => assert_eq!(
headers.get("x-amz-server-side-encryption").map(String::as_str),
Some("AES256"),
"AES-256 objects advertise SSE-S3"
),
_ => {
assert_eq!(
headers.get("x-amz-server-side-encryption").map(String::as_str),
Some("aws:kms"),
"non-AES-256 objects advertise SSE-KMS"
);
assert_eq!(
headers.get("x-amz-server-side-encryption-aws-kms-key-id").map(String::as_str),
Some(encrypted.metadata.key_id.as_str()),
"SSE-KMS must name its key in the S3 header"
);
}
}
assert_eq!(
headers.get("x-rustfs-encryption-key-id").map(String::as_str),
Some(encrypted.metadata.key_id.as_str()),
"the internal key-id header must always be present for KMS-backed objects"
);
for required in [
"x-rustfs-encryption-iv",
"x-rustfs-encryption-tag",
"x-rustfs-encryption-key",
"x-rustfs-encryption-context",
] {
assert!(headers.contains_key(required), "header {required} is required to rebuild metadata");
}
// Nothing plaintext-sensitive may ride along in a header.
assert!(
!headers.values().any(|value| value.contains("BEGIN")),
"headers must not carry key material"
);
let rebuilt = service
.headers_to_metadata(&headers)
.expect("headers written by this service must parse back");
assert_eq!(rebuilt.key_id, encrypted.metadata.key_id, "key id must survive the projection");
assert_eq!(rebuilt.iv, encrypted.metadata.iv, "IV must survive the projection");
assert_eq!(rebuilt.tag, encrypted.metadata.tag, "tag must survive the projection");
assert_eq!(
rebuilt.encrypted_data_key, encrypted.metadata.encrypted_data_key,
"the wrapped DEK must survive the projection"
);
assert_eq!(
rebuilt.encryption_context, encrypted.metadata.encryption_context,
"the bound context must survive the projection"
);
// The point of the projection: the rebuilt record must open the object.
// Field equality is not enough — `decrypt_object` derives its AEAD
// additional data from the context, so the context has to survive as
// *bytes*, not merely as a map.
let decrypted = read_all(
service
.decrypt_object(BUCKET, &object_key, encrypted.ciphertext.clone(), &rebuilt, None)
.await
.unwrap_or_else(|error| panic!("[{algorithm:?}] rebuilt metadata must decrypt the object: {error:?}")),
)
.await;
assert_eq!(decrypted, data, "[{algorithm:?}] the header round-trip must preserve the plaintext");
}
}
#[tokio::test]
async fn headers_missing_required_fields_are_rejected() {
let (_kms, service) = service_with_key("sse-bad-headers").await;
let encrypted = service
.encrypt_object(
BUCKET,
"bad-headers.bin",
payload(32).as_slice(),
&EncryptionAlgorithm::Aes256,
None,
None,
)
.await
.expect("encrypt");
let good = service.metadata_to_headers(&encrypted.metadata);
let without = |name: &str| {
let mut headers = good.clone();
headers.remove(name);
headers
};
assert!(
service.headers_to_metadata(&HashMap::new()).is_err(),
"an empty header set carries no algorithm and must be rejected"
);
assert!(
service.headers_to_metadata(&without("x-amz-server-side-encryption")).is_err(),
"the algorithm header is required"
);
assert!(
service.headers_to_metadata(&without("x-rustfs-encryption-iv")).is_err(),
"the IV header is required"
);
// Malformed base64 must be a validation error, not a panic.
for field in ["x-rustfs-encryption-iv", "x-rustfs-encryption-tag", "x-rustfs-encryption-key"] {
let mut headers = good.clone();
headers.insert(field.to_string(), "!!! not base64 !!!".to_string());
assert!(
service.headers_to_metadata(&headers).is_err(),
"malformed base64 in {field} must be rejected"
);
}
let mut bad_context = good.clone();
bad_context.insert("x-rustfs-encryption-context".to_string(), "{not json".to_string());
assert!(
service.headers_to_metadata(&bad_context).is_err(),
"a malformed context header must be rejected"
);
}
#[tokio::test]
async fn sse_s3_auto_creates_its_key_but_sse_kms_requires_one() {
// No key exists yet; only the default id is configured.
let kms = TestKms::local_with(|config| config.default_key_id = Some("auto-created".to_string())).await;
let service = kms.service().await;
let encrypted = service
.encrypt_object(BUCKET, "auto.bin", payload(16).as_slice(), &EncryptionAlgorithm::Aes256, None, None)
.await
.expect("SSE-S3 must auto-create its default key");
assert_eq!(encrypted.metadata.key_id, "auto-created");
// The key really exists now and the object opens.
let manager = kms.kms().await;
manager
.describe_key(rustfs_kms::DescribeKeyRequest {
key_id: "auto-created".to_string(),
})
.await
.expect("the auto-created key must be describable afterwards");
read_all(
service
.decrypt_object(BUCKET, "auto.bin", encrypted.ciphertext.clone(), &encrypted.metadata, None)
.await
.expect("the auto-created key must decrypt its object"),
)
.await;
// A non-AES-256 algorithm is SSE-KMS: the caller must have provisioned the
// key, because auto-creating a customer-named key would be surprising.
assert_invalid_operation(
discard(
service
.encrypt_object(
BUCKET,
"explicit.bin",
payload(16).as_slice(),
&EncryptionAlgorithm::ChaCha20Poly1305,
Some("never-created"),
None,
)
.await,
),
"not found",
);
// With no key id and no default at all, the call is a configuration error.
let bare = TestKms::local().await;
let bare_service = bare.service().await;
match bare_service
.encrypt_object(BUCKET, "bare.bin", payload(16).as_slice(), &EncryptionAlgorithm::Aes256, None, None)
.await
{
Err(KmsError::ConfigurationError { message }) => {
assert!(message.contains("No KMS key ID"), "should explain the missing key id: {message}")
}
other => panic!("expected ConfigurationError, got {other:?}"),
}
}
#[tokio::test]
async fn caller_supplied_context_is_merged_and_bound() {
let (_kms, service) = service_with_key("sse-extra-context").await;
let extra = ctx(&[("tenant", "acme"), ("classification", "internal")]);
let encrypted = service
.encrypt_object(
BUCKET,
"tenant.bin",
payload(128).as_slice(),
&EncryptionAlgorithm::Aes256,
None,
Some(&extra),
)
.await
.expect("encrypt with extra context");
for (key, value) in &extra {
assert_eq!(
encrypted.metadata.encryption_context.get(key),
Some(value),
"caller context {key} must be preserved in the stored metadata"
);
}
// The server-owned keys are still present and win over any caller value.
assert_eq!(encrypted.metadata.encryption_context.get("bucket").map(String::as_str), Some(BUCKET));
read_all(
service
.decrypt_object(BUCKET, "tenant.bin", encrypted.ciphertext.clone(), &encrypted.metadata, Some(&extra))
.await
.expect("the merged context must validate and decrypt"),
)
.await;
// Dropping a caller-supplied key from the stored context breaks the AAD.
let mut stripped = encrypted.metadata.clone();
stripped.encryption_context.remove("tenant");
assert!(
service
.decrypt_object(BUCKET, "tenant.bin", encrypted.ciphertext.clone(), &stripped, None)
.await
.is_err(),
"removing a bound context entry must break decryption"
);
}
#[tokio::test]
async fn sse_c_round_trips_and_rejects_the_wrong_key() {
let (_kms, service) = service_with_key("sse-c-unused").await;
let customer_key = [0x11u8; 32];
let wrong_key = [0x22u8; 32];
let data = payload(4096);
let encrypted = service
.encrypt_object_with_customer_key(BUCKET, "customer.bin", data.as_slice(), &customer_key, None)
.await
.expect("SSE-C encrypt");
assert_eq!(
encrypted.metadata.key_id, "sse-c",
"SSE-C objects are marked so a GET knows not to consult the KMS"
);
assert!(
encrypted.metadata.encrypted_data_key.is_empty(),
"SSE-C stores no wrapped data key: the customer holds the only copy"
);
assert_eq!(encrypted.metadata.original_size, data.len() as u64);
let decrypted = read_all(
service
.decrypt_object_with_customer_key(
BUCKET,
"customer.bin",
encrypted.ciphertext.clone(),
&encrypted.metadata,
&customer_key,
)
.await
.expect("SSE-C decrypt with the right key"),
)
.await;
assert_eq!(decrypted, data, "SSE-C round-trip must return the original bytes");
assert!(
service
.decrypt_object_with_customer_key(
BUCKET,
"customer.bin",
encrypted.ciphertext.clone(),
&encrypted.metadata,
&wrong_key
)
.await
.is_err(),
"a different customer key must not open the object"
);
// Key length is validated on both sides before any crypto happens.
assert_invalid_key_size(
service
.encrypt_object_with_customer_key(BUCKET, "short.bin", data.as_slice(), &[0u8; 16], None)
.await,
32,
16,
);
assert_invalid_key_size(
discard(
service
.decrypt_object_with_customer_key(
BUCKET,
"customer.bin",
encrypted.ciphertext.clone(),
&encrypted.metadata,
&[0u8; 31],
)
.await,
),
32,
31,
);
}
#[tokio::test]
async fn sse_c_validates_the_supplied_key_md5() {
let (_kms, service) = service_with_key("sse-c-md5-unused").await;
let customer_key = [0x33u8; 32];
let correct_md5 = hex::encode(md5_of(&customer_key));
service
.encrypt_object_with_customer_key(BUCKET, "md5-ok.bin", payload(64).as_slice(), &customer_key, Some(&correct_md5))
.await
.expect("a matching MD5 must be accepted");
// Uppercase is accepted: the comparison is case-insensitive on the input.
service
.encrypt_object_with_customer_key(
BUCKET,
"md5-upper.bin",
payload(64).as_slice(),
&customer_key,
Some(&correct_md5.to_uppercase()),
)
.await
.expect("MD5 comparison must be case-insensitive");
match service
.encrypt_object_with_customer_key(
BUCKET,
"md5-bad.bin",
payload(64).as_slice(),
&customer_key,
Some("00000000000000000000000000000000"),
)
.await
{
Err(KmsError::ValidationError { message }) => {
assert!(message.contains("MD5"), "the error must name the MD5 check: {message}")
}
other => panic!("expected ValidationError for an MD5 mismatch, got {other:?}"),
}
}
#[tokio::test]
async fn sse_c_and_kms_objects_do_not_cross_paths() {
let (_kms, service) = service_with_key("sse-c-crossover").await;
let customer_key = [0x44u8; 32];
let kms_object = service
.encrypt_object(BUCKET, "kms.bin", payload(256).as_slice(), &EncryptionAlgorithm::Aes256, None, None)
.await
.expect("KMS encrypt");
let sse_c_object = service
.encrypt_object_with_customer_key(BUCKET, "customer.bin", payload(256).as_slice(), &customer_key, None)
.await
.expect("SSE-C encrypt");
// A KMS-encrypted object must not be openable through the SSE-C path, even
// with a valid-looking key: the metadata marker is what routes the GET.
assert_invalid_operation(
discard(
service
.decrypt_object_with_customer_key(
BUCKET,
"kms.bin",
kms_object.ciphertext.clone(),
&kms_object.metadata,
&customer_key,
)
.await,
),
"not encrypted with SSE-C",
);
// And an SSE-C object has no wrapped data key for the KMS path to unwrap.
assert!(
service
.decrypt_object(BUCKET, "customer.bin", sse_c_object.ciphertext.clone(), &sse_c_object.metadata, None)
.await
.is_err(),
"the KMS path must not be able to open an SSE-C object"
);
// The SSE-C header projection advertises the customer algorithm, which is
// how `headers_to_metadata` re-identifies the object on the way back.
let headers = service.metadata_to_headers(&sse_c_object.metadata);
assert_eq!(
headers
.get("x-amz-server-side-encryption-customer-algorithm")
.map(String::as_str),
Some("AES256"),
"SSE-C objects must advertise the customer algorithm header"
);
assert!(!headers.contains_key("x-rustfs-encryption-key-id"), "SSE-C must not claim a KMS key id");
let rebuilt = service.headers_to_metadata(&headers).expect("SSE-C headers must parse");
assert_eq!(rebuilt.key_id, "sse-c", "the SSE-C marker must survive the header projection");
assert!(
rebuilt.encrypted_data_key.is_empty(),
"no wrapped key may materialise out of SSE-C headers"
);
assert_eq!(
rebuilt.encryption_context, sse_c_object.metadata.encryption_context,
"the SSE-C context must survive the projection"
);
let decrypted = read_all(
service
.decrypt_object_with_customer_key(BUCKET, "customer.bin", sse_c_object.ciphertext.clone(), &rebuilt, &customer_key)
.await
.expect("SSE-C must decrypt from a record rebuilt out of its own headers"),
)
.await;
assert_eq!(decrypted.len(), 256, "the SSE-C header round-trip must preserve the object");
}
fn md5_of(bytes: &[u8]) -> Vec<u8> {
use md5::Digest as _;
let mut hasher = md5::Md5::new();
hasher.update(bytes);
hasher.finalize().to_vec()
}
/// Serialize a context in reverse-sorted key order.
///
/// Deliberately not the canonical ordering, and derived from the real context
/// rather than hand-written: the service adds its own bucket-path entry, so a
/// literal would silently describe a different map and prove nothing.
fn non_canonical_context_json(context: &HashMap<String, String>) -> String {
let mut entries: Vec<_> = context.iter().collect();
entries.sort_by(|left, right| right.0.cmp(left.0));
let body = entries
.iter()
.map(|(key, value)| {
format!(
"{}:{}",
serde_json::to_string(key).expect("key serializes"),
serde_json::to_string(value).expect("value serializes")
)
})
.collect::<Vec<_>>()
.join(",");
format!("{{{body}}}")
}
/// An object sealed before the context was canonicalized must still open.
///
/// The AAD is the *serialization* of the encryption context, not the map. Any
/// object written while the context was serialized straight from a `HashMap`
/// carries whatever order that map happened to iterate in, and
/// `x-rustfs-encryption-context` is where that exact byte sequence survives.
/// Canonicalizing on the way back in would recompute sorted AAD, fail the AEAD,
/// and make a readable object permanently unreadable — so the stored bytes have
/// to win over anything re-derived from the parsed map.
///
/// The legacy object is reconstructed here the only way a black-box test can:
/// by rewriting the projected header into a non-sorted ordering *and* pinning
/// the sealed bytes to that same ordering, which is exactly the on-disk state a
/// pre-upgrade write left behind.
#[tokio::test]
async fn an_object_sealed_under_a_non_canonical_context_still_opens() {
let (_kms, service) = service_with_key("sse-legacy-aad").await;
let object_key = "legacy-context.bin";
let data = payload(512);
// Several entries, so an ordering difference is observable at all.
let context = ctx(&[("zeta", "26"), ("alpha", "1"), ("mu", "13")]);
let encrypted = service
.encrypt_object(BUCKET, object_key, data.as_slice(), &EncryptionAlgorithm::Aes256, None, Some(&context))
.await
.expect("encrypting with a multi-entry context should succeed");
let headers = service.metadata_to_headers(&encrypted.metadata);
let stored_context = headers
.get("x-rustfs-encryption-context")
.expect("the context must be projected into a header");
// What the projection stores must be the bytes the object was sealed
// under, or the two can drift apart without anything failing yet.
let rebuilt = service.headers_to_metadata(&headers).expect("headers must parse back");
assert_eq!(
rebuilt.context_aad.as_deref(),
Some(stored_context.as_bytes()),
"the rebuilt record must carry the stored context bytes verbatim"
);
let reopened = read_all(
service
.decrypt_object(BUCKET, object_key, encrypted.ciphertext.clone(), &rebuilt, None)
.await
.expect("an object must open from its own projected headers"),
)
.await;
assert_eq!(reopened, data);
// Now the legacy shape: same pairs, different serialization order. An
// object written before canonicalization has exactly this on disk.
let legacy_json = non_canonical_context_json(&encrypted.metadata.encryption_context);
let legacy_json = legacy_json.as_str();
assert_ne!(
legacy_json, stored_context,
"the legacy ordering must actually differ from the canonical one, or this proves nothing"
);
let legacy_context: HashMap<String, String> = serde_json::from_str(legacy_json).expect("legacy context parses");
assert_eq!(legacy_context, encrypted.metadata.encryption_context, "same pairs, different order");
let legacy_metadata = EncryptionMetadata {
context_aad: Some(legacy_json.as_bytes().to_vec()),
encryption_context: legacy_context,
..encrypted.metadata.clone()
};
// Re-projecting a legacy record must not rewrite it into sorted form: that
// would destroy the only copy of the ordering the object needs.
let legacy_headers = service.metadata_to_headers(&legacy_metadata);
assert_eq!(
legacy_headers.get("x-rustfs-encryption-context").map(String::as_str),
Some(legacy_json),
"a re-projection must preserve the original context ordering byte-for-byte"
);
assert_eq!(
service
.headers_to_metadata(&legacy_headers)
.expect("legacy headers must parse")
.context_aad
.as_deref(),
Some(legacy_json.as_bytes()),
"the legacy ordering must survive a full header round trip"
);
}
/// Rewriting the stored context is a tamper, not a legacy read.
///
/// The flip side of honouring the stored bytes: they are authenticated, so
/// changing them — even to a reordering that parses to an identical map — must
/// fail rather than silently re-deriving a working AAD.
#[tokio::test]
async fn a_rewritten_context_header_fails_authentication() {
let (_kms, service) = service_with_key("sse-tampered-context").await;
let object_key = "tampered-context.bin";
let data = payload(256);
let context = ctx(&[("zeta", "26"), ("alpha", "1"), ("mu", "13")]);
let encrypted = service
.encrypt_object(BUCKET, object_key, data.as_slice(), &EncryptionAlgorithm::Aes256, None, Some(&context))
.await
.expect("encrypt should succeed");
let mut headers = service.metadata_to_headers(&encrypted.metadata);
// Same pairs, different serialization: a pure ordering rewrite, so the
// rejection can only come from the AAD bytes and not from a changed map.
headers.insert(
"x-rustfs-encryption-context".to_string(),
non_canonical_context_json(&encrypted.metadata.encryption_context),
);
let tampered = service.headers_to_metadata(&headers).expect("tampered headers still parse");
assert!(
discard(
service
.decrypt_object(BUCKET, object_key, encrypted.ciphertext.clone(), &tampered, None)
.await
)
.is_err(),
"a context the object was not sealed under must not open it"
);
}