mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-08 22:33:22 +00:00
6633c80151
* refactor(kms): share the DEK spec mapping and stop re-parsing opened envelopes
- generate_key_material is now the single spec->length mapping for every
backend that mints DEKs itself; the inline copies in the Static and Local
backends are gone, and ChaCha20 (32 bytes, same as AES_256) is accepted
uniformly instead of only by Static.
- The pub(crate) client decrypt of the Local, Vault KV2 and Vault Transit
backends returns (plaintext, master_key_id), so KmsBackend::decrypt no
longer re-parses the envelope it just opened (one JSON parse per SSE GET
instead of two, and unknown-field observability is no longer double-counted).
- Malformed-envelope parse failures now report CryptographicError("parse")
on all backends; Local was the last one mapping them to SerializationError.
- The four KmsBackend::generate_data_key adapters take fields out of
DataKeyInfo instead of cloning, dropping a redundant un-zeroized plaintext
DEK copy and a full ciphertext clone per call; a missing plaintext now
fails closed everywhere instead of returning an empty key on three of four
backends.
* test(kms): pin legacy header fallback, stored-AAD, and decrypt key-id contracts
- a_legacy_aws_kms_object_without_the_cipher_header_still_opens rebuilds the
true pre-internal-header shape (aws:kms mode + S3 key-id header, no
x-rustfs-* headers) and asserts the fallback normalizes the cipher and
re-projects it.
- a_rewritten_sse_c_context_header_fails_authentication is the SSE-C flank of
the stored-AAD tamper check; metadata_without_stored_context_bytes_still_opens
covers the derived-AAD path for both flavours and pins the seal side to the
canonical bytes (mutation-verified).
- data_key_spec_controls_the_length_of_the_generated_key requires every
backend in the matrix to honour all three specs, asserts the envelope
records the requested spec, and round-trips each blob.
- corrupt_ciphertext_fails_cleanly pins unparseable ciphertext to
CryptographicError instead of merely not-InternalError.
- Deleted the never-called assert_validation_error / assert_cryptographic_error
helpers.
1106 lines
43 KiB
Rust
1106 lines
43 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"
|
|
);
|
|
}
|
|
|
|
/// The SSE-C flank of the tamper check above: the customer-key path prefers
|
|
/// the stored AAD bytes through the same branch, so a reverted preference —
|
|
/// re-deriving canonical bytes from the parsed map — would open a tampered
|
|
/// object here too, and only an SSE-C probe would notice.
|
|
#[tokio::test]
|
|
async fn a_rewritten_sse_c_context_header_fails_authentication() {
|
|
let (_kms, service) = service_with_key("sse-c-tampered-context").await;
|
|
let object_key = "tampered-sse-c.bin";
|
|
let customer_key = [0x55u8; 32];
|
|
let data = payload(256);
|
|
|
|
let encrypted = service
|
|
.encrypt_object_with_customer_key(BUCKET, object_key, data.as_slice(), &customer_key, None)
|
|
.await
|
|
.expect("SSE-C 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.
|
|
let rewritten = non_canonical_context_json(&encrypted.metadata.encryption_context);
|
|
assert_ne!(
|
|
Some(rewritten.as_str()),
|
|
headers.get("x-rustfs-encryption-context").map(String::as_str),
|
|
"the rewrite must actually change the stored bytes, or this proves nothing"
|
|
);
|
|
headers.insert("x-rustfs-encryption-context".to_string(), rewritten);
|
|
|
|
let tampered = service.headers_to_metadata(&headers).expect("tampered headers still parse");
|
|
assert!(
|
|
discard(
|
|
service
|
|
.decrypt_object_with_customer_key(BUCKET, object_key, encrypted.ciphertext.clone(), &tampered, &customer_key)
|
|
.await
|
|
)
|
|
.is_err(),
|
|
"a context the SSE-C object was not sealed under must not open it, even with the right key"
|
|
);
|
|
}
|
|
|
|
/// An SSE-KMS object written before the internal `x-rustfs-` headers existed
|
|
/// must still open, and must rebuild into a record that names its real cipher.
|
|
///
|
|
/// Back then `x-amz-server-side-encryption: aws:kms` plus the S3 key-id header
|
|
/// was the whole record, and AES-256-GCM was the only cipher in use — which is
|
|
/// exactly the assumption the `aws:kms` fallback in `headers_to_metadata`
|
|
/// encodes. The fallback is a normalization: `aws:kms` also parses as a cipher
|
|
/// alias for AES-256-GCM, so the object opens either way, but only the
|
|
/// normalized record re-projects the cipher header a modern read expects. The
|
|
/// legacy header shape is reconstructed here by rewriting the SSE mode to
|
|
/// `aws:kms`, adding the S3 key-id header, and dropping both internal headers.
|
|
#[tokio::test]
|
|
async fn a_legacy_aws_kms_object_without_the_cipher_header_still_opens() {
|
|
let (_kms, service) = service_with_key("sse-legacy-mode").await;
|
|
let object_key = "legacy-aws-kms.bin";
|
|
let data = payload(512);
|
|
|
|
let encrypted = service
|
|
.encrypt_object(BUCKET, object_key, data.as_slice(), &EncryptionAlgorithm::Aes256, None, None)
|
|
.await
|
|
.expect("encrypt should succeed");
|
|
|
|
let mut headers = service.metadata_to_headers(&encrypted.metadata);
|
|
headers.insert("x-amz-server-side-encryption".to_string(), "aws:kms".to_string());
|
|
headers.insert(
|
|
"x-amz-server-side-encryption-aws-kms-key-id".to_string(),
|
|
encrypted.metadata.key_id.clone(),
|
|
);
|
|
for internal in ["x-rustfs-encryption-algorithm", "x-rustfs-encryption-key-id"] {
|
|
headers
|
|
.remove(internal)
|
|
.unwrap_or_else(|| panic!("the modern projection must write the {internal} header this test deletes"));
|
|
}
|
|
|
|
let rebuilt = service
|
|
.headers_to_metadata(&headers)
|
|
.expect("a pre-internal-header record must still parse");
|
|
assert_eq!(rebuilt.key_id, encrypted.metadata.key_id, "the S3 key-id header must resolve the key");
|
|
assert_eq!(
|
|
rebuilt.algorithm,
|
|
EncryptionAlgorithm::Aes256.as_str(),
|
|
"aws:kms with no cipher header must normalize to the only cipher that era wrote"
|
|
);
|
|
|
|
// The normalization is what a re-projection stores: the upgraded record
|
|
// writes the modern cipher header instead of perpetuating the gap.
|
|
let reprojected = service.metadata_to_headers(&rebuilt);
|
|
assert_eq!(
|
|
reprojected.get("x-rustfs-encryption-algorithm").map(String::as_str),
|
|
Some(EncryptionAlgorithm::Aes256.as_str()),
|
|
"re-projecting the rebuilt record must write the cipher header"
|
|
);
|
|
|
|
let decrypted = read_all(
|
|
service
|
|
.decrypt_object(BUCKET, object_key, encrypted.ciphertext.clone(), &rebuilt, None)
|
|
.await
|
|
.expect("a legacy aws:kms object must still open"),
|
|
)
|
|
.await;
|
|
assert_eq!(decrypted, data, "the rebuilt record must recover the full plaintext");
|
|
}
|
|
|
|
/// Metadata persisted before `context_aad` existed deserializes with `None`
|
|
/// there, and decrypt must then re-derive the AAD from the parsed context.
|
|
/// That derived path only opens the object because the seal side canonicalizes
|
|
/// the very same way — this is the independent probe of that pairing, for both
|
|
/// the KMS and the customer-key flavours.
|
|
#[tokio::test]
|
|
async fn metadata_without_stored_context_bytes_still_opens() {
|
|
let (_kms, service) = service_with_key("sse-derived-aad").await;
|
|
let data = payload(512);
|
|
// Several entries, so canonicalization has an ordering to actually decide.
|
|
let context = ctx(&[("zeta", "26"), ("alpha", "1"), ("mu", "13")]);
|
|
|
|
// Byte-equality of the derived and stored AAD is what keeps the `None`
|
|
// path working, so pin the seal side of that pairing directly: the sealed
|
|
// record must carry exactly the canonical serialization of its context.
|
|
let canonical_aad = |context: &HashMap<String, String>| {
|
|
let canonical: std::collections::BTreeMap<&str, &str> =
|
|
context.iter().map(|(key, value)| (key.as_str(), value.as_str())).collect();
|
|
serde_json::to_vec(&canonical).expect("context serializes")
|
|
};
|
|
|
|
let encrypted = service
|
|
.encrypt_object(
|
|
BUCKET,
|
|
"derived-aad.bin",
|
|
data.as_slice(),
|
|
&EncryptionAlgorithm::Aes256,
|
|
None,
|
|
Some(&context),
|
|
)
|
|
.await
|
|
.expect("encrypt should succeed");
|
|
assert_eq!(
|
|
encrypted.metadata.context_aad.as_deref(),
|
|
Some(canonical_aad(&encrypted.metadata.encryption_context).as_slice()),
|
|
"the seal must pin the exact canonical AAD bytes it fed the AEAD"
|
|
);
|
|
let stripped = EncryptionMetadata {
|
|
context_aad: None,
|
|
..encrypted.metadata.clone()
|
|
};
|
|
let decrypted = read_all(
|
|
service
|
|
.decrypt_object(BUCKET, "derived-aad.bin", encrypted.ciphertext.clone(), &stripped, None)
|
|
.await
|
|
.expect("metadata with no stored AAD bytes must open through the derived path"),
|
|
)
|
|
.await;
|
|
assert_eq!(decrypted, data, "the derived AAD must match the bytes the object was sealed under");
|
|
|
|
// The SSE-C record carries the same optional field through the same serde
|
|
// default, so its derived path needs its own proof.
|
|
let customer_key = [0x66u8; 32];
|
|
let sse_c = service
|
|
.encrypt_object_with_customer_key(BUCKET, "derived-aad-c.bin", data.as_slice(), &customer_key, None)
|
|
.await
|
|
.expect("SSE-C encrypt should succeed");
|
|
assert_eq!(
|
|
sse_c.metadata.context_aad.as_deref(),
|
|
Some(canonical_aad(&sse_c.metadata.encryption_context).as_slice()),
|
|
"the SSE-C seal must pin the exact canonical AAD bytes it fed the AEAD"
|
|
);
|
|
let stripped = EncryptionMetadata {
|
|
context_aad: None,
|
|
..sse_c.metadata.clone()
|
|
};
|
|
let decrypted = read_all(
|
|
service
|
|
.decrypt_object_with_customer_key(BUCKET, "derived-aad-c.bin", sse_c.ciphertext.clone(), &stripped, &customer_key)
|
|
.await
|
|
.expect("SSE-C metadata with no stored AAD bytes must open through the derived path"),
|
|
)
|
|
.await;
|
|
assert_eq!(decrypted, data, "the SSE-C derived AAD must match the bytes the object was sealed under");
|
|
}
|