refactor(kms): close the low-severity follow-ups from the #5668 adversarial re-review (#5817)

* 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.
This commit is contained in:
唐小鸭
2026-08-08 05:41:50 +08:00
committed by GitHub
parent a0a8eaa0f3
commit 6633c80151
9 changed files with 315 additions and 134 deletions
@@ -183,6 +183,7 @@ async fn assert_state_machine_contract(backend: &dyn KmsBackend, key_id: &str) {
.await
.expect("decrypt with a disabled key must keep working");
assert_eq!(decrypted.plaintext, data_key.plaintext_key, "decrypt must recover the original data key");
assert_eq!(decrypted.key_id, key_id, "decrypt must report the master key that opened the envelope");
// ...disable stays idempotent, cancel has nothing to cancel, and enable recovers.
backend.disable_key(key_id).await.expect("disable must be idempotent");
expect_invalid_key_state(backend.cancel_key_deletion(cancel_request(key_id)).await, "not pending deletion");
+29 -25
View File
@@ -1529,14 +1529,7 @@ impl LocalKmsClient {
ensure_key_status_permits(&request.master_key_id, &key_info.status, StateGatedOperation::GenerateDataKey)?;
// Generate random data key material
let key_length = match request.key_spec.as_str() {
"AES_256" => 32,
"AES_128" => 16,
_ => return Err(KmsError::unsupported_algorithm(&request.key_spec)),
};
let mut plaintext_key = vec![0u8; key_length];
rand::rng().fill(&mut plaintext_key[..]);
let plaintext_key = generate_key_material(&request.key_spec)?;
// Encrypt the data key with the master key
let (encrypted_key, nonce) = self.encrypt_with_master_key(&request.master_key_id, &plaintext_key).await?;
@@ -1596,11 +1589,19 @@ impl LocalKmsClient {
})
}
pub(crate) async fn decrypt(&self, request: &DecryptRequest, _context: Option<&OperationContext>) -> Result<Vec<u8>> {
/// Open a data-key envelope, returning the plaintext and the master key
/// that wrapped it.
pub(crate) async fn decrypt(
&self,
request: &DecryptRequest,
_context: Option<&OperationContext>,
) -> Result<(Vec<u8>, String)> {
debug!("Decrypting data");
// Parse the data key envelope from ciphertext
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)?;
// Parse the data key envelope from ciphertext. Mapped to the same
// error class the other backends report for unparseable ciphertext.
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)
.map_err(|error| KmsError::cryptographic_error("parse", format!("Failed to parse data key envelope: {error}")))?;
// NOTE: this comparison is an authorization check, not a cryptographic
// binding. `DekCrypto` seals only the plaintext, so `encryption_context`
@@ -1634,7 +1635,7 @@ impl LocalKmsClient {
.await?;
debug!("Local KMS data decrypted");
Ok(plaintext)
Ok((plaintext, envelope.master_key_id))
}
/// Test-only lifecycle driver: the product path goes through [`KmsBackend`].
@@ -1994,16 +1995,11 @@ impl KmsBackend for LocalKmsBackend {
}
async fn decrypt(&self, request: DecryptRequest) -> Result<DecryptResponse> {
let plaintext = self.client.decrypt(&request, None).await?;
// The envelope that was just opened names the master key that opened it.
// Reporting "unknown" left every caller unable to tell which key was
// actually used, which is what audit and key-rotation checks read.
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)?;
let (plaintext, key_id) = self.client.decrypt(&request, None).await?;
Ok(DecryptResponse {
plaintext,
key_id: envelope.master_key_id,
key_id,
encryption_algorithm: Some("AES-256-GCM".to_string()),
})
}
@@ -2017,12 +2013,19 @@ impl KmsBackend for LocalKmsBackend {
grant_tokens: Vec::new(),
};
let data_key = self.client.generate_data_key(&generate_request, None).await?;
let mut data_key = self.client.generate_data_key(&generate_request, None).await?;
// Fields are taken, not destructured or cloned: `DataKeyInfo` has a
// `Drop` impl, and a clone would leave a second un-zeroized plaintext
// DEK on the heap.
let plaintext_key = data_key
.plaintext
.take()
.ok_or_else(|| KmsError::internal_error("Generated data key is missing plaintext"))?;
Ok(GenerateDataKeyResponse {
key_id: request.key_id,
plaintext_key: data_key.plaintext.clone().unwrap_or_default(),
ciphertext_blob: data_key.ciphertext.clone(),
plaintext_key,
ciphertext_blob: std::mem::take(&mut data_key.ciphertext),
})
}
@@ -2423,8 +2426,9 @@ mod tests {
let decrypt_request =
DecryptRequest::new(data_key.ciphertext.clone()).with_context("bucket".to_string(), "test-bucket".to_string());
let decrypted = client.decrypt(&decrypt_request, None).await.expect("Failed to decrypt");
let (decrypted, opened_by) = client.decrypt(&decrypt_request, None).await.expect("Failed to decrypt");
assert_eq!(decrypted, data_key.plaintext.clone().expect("No plaintext"));
assert_eq!(opened_by, key_id, "decrypt must report the master key that opened the envelope");
}
#[tokio::test]
@@ -2455,7 +2459,7 @@ mod tests {
// Pre-fix, each of those regenerated the master key, so this unwrap fails with an AEAD
// error. Post-fix, the original material is preserved and the DEK still decrypts.
let decrypt_request = DecryptRequest::new(ciphertext).with_context("bucket".to_string(), "b".to_string());
let decrypted = client
let (decrypted, _opened_by) = client
.decrypt(&decrypt_request, None)
.await
.expect("DEK must still decrypt after status transitions");
@@ -2796,7 +2800,7 @@ mod tests {
assert!(matches!(error, KmsError::InvalidOperation { .. }));
for (index, (ciphertext, plaintext)) in batch.iter().enumerate() {
let decrypted = client
let (decrypted, _opened_by) = client
.decrypt(&DecryptRequest::new(ciphertext.clone()), None)
.await
.unwrap_or_else(|error| panic!("batch member {index} must decrypt: {error}"));
+10 -16
View File
@@ -26,7 +26,7 @@
use crate::backends::{BackendCapabilities, KmsBackend, empty_key_page, list_keys_page_size};
use crate::config::{BackendConfig, KmsConfig};
use crate::encryption::{DataKeyEnvelope, context_aad};
use crate::encryption::{DataKeyEnvelope, context_aad, generate_key_material};
use crate::error::{KmsError, Result};
use crate::types::*;
use aes_gcm::{
@@ -124,16 +124,7 @@ impl StaticKmsBackend {
// The requested spec decides the DEK length; a caller that asked for
// AES_128 and silently got 256 bits would build objects whose recorded
// spec does not match their key material.
// Lengths track `KeySpec::key_size`; the request carries the spec as a
// string, so the mapping is repeated here rather than shared.
let key_length = match request.key_spec.as_str() {
"AES_256" | "ChaCha20" => 32,
"AES_128" => 16,
_ => return Err(KmsError::unsupported_algorithm(&request.key_spec)),
};
let mut plaintext = vec![0u8; key_length];
rand::rng().fill(&mut plaintext[..]);
let plaintext = generate_key_material(&request.key_spec)?;
// Encrypt DEK with AES-256-GCM using the static key directly
let key = Key::<Aes256Gcm>::from(*self.key);
@@ -167,7 +158,7 @@ impl StaticKmsBackend {
Ok(DataKeyInfo::new(
self.key_id.clone(),
0,
Some(plaintext.to_vec()),
Some(plaintext),
ciphertext,
request.key_spec.clone(),
))
@@ -360,17 +351,20 @@ impl KmsBackend for StaticKmsBackend {
encryption_context: request.encryption_context,
grant_tokens: Vec::new(),
};
let data_key = self.generate_data_key_envelope(&gen_req)?;
let mut data_key = self.generate_data_key_envelope(&gen_req)?;
// Fields are taken, not destructured or cloned: `DataKeyInfo` has a
// `Drop` impl, and a clone would leave a second un-zeroized plaintext
// DEK on the heap.
let plaintext_key = data_key
.plaintext
.clone()
.take()
.ok_or_else(|| KmsError::internal_error("Generated data key is missing plaintext"))?;
Ok(GenerateDataKeyResponse {
key_id: data_key.key_id.clone(),
key_id: std::mem::take(&mut data_key.key_id),
plaintext_key,
ciphertext_blob: data_key.ciphertext.clone(),
ciphertext_blob: std::mem::take(&mut data_key.ciphertext),
})
}
+27 -18
View File
@@ -875,7 +875,13 @@ impl VaultKmsClient {
})
}
pub(crate) async fn decrypt(&self, request: &DecryptRequest, _context: Option<&OperationContext>) -> Result<Vec<u8>> {
/// Open a data-key envelope, returning the plaintext and the master key
/// that wrapped it.
pub(crate) async fn decrypt(
&self,
request: &DecryptRequest,
_context: Option<&OperationContext>,
) -> Result<(Vec<u8>, String)> {
debug!("Decrypting data");
// Parse the data key envelope from ciphertext
@@ -929,7 +935,7 @@ impl VaultKmsClient {
};
debug!("Vault KMS data decrypted");
Ok(plaintext)
Ok((plaintext, envelope.master_key_id))
}
/// Report which master key version wraps an envelope, and whether that is
@@ -1626,16 +1632,11 @@ impl KmsBackend for VaultKmsBackend {
}
async fn decrypt(&self, request: DecryptRequest) -> Result<DecryptResponse> {
let plaintext = self.client.decrypt(&request, None).await?;
// The envelope that was just opened names the master key that opened it.
// Reporting "unknown" left every caller unable to tell which key was
// actually used, which is what audit and key-rotation checks read.
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)?;
let (plaintext, key_id) = self.client.decrypt(&request, None).await?;
Ok(DecryptResponse {
plaintext,
key_id: envelope.master_key_id,
key_id,
encryption_algorithm: Some("AES-256-GCM".to_string()),
})
}
@@ -1660,12 +1661,19 @@ impl KmsBackend for VaultKmsBackend {
grant_tokens: Vec::new(),
};
let data_key = self.client.generate_data_key(&generate_request, None).await?;
let mut data_key = self.client.generate_data_key(&generate_request, None).await?;
// Fields are taken, not destructured or cloned: `DataKeyInfo` has a
// `Drop` impl, and a clone would leave a second un-zeroized plaintext
// DEK on the heap.
let plaintext_key = data_key
.plaintext
.take()
.ok_or_else(|| KmsError::internal_error("Generated data key is missing plaintext"))?;
Ok(GenerateDataKeyResponse {
key_id: request.key_id,
plaintext_key: data_key.plaintext.clone().unwrap_or_default(),
ciphertext_blob: data_key.ciphertext.clone(),
plaintext_key,
ciphertext_blob: std::mem::take(&mut data_key.ciphertext),
})
}
@@ -2525,7 +2533,7 @@ mod tests {
// A mixed batch of envelopes from every historical version must decrypt.
for (data_key, label) in [(&dk_v1, "v1"), (&dk_v3, "v3"), (&dk_v2, "v2"), (&dk_v1, "v1 again")] {
let plaintext = client
let (plaintext, _opened_by) = client
.decrypt(&integration_decrypt_request(data_key.ciphertext.clone()), None)
.await
.unwrap_or_else(|error| panic!("envelope wrapped under {label} must stay decryptable: {error}"));
@@ -2561,7 +2569,7 @@ mod tests {
// The baseline rule must route the legacy envelope to the frozen version 1
// material even though the current version has moved on.
let plaintext = client
let (plaintext, _opened_by) = client
.decrypt(&integration_decrypt_request(legacy_ciphertext), None)
.await
.expect("legacy envelope must stay decryptable after rotation");
@@ -2605,7 +2613,7 @@ mod tests {
);
// The untampered envelope still decrypts through its recorded version.
let plaintext = client
let (plaintext, _opened_by) = client
.decrypt(&integration_decrypt_request(data_key.ciphertext.clone()), None)
.await
.expect("untampered envelope must still decrypt");
@@ -2870,7 +2878,7 @@ mod tests {
assert_eq!(envelope.master_key_id, "wired-key");
assert_eq!(envelope.master_key_version, Some(1));
let decrypted = client
let (decrypted, opened_by) = client
.decrypt(
&DecryptRequest {
ciphertext: encrypted.ciphertext.clone(),
@@ -2882,6 +2890,7 @@ mod tests {
.await
.expect("decrypt must round-trip the envelope");
assert_eq!(decrypted, b"kv2-direct-encrypt".to_vec());
assert_eq!(opened_by, "wired-key", "decrypt must report the master key that opened the envelope");
// A different object context must not decrypt (checked before any
// Vault read, so no scripted response is consumed).
@@ -4181,7 +4190,7 @@ mod tests {
let (vault, client) = scripted_client(vec![ScriptedResponse::ok(kv2_read_data(&key_data))]).await;
let plaintext = client
let (plaintext, _opened_by) = client
.decrypt(
&DecryptRequest {
ciphertext,
@@ -4315,7 +4324,7 @@ mod tests {
}
let (vault, client) = scripted_client(responses).await;
let plaintext = client
let (plaintext, _opened_by) = client
.decrypt(
&DecryptRequest {
ciphertext: ciphertext.to_vec(),
+23 -11
View File
@@ -817,7 +817,13 @@ impl VaultTransitKmsClient {
})
}
pub(crate) async fn decrypt(&self, request: &DecryptRequest, _context: Option<&OperationContext>) -> Result<Vec<u8>> {
/// Open a data-key envelope, returning the plaintext and the master key
/// that wrapped it.
pub(crate) async fn decrypt(
&self,
request: &DecryptRequest,
_context: Option<&OperationContext>,
) -> Result<(Vec<u8>, String)> {
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)
.map_err(|e| KmsError::cryptographic_error("parse", format!("Failed to parse data key envelope: {e}")))?;
@@ -839,7 +845,7 @@ impl VaultTransitKmsClient {
.transit_decrypt(&envelope.master_key_id, encrypted_key, &envelope.encryption_context)
.await
{
Ok(plaintext) => Ok(plaintext),
Ok(plaintext) => Ok((plaintext, envelope.master_key_id)),
Err(error) => {
self.invalidate_metadata_on_state_error(&envelope.master_key_id, &error).await;
Err(error)
@@ -1384,11 +1390,10 @@ impl KmsBackend for VaultTransitKmsBackend {
}
async fn decrypt(&self, request: DecryptRequest) -> Result<DecryptResponse> {
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)?;
let plaintext = self.client.decrypt(&request, None).await?;
let (plaintext, key_id) = self.client.decrypt(&request, None).await?;
Ok(DecryptResponse {
plaintext,
key_id: envelope.master_key_id,
key_id,
encryption_algorithm: Some("vault-transit".to_string()),
})
}
@@ -1413,13 +1418,19 @@ impl KmsBackend for VaultTransitKmsBackend {
grant_tokens: Vec::new(),
};
let data_key = self.client.generate_data_key(&generate_request, None).await?;
let plaintext_key = data_key.plaintext.clone().unwrap_or_default();
let ciphertext_blob = data_key.ciphertext.clone();
let mut data_key = self.client.generate_data_key(&generate_request, None).await?;
// Fields are taken, not destructured or cloned: `DataKeyInfo` has a
// `Drop` impl, and a clone would leave a second un-zeroized plaintext
// DEK on the heap.
let plaintext_key = data_key
.plaintext
.take()
.ok_or_else(|| KmsError::internal_error("Generated data key is missing plaintext"))?;
Ok(GenerateDataKeyResponse {
key_id: request.key_id,
plaintext_key,
ciphertext_blob,
ciphertext_blob: std::mem::take(&mut data_key.ciphertext),
})
}
@@ -2089,7 +2100,7 @@ mod tests {
// Historical ciphertext keeps decrypting per Vault's version semantics,
// interleaved with post-rotation ciphertext.
for (data_key, label) in [(&dk_v1, "v1"), (&dk_v2, "v2"), (&dk_v1, "v1 again")] {
let plaintext = client
let (plaintext, _opened_by) = client
.decrypt(
&DecryptRequest {
ciphertext: data_key.ciphertext.clone(),
@@ -2659,7 +2670,7 @@ mod tests {
let rotated = client.rotate_key("wired-key", None).await.expect("rotation must commit");
assert_eq!(rotated.version, 2, "the rotation must record the version bump");
let plaintext = client
let (plaintext, opened_by) = client
.decrypt(
&DecryptRequest {
ciphertext: data_key.ciphertext.clone(),
@@ -2675,6 +2686,7 @@ mod tests {
RECOVERED_DEK.to_vec(),
"the decrypt must hand back the recovered material, not merely avoid an error"
);
assert_eq!(opened_by, "wired-key", "decrypt must report the master key that opened the envelope");
let requests = vault.requests();
assert_eq!(requests.len(), 7, "{requests:?}");
+5 -3
View File
@@ -389,16 +389,18 @@ impl Default for AesDekCrypto {
}
}
/// Generate random key material for the given algorithm
/// Generate random key material for the given algorithm.
///
/// The lengths must track [`crate::types::KeySpec::key_size`].
///
/// # Arguments
/// * `algorithm` - The key algorithm (e.g., "AES_256", "AES_128")
/// * `algorithm` - The key algorithm (e.g., "AES_256", "AES_128", "ChaCha20")
///
/// # Returns
/// A vector containing the generated key material
pub fn generate_key_material(algorithm: &str) -> Result<Vec<u8>> {
let key_size = match algorithm {
"AES_256" => 32,
"AES_256" | "ChaCha20" => 32,
"AES_128" => 16,
_ => return Err(KmsError::unsupported_algorithm(algorithm)),
};