mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-09 14:49:25 +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.
570 lines
23 KiB
Rust
570 lines
23 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: master-key crypto and data-encryption-key semantics.
|
|
//!
|
|
//! Three invariants carry most of the weight here:
|
|
//!
|
|
//! 1. **Every DEK is fresh.** `lib.rs` forbids caching a generated data key by
|
|
//! master key id: a DEK and its ciphertext are bound to one object's
|
|
//! encryption context, so reuse would both break context validation and
|
|
//! violate the per-object DEK model SSE-S3 and SSE-KMS assume.
|
|
//! 2. **The encryption context is authenticated.** It is the AEAD's additional
|
|
//! data, so a wrong value must fail decryption rather than silently return
|
|
//! the wrong plaintext.
|
|
//! 3. **Corrupt input fails cleanly.** Tampered, truncated, or foreign
|
|
//! ciphertext returns a typed error and never panics — this input is
|
|
//! attacker-reachable through object metadata.
|
|
//!
|
|
//! One deliberate carve-out is pinned below: an *empty* request context skips
|
|
//! the "missing context key" check so legacy objects, written before contexts
|
|
//! were bound, stay readable. A *wrong* value is still rejected.
|
|
|
|
mod common;
|
|
|
|
use common::{BackendCase, TestKms, assert_context_mismatch, ctx, flip_middle_bit, for_each_backend, payload};
|
|
use rustfs_kms::{
|
|
DecryptRequest, EncryptRequest, GenerateDataKeyRequest, KeySpec, KmsError, ObjectEncryptionContext, is_data_key_envelope,
|
|
};
|
|
|
|
fn context() -> std::collections::HashMap<String, String> {
|
|
ctx(&[("bucket", "crypto-behavior"), ("object", "alpha.bin")])
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn master_key_encrypt_decrypt_round_trips_under_the_same_context() {
|
|
for_each_backend(|case: BackendCase| async move {
|
|
let manager = case.kms.kms().await;
|
|
let label = case.kind().name();
|
|
let plaintext = payload(1024);
|
|
|
|
let encrypted = manager
|
|
.encrypt(EncryptRequest {
|
|
key_id: case.key_id.clone(),
|
|
plaintext: plaintext.clone(),
|
|
encryption_context: context(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] encrypt should succeed: {error:?}"));
|
|
|
|
assert!(!encrypted.ciphertext.is_empty(), "[{label}] ciphertext must not be empty");
|
|
assert_ne!(encrypted.ciphertext, plaintext, "[{label}] ciphertext must not equal the plaintext");
|
|
assert_eq!(encrypted.key_id, case.key_id, "[{label}] the response names the key used");
|
|
assert!(!encrypted.algorithm.is_empty(), "[{label}] the algorithm must be reported");
|
|
|
|
let decrypted = manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: encrypted.ciphertext.clone(),
|
|
encryption_context: context(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] decrypt should succeed: {error:?}"));
|
|
assert_eq!(decrypted.plaintext, plaintext, "[{label}] round-trip must return the input");
|
|
assert_eq!(decrypted.key_id, case.key_id, "[{label}] decrypt reports the key it used");
|
|
|
|
// Encrypting the same plaintext twice must not produce the same
|
|
// ciphertext: a fresh nonce per call is what keeps AES-GCM safe.
|
|
let again = manager
|
|
.encrypt(EncryptRequest {
|
|
key_id: case.key_id.clone(),
|
|
plaintext: plaintext.clone(),
|
|
encryption_context: context(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] second encrypt should succeed: {error:?}"));
|
|
assert_ne!(
|
|
again.ciphertext, encrypted.ciphertext,
|
|
"[{label}] repeated encryption of identical plaintext must not be deterministic"
|
|
);
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_plaintext_round_trips() {
|
|
for_each_backend(|case: BackendCase| async move {
|
|
let manager = case.kms.kms().await;
|
|
let label = case.kind().name();
|
|
|
|
let encrypted = manager
|
|
.encrypt(EncryptRequest {
|
|
key_id: case.key_id.clone(),
|
|
plaintext: Vec::new(),
|
|
encryption_context: context(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] encrypting nothing should still succeed: {error:?}"));
|
|
|
|
let decrypted = manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: encrypted.ciphertext,
|
|
encryption_context: context(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] decrypt should succeed: {error:?}"));
|
|
assert!(decrypted.plaintext.is_empty(), "[{label}] empty in, empty out");
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn encryption_context_is_authenticated() {
|
|
for_each_backend(|case: BackendCase| async move {
|
|
let manager = case.kms.kms().await;
|
|
let label = case.kind().name();
|
|
|
|
let dek = manager
|
|
.generate_data_key(GenerateDataKeyRequest {
|
|
key_id: case.key_id.clone(),
|
|
key_spec: KeySpec::Aes256,
|
|
encryption_context: context(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] generate should succeed: {error:?}"));
|
|
|
|
// A changed value for a bound key is a mismatch.
|
|
assert_context_mismatch(
|
|
manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: dek.ciphertext_blob.clone(),
|
|
encryption_context: ctx(&[("bucket", "crypto-behavior"), ("object", "other.bin")]),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await,
|
|
);
|
|
|
|
// A non-empty context that omits a bound key is also a mismatch.
|
|
assert_context_mismatch(
|
|
manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: dek.ciphertext_blob.clone(),
|
|
encryption_context: ctx(&[("bucket", "crypto-behavior")]),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await,
|
|
);
|
|
|
|
// Extra keys beyond the bound set are tolerated: only the bound pairs
|
|
// are authenticated, so adding context cannot lock an object out.
|
|
let with_extra = manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: dek.ciphertext_blob.clone(),
|
|
encryption_context: ctx(&[("bucket", "crypto-behavior"), ("object", "alpha.bin"), ("unrelated", "value")]),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] a superset context should decrypt: {error:?}"));
|
|
assert_eq!(with_extra.plaintext, dek.plaintext_key);
|
|
|
|
// The documented legacy carve-out: a fully empty request context skips
|
|
// the missing-key check so pre-context objects stay readable.
|
|
let legacy = manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: dek.ciphertext_blob.clone(),
|
|
encryption_context: Default::default(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] the empty-context legacy path must work: {error:?}"));
|
|
assert_eq!(
|
|
legacy.plaintext, dek.plaintext_key,
|
|
"[{label}] the legacy path must return the same data key"
|
|
);
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn every_generated_data_key_is_fresh() {
|
|
for_each_backend(|case: BackendCase| async move {
|
|
let manager = case.kms.kms().await;
|
|
let label = case.kind().name();
|
|
|
|
// Same key id, same context, repeated: nothing may be reused.
|
|
let mut plaintexts = Vec::new();
|
|
let mut ciphertexts = Vec::new();
|
|
for _ in 0..8 {
|
|
let dek = manager
|
|
.generate_data_key(GenerateDataKeyRequest {
|
|
key_id: case.key_id.clone(),
|
|
key_spec: KeySpec::Aes256,
|
|
encryption_context: context(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] generate should succeed: {error:?}"));
|
|
|
|
assert_eq!(dek.plaintext_key.len(), 32, "[{label}] an AES-256 DEK is 32 bytes");
|
|
assert!(!dek.ciphertext_blob.is_empty(), "[{label}] the wrapped DEK must not be empty");
|
|
assert!(
|
|
!dek.ciphertext_blob
|
|
.windows(dek.plaintext_key.len())
|
|
.any(|window| window == dek.plaintext_key),
|
|
"[{label}] the wrapped blob must never contain the plaintext data key"
|
|
);
|
|
plaintexts.push(dek.plaintext_key);
|
|
ciphertexts.push(dek.ciphertext_blob);
|
|
}
|
|
|
|
for i in 0..plaintexts.len() {
|
|
for j in (i + 1)..plaintexts.len() {
|
|
assert_ne!(
|
|
plaintexts[i], plaintexts[j],
|
|
"[{label}] data keys must not repeat across calls (indices {i} and {j})"
|
|
);
|
|
assert_ne!(
|
|
ciphertexts[i], ciphertexts[j],
|
|
"[{label}] wrapped data keys must not repeat across calls (indices {i} and {j})"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Each wrapped blob still opens to exactly its own plaintext.
|
|
for (index, (expected, blob)) in plaintexts.iter().zip(ciphertexts.iter()).enumerate() {
|
|
let decrypted = manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: blob.clone(),
|
|
encryption_context: context(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] blob {index} should decrypt: {error:?}"));
|
|
assert_eq!(&decrypted.plaintext, expected, "[{label}] blob {index} opened to the wrong key");
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn data_key_spec_controls_the_length_of_the_generated_key() {
|
|
for_each_backend(|case: BackendCase| async move {
|
|
let manager = case.kms.kms().await;
|
|
let label = case.kind().name();
|
|
|
|
// A backend that accepts a `key_spec` must honour it. Silently
|
|
// returning a different size means the caller builds a cipher from
|
|
// material it did not ask for, and the envelope records a spec its
|
|
// payload does not match. Every backend in this matrix mints DEKs via
|
|
// the shared `generate_key_material`, so all three specs must be
|
|
// honoured; tolerating a refusal would accept a backend regressing
|
|
// out of that shared mapping.
|
|
for spec in [KeySpec::Aes256, KeySpec::Aes128, KeySpec::ChaCha20] {
|
|
let generated = manager
|
|
.generate_data_key(GenerateDataKeyRequest {
|
|
key_id: case.key_id.clone(),
|
|
key_spec: spec.clone(),
|
|
encryption_context: context(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] {spec:?} must yield a data key: {error:?}"));
|
|
assert_eq!(
|
|
generated.plaintext_key.len(),
|
|
spec.key_size(),
|
|
"[{label}] {spec:?} must yield a {}-byte data key",
|
|
spec.key_size()
|
|
);
|
|
assert_eq!(
|
|
generated.key_id, case.key_id,
|
|
"[{label}] {spec:?} must name the master key that wrapped the DEK"
|
|
);
|
|
|
|
// The envelope must record the spec it was minted under, or a
|
|
// reader can no longer tell what the wrapped material is.
|
|
let envelope: serde_json::Value =
|
|
serde_json::from_slice(&generated.ciphertext_blob).expect("ciphertext must be a KMS envelope");
|
|
assert_eq!(
|
|
envelope.get("key_spec").and_then(|value| value.as_str()),
|
|
Some(spec.as_str()),
|
|
"[{label}] the envelope must record the requested spec"
|
|
);
|
|
|
|
// Whatever the length, the blob still round-trips.
|
|
let decrypted = manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: generated.ciphertext_blob,
|
|
encryption_context: context(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] {spec:?} blob should decrypt: {error:?}"));
|
|
assert_eq!(decrypted.plaintext, generated.plaintext_key);
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn corrupt_ciphertext_fails_cleanly() {
|
|
for_each_backend(|case: BackendCase| async move {
|
|
let manager = case.kms.kms().await;
|
|
let label = case.kind().name();
|
|
|
|
let dek = manager
|
|
.generate_data_key(GenerateDataKeyRequest {
|
|
key_id: case.key_id.clone(),
|
|
key_spec: KeySpec::Aes256,
|
|
encryption_context: context(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] generate should succeed: {error:?}"));
|
|
|
|
let decrypt = |ciphertext: Vec<u8>| {
|
|
let manager = manager.clone();
|
|
async move {
|
|
manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext,
|
|
encryption_context: context(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
}
|
|
};
|
|
|
|
// A single flipped bit anywhere in the envelope must not decrypt.
|
|
let tampered = flip_middle_bit(&dek.ciphertext_blob);
|
|
assert!(decrypt(tampered).await.is_err(), "[{label}] a bit-flipped envelope must not decrypt");
|
|
|
|
// Truncation, emptiness, and non-envelope bytes are unparseable
|
|
// ciphertext, and every backend reports that as the same error class.
|
|
for (name, input) in [
|
|
("truncated", dek.ciphertext_blob[..dek.ciphertext_blob.len() / 2].to_vec()),
|
|
("empty", Vec::new()),
|
|
("not-json", b"absolutely not an envelope".to_vec()),
|
|
("json-but-wrong-shape", br#"{"hello":"world"}"#.to_vec()),
|
|
] {
|
|
let error = decrypt(input)
|
|
.await
|
|
.expect_err(&format!("[{label}] {name} input must be rejected"));
|
|
assert!(
|
|
matches!(error, KmsError::CryptographicError { .. }),
|
|
"[{label}] {name} input must be rejected as unparseable ciphertext, got: {error:?}"
|
|
);
|
|
}
|
|
|
|
// Truncating only the AEAD tail (keeping the envelope parseable) must
|
|
// fail authentication rather than return partial plaintext.
|
|
let mut short_envelope = dek.ciphertext_blob.clone();
|
|
short_envelope.pop();
|
|
assert!(decrypt(short_envelope).await.is_err(), "[{label}] a truncated envelope must not decrypt");
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_data_key_is_not_transferable_between_master_keys() {
|
|
// Two independent master keys on the same backend: a blob wrapped by one
|
|
// must not open under the other, even with an identical context.
|
|
let kms = TestKms::local_with(|config| config.allow_immediate_deletion = true).await;
|
|
let manager = kms.kms().await;
|
|
kms.create_key("wrapper-a").await;
|
|
kms.create_key("wrapper-b").await;
|
|
|
|
let from_a = manager
|
|
.generate_data_key(GenerateDataKeyRequest {
|
|
key_id: "wrapper-a".to_string(),
|
|
key_spec: KeySpec::Aes256,
|
|
encryption_context: context(),
|
|
})
|
|
.await
|
|
.expect("generate under wrapper-a");
|
|
let from_b = manager
|
|
.generate_data_key(GenerateDataKeyRequest {
|
|
key_id: "wrapper-b".to_string(),
|
|
key_spec: KeySpec::Aes256,
|
|
encryption_context: context(),
|
|
})
|
|
.await
|
|
.expect("generate under wrapper-b");
|
|
|
|
assert_ne!(
|
|
from_a.plaintext_key, from_b.plaintext_key,
|
|
"different master keys must produce different data keys"
|
|
);
|
|
|
|
// The envelope names its own master key, so each opens under its own.
|
|
for (label, dek) in [("wrapper-a", &from_a), ("wrapper-b", &from_b)] {
|
|
let decrypted = manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: dek.ciphertext_blob.clone(),
|
|
encryption_context: context(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("{label} blob should decrypt under its own key: {error:?}"));
|
|
assert_eq!(&decrypted.plaintext, &dek.plaintext_key);
|
|
}
|
|
|
|
// Deleting wrapper-a makes its blobs undecryptable while wrapper-b's keep
|
|
// working — the blobs are genuinely bound to distinct material.
|
|
manager
|
|
.delete_key(rustfs_kms::DeleteKeyRequest {
|
|
key_id: "wrapper-a".to_string(),
|
|
pending_window_in_days: None,
|
|
force_immediate: Some(true),
|
|
confirm_key_id: Some("wrapper-a".to_string()),
|
|
})
|
|
.await
|
|
.expect("forced deletion should succeed");
|
|
|
|
assert!(
|
|
manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: from_a.ciphertext_blob.clone(),
|
|
encryption_context: context(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.is_err(),
|
|
"a blob wrapped by a destroyed key must not decrypt"
|
|
);
|
|
manager
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: from_b.ciphertext_blob.clone(),
|
|
encryption_context: context(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.expect("an unrelated key's blobs must be unaffected by the deletion");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn data_key_envelope_detection_matches_produced_blobs() {
|
|
for_each_backend(|case: BackendCase| async move {
|
|
let manager = case.kms.kms().await;
|
|
let label = case.kind().name();
|
|
|
|
let dek = manager
|
|
.generate_data_key(GenerateDataKeyRequest {
|
|
key_id: case.key_id.clone(),
|
|
key_spec: KeySpec::Aes256,
|
|
encryption_context: context(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("[{label}] generate should succeed: {error:?}"));
|
|
|
|
assert!(
|
|
is_data_key_envelope(&dek.ciphertext_blob),
|
|
"[{label}] a freshly wrapped data key must be recognised as an envelope"
|
|
);
|
|
|
|
for (name, input) in [
|
|
("empty", Vec::new()),
|
|
("raw bytes", vec![0x00, 0x01, 0x02]),
|
|
("plain text", b"not an envelope".to_vec()),
|
|
("unrelated json", br#"{"unrelated":true}"#.to_vec()),
|
|
("json array", b"[]".to_vec()),
|
|
] {
|
|
assert!(
|
|
!is_data_key_envelope(&input),
|
|
"[{label}] {name} must not be mistaken for a data key envelope"
|
|
);
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn object_data_keys_are_bound_to_their_object() {
|
|
// `ObjectEncryptionService::create_data_key` derives the encryption context
|
|
// from bucket + object key, so a DEK minted for one object must not open
|
|
// under another object's context.
|
|
let kms = TestKms::local().await;
|
|
let service = kms.service().await;
|
|
kms.create_key("object-binding").await;
|
|
let key_id = Some("object-binding".to_string());
|
|
|
|
let alpha = ObjectEncryptionContext::new("bucket-x".to_string(), "alpha.bin".to_string());
|
|
let beta = ObjectEncryptionContext::new("bucket-x".to_string(), "beta.bin".to_string());
|
|
|
|
let (alpha_key, alpha_blob) = service
|
|
.create_data_key(&key_id, &alpha)
|
|
.await
|
|
.expect("create_data_key for alpha");
|
|
let (beta_key, beta_blob) = service
|
|
.create_data_key(&key_id, &beta)
|
|
.await
|
|
.expect("create_data_key for beta");
|
|
|
|
assert_ne!(alpha_key.plaintext_key, beta_key.plaintext_key, "each object gets its own data key");
|
|
assert_ne!(alpha_blob, beta_blob, "each object gets its own wrapped data key");
|
|
assert_ne!(alpha_key.nonce, beta_key.nonce, "each object gets its own base nonce for streaming");
|
|
|
|
let recovered = service
|
|
.decrypt_data_key(&alpha_blob, &alpha)
|
|
.await
|
|
.expect("alpha's blob must open under alpha's context");
|
|
assert_eq!(
|
|
recovered.plaintext_key, alpha_key.plaintext_key,
|
|
"the recovered data key must match the one handed out"
|
|
);
|
|
|
|
assert_context_mismatch(service.decrypt_data_key(&alpha_blob, &beta).await);
|
|
assert_context_mismatch(service.decrypt_data_key(&beta_blob, &alpha).await);
|
|
|
|
// A different bucket with the same object name is also a different object.
|
|
let other_bucket = ObjectEncryptionContext::new("bucket-y".to_string(), "alpha.bin".to_string());
|
|
assert_context_mismatch(service.decrypt_data_key(&alpha_blob, &other_bucket).await);
|
|
|
|
// The legacy path intentionally drops the context; it is the only way to
|
|
// read objects written before per-object binding existed.
|
|
let legacy = service
|
|
.decrypt_legacy_data_key(&alpha_blob)
|
|
.await
|
|
.expect("the legacy path must still open the blob");
|
|
assert_eq!(legacy.plaintext_key, alpha_key.plaintext_key);
|
|
assert_eq!(
|
|
legacy.nonce, [0u8; 12],
|
|
"the legacy path returns a zero nonce; callers substitute the stored one"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_data_key_requires_a_resolvable_key_id() {
|
|
let kms = TestKms::local().await;
|
|
let service = kms.service().await;
|
|
let context = ObjectEncryptionContext::new("bucket".to_string(), "object".to_string());
|
|
|
|
// No explicit id and no configured default: a configuration error, not a
|
|
// silent fallback to some arbitrary key.
|
|
match service.create_data_key(&None, &context).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:?}"),
|
|
}
|
|
|
|
// With a default configured, the same call resolves to it.
|
|
let kms = TestKms::local_with(|config| config.default_key_id = Some("default-key".to_string())).await;
|
|
let service = kms.service().await;
|
|
kms.create_key("default-key").await;
|
|
assert_eq!(
|
|
service.get_default_key_id().map(String::as_str),
|
|
Some("default-key"),
|
|
"the configured default must be visible to callers"
|
|
);
|
|
let (_key, blob) = service
|
|
.create_data_key(&None, &context)
|
|
.await
|
|
.expect("the default key must be used when none is given");
|
|
service
|
|
.decrypt_data_key(&blob, &context)
|
|
.await
|
|
.expect("the default key's blob must round-trip");
|
|
}
|