feat(kms): bind encryption context into DEK envelopes as AAD (#6639)

This commit is contained in:
唐小鸭
2026-08-26 13:58:13 +08:00
committed by GitHub
parent 51041e917e
commit a4377b6351
9 changed files with 606 additions and 55 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ anyhow = { workspace = true }
metrics-util = { workspace = true, features = ["debugging"] }
insta = { workspace = true, features = ["yaml", "json"] }
tempfile = { workspace = true }
temp-env = { workspace = true }
temp-env = { workspace = true, features = ["async_closure"] }
rcgen = { workspace = true }
# "net" backs the scripted loopback Vault used by the policy wiring tests.
tokio = { workspace = true, features = ["net", "test-util"] }
+140 -15
View File
@@ -20,7 +20,10 @@ use crate::backends::{
};
use crate::config::KmsConfig;
use crate::config::LocalConfig;
use crate::encryption::{AesDekCrypto, DataKeyEnvelope, DekCrypto, generate_key_material};
use crate::encryption::{
AesDekCrypto, CONTEXT_BINDING_AAD_V1, DataKeyEnvelope, DekCrypto, context_aad, envelope_aad_write_enabled, envelope_wrap_aad,
generate_key_material,
};
use crate::error::{KmsError, Result};
use crate::persisted_observability::{BoundedUnknownFieldName, UnknownFieldSummary};
use crate::types::*;
@@ -1503,17 +1506,17 @@ impl LocalKmsClient {
}
/// Encrypt data using a master key
async fn encrypt_with_master_key(&self, key_id: &str, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
async fn encrypt_with_master_key(&self, key_id: &str, plaintext: &[u8], aad: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
// Load the actual master key material
let key_material = self.get_key_material(key_id).await?;
self.dek_crypto.encrypt(&key_material, plaintext).await
self.dek_crypto.encrypt(&key_material, plaintext, aad).await
}
/// Decrypt data using a master key
async fn decrypt_with_master_key(&self, key_id: &str, ciphertext: &[u8], nonce: &[u8]) -> Result<Vec<u8>> {
async fn decrypt_with_master_key(&self, key_id: &str, ciphertext: &[u8], nonce: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
// Load the actual master key material
let key_material = self.get_key_material(key_id).await?;
self.dek_crypto.decrypt(&key_material, ciphertext, nonce).await
self.dek_crypto.decrypt(&key_material, ciphertext, nonce, aad).await
}
}
@@ -1532,7 +1535,14 @@ impl LocalKmsClient {
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?;
let context_binding = envelope_aad_write_enabled().then_some(CONTEXT_BINDING_AAD_V1);
let wrap_aad = match context_binding {
Some(_) => context_aad(&request.encryption_context)?,
None => Vec::new(),
};
let (encrypted_key, nonce) = self
.encrypt_with_master_key(&request.master_key_id, &plaintext_key, &wrap_aad)
.await?;
// Local rotation is rejected, so every envelope is wrapped by the key's sole
// material and needs no master key version.
@@ -1545,6 +1555,7 @@ impl LocalKmsClient {
encryption_context: request.encryption_context.clone(),
created_at: Zoned::now(),
master_key_version: None,
context_binding,
};
// Serialize the envelope as the ciphertext
@@ -1563,7 +1574,14 @@ impl LocalKmsClient {
let key_info = self.describe_key(&request.key_id, context).await?;
ensure_key_status_permits(&request.key_id, &key_info.status, StateGatedOperation::Encrypt)?;
let (encrypted_key, nonce) = self.encrypt_with_master_key(&request.key_id, &request.plaintext).await?;
let context_binding = envelope_aad_write_enabled().then_some(CONTEXT_BINDING_AAD_V1);
let wrap_aad = match context_binding {
Some(_) => context_aad(&request.encryption_context)?,
None => Vec::new(),
};
let (encrypted_key, nonce) = self
.encrypt_with_master_key(&request.key_id, &request.plaintext, &wrap_aad)
.await?;
// The ciphertext must be the same envelope `decrypt` parses: the nonce
// and the bound context live in it, so handing back the bare AES-GCM
@@ -1578,6 +1596,7 @@ impl LocalKmsClient {
created_at: Zoned::now(),
// Local rotation is rejected, so the key has a single material version.
master_key_version: None,
context_binding,
};
let ciphertext = serde_json::to_vec(&envelope)?;
@@ -1603,13 +1622,12 @@ impl LocalKmsClient {
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`
// rides in the envelope unauthenticated: anyone able to rewrite the
// stored envelope can rewrite this field and present a matching context.
// The Static and Vault Transit backends do bind it (as AEAD AAD and as
// the Transit KDF context respectively); closing the gap here needs a
// versioned envelope, since existing ciphertext was sealed without AAD.
// Two layers guard the context. On envelopes with a context binding,
// the stored `encryption_context` is authenticated: it was sealed into
// the wrap as AAD, so rewriting the stored field (or stripping the
// binding flag) makes the unwrap below fail. On legacy envelopes the
// field rides unauthenticated and only this comparison covers it —
// which is an authorization check, not a cryptographic binding.
// Verify encryption context matches
// Check that all keys in envelope.encryption_context are present in request.encryption_context
// and their values match. This ensures the context used for decryption matches what was used for encryption.
@@ -1630,8 +1648,9 @@ impl LocalKmsClient {
}
// Decrypt the data key
let wrap_aad = envelope_wrap_aad(&envelope)?;
let plaintext = self
.decrypt_with_master_key(&envelope.master_key_id, &envelope.encrypted_key, &envelope.nonce)
.decrypt_with_master_key(&envelope.master_key_id, &envelope.encrypted_key, &envelope.nonce, &wrap_aad)
.await?;
debug!("Local KMS data decrypted");
@@ -2364,6 +2383,112 @@ mod tests {
(client, temp_dir)
}
/// With the AAD write switch on, the Local backend seals the stored
/// encryption context into the wrap exactly like KV2: the bound envelope
/// round-trips, a rewritten stored context fails authentication even with
/// a matching request context, and legacy envelopes keep decrypting.
#[tokio::test]
async fn test_local_bound_envelope_authenticates_its_stored_context() {
let (client, _temp_dir) = create_test_client().await;
client
.create_key("aad-key", "AES_256", None)
.await
.expect("Failed to create key");
let context = HashMap::from([("bucket".to_string(), "local-aad".to_string())]);
// Legacy envelope written with the default (switch off).
let legacy = client
.generate_data_key(
&GenerateKeyRequest {
master_key_id: "aad-key".to_string(),
key_spec: "AES_256".to_string(),
encryption_context: context.clone(),
grant_tokens: Vec::new(),
key_length: None,
},
None,
)
.await
.expect("legacy generate must succeed");
let legacy_value: serde_json::Value = serde_json::from_slice(&legacy.ciphertext).expect("envelope must parse");
assert!(
!legacy_value
.as_object()
.expect("envelope is a JSON object")
.contains_key("context_binding"),
"default writes must keep the historical envelope shape"
);
let bound = temp_env::async_with_vars([(crate::config::ENV_KMS_ENVELOPE_AAD, Some("true"))], async {
client
.generate_data_key(
&GenerateKeyRequest {
master_key_id: "aad-key".to_string(),
key_spec: "AES_256".to_string(),
encryption_context: context.clone(),
grant_tokens: Vec::new(),
key_length: None,
},
None,
)
.await
.expect("bound generate must succeed")
})
.await;
let envelope: DataKeyEnvelope = serde_json::from_slice(&bound.ciphertext).expect("envelope must parse");
assert_eq!(envelope.context_binding, Some(CONTEXT_BINDING_AAD_V1));
// Both generations decrypt, with no switch involved on the read side.
for ciphertext in [&legacy.ciphertext, &bound.ciphertext] {
client
.decrypt(
&DecryptRequest {
ciphertext: ciphertext.clone(),
encryption_context: context.clone(),
grant_tokens: Vec::new(),
},
None,
)
.await
.expect("both envelope generations must decrypt");
}
// Rewriting the stored context defeats the comparison but not the AAD.
let mut tampered: serde_json::Value = serde_json::from_slice(&bound.ciphertext).expect("envelope must parse");
tampered["encryption_context"] = serde_json::json!({"bucket": "stolen"});
let error = client
.decrypt(
&DecryptRequest {
ciphertext: serde_json::to_vec(&tampered).expect("serialize tampered envelope"),
encryption_context: HashMap::from([("bucket".to_string(), "stolen".to_string())]),
grant_tokens: Vec::new(),
},
None,
)
.await
.expect_err("a rewritten stored context must fail authentication");
assert!(
!matches!(error, KmsError::ContextMismatch { .. }),
"the failure must come from the AAD, not the field comparison: {error:?}"
);
// The same tamper against the legacy envelope shows what the binding
// adds: the comparison alone accepts it.
let mut legacy_tampered: serde_json::Value = serde_json::from_slice(&legacy.ciphertext).expect("envelope must parse");
legacy_tampered["encryption_context"] = serde_json::json!({"bucket": "stolen"});
client
.decrypt(
&DecryptRequest {
ciphertext: serde_json::to_vec(&legacy_tampered).expect("serialize tampered envelope"),
encryption_context: HashMap::from([("bucket".to_string(), "stolen".to_string())]),
grant_tokens: Vec::new(),
},
None,
)
.await
.expect("the legacy format cannot detect a rewritten stored context; this is the gap the binding closes");
}
#[tokio::test]
async fn test_key_lifecycle() {
let (client, _temp_dir) = create_test_client().await;
+8
View File
@@ -152,6 +152,10 @@ impl StaticKmsBackend {
created_at: Zoned::now(),
// The static backend has a single fixed key with no rotation.
master_key_version: None,
// The context is already bound as AAD by the Static cipher path
// unconditionally; this field describes only the DekCrypto-layer
// binding, which Static envelopes never use.
context_binding: None,
};
let ciphertext = serde_json::to_vec(&envelope)?;
@@ -199,6 +203,10 @@ impl StaticKmsBackend {
created_at: Zoned::now(),
// The static backend has a single fixed key with no rotation.
master_key_version: None,
// The context is already bound as AAD by the Static cipher path
// unconditionally; this field describes only the DekCrypto-layer
// binding, which Static envelopes never use.
context_binding: None,
};
let ciphertext = serde_json::to_vec(&envelope)?;
+228 -24
View File
@@ -24,7 +24,10 @@ use crate::backends::{
ensure_rewrap_context_matches, ensure_tag_keys_are_mutable, list_keys_page_size, paginate_keys, started_at_the_first_key,
};
use crate::config::{KmsConfig, VaultConfig};
use crate::encryption::{AesDekCrypto, DataKeyEnvelope, DekCrypto, generate_key_material};
use crate::encryption::{
AesDekCrypto, CONTEXT_BINDING_AAD_V1, DataKeyEnvelope, DekCrypto, context_aad, desired_context_binding,
envelope_aad_write_enabled, envelope_wrap_aad, generate_key_material,
};
use crate::error::{KmsError, Result};
use crate::persisted_observability::{BoundedUnknownFieldName, UnknownFieldSummary};
use crate::policy::{self, AttemptError, OpClass, RetryPolicy};
@@ -1189,7 +1192,12 @@ impl VaultKmsClient {
warn!(key_id = %request.master_key_id, %error, "Vault KMS key material failed validation");
})?;
self.consume_wrap_budget(&request.master_key_id, key_data.version).await;
let (encrypted_key, nonce) = self.dek_crypto.encrypt(&key_material, &plaintext_key).await?;
let context_binding = envelope_aad_write_enabled().then_some(CONTEXT_BINDING_AAD_V1);
let wrap_aad = match context_binding {
Some(_) => context_aad(&request.encryption_context)?,
None => Vec::new(),
};
let (encrypted_key, nonce) = self.dek_crypto.encrypt(&key_material, &plaintext_key, &wrap_aad).await?;
// Create data key envelope with master key version for rotation support
let envelope = DataKeyEnvelope {
@@ -1201,6 +1209,7 @@ impl VaultKmsClient {
encryption_context: request.encryption_context.clone(),
created_at: Zoned::now(),
master_key_version: Some(key_data.version),
context_binding,
};
// Serialize the envelope as the ciphertext
@@ -1223,7 +1232,12 @@ impl VaultKmsClient {
let key_material = decode_stored_key_material(&request.key_id, &key_data.encrypted_key_material)
.inspect_err(|error| warn!(key_id = %request.key_id, %error, "Vault KMS key material failed validation"))?;
self.consume_wrap_budget(&request.key_id, key_data.version).await;
let (encrypted_key, nonce) = self.dek_crypto.encrypt(&key_material, &request.plaintext).await?;
let context_binding = envelope_aad_write_enabled().then_some(CONTEXT_BINDING_AAD_V1);
let wrap_aad = match context_binding {
Some(_) => context_aad(&request.encryption_context)?,
None => Vec::new(),
};
let (encrypted_key, nonce) = self.dek_crypto.encrypt(&key_material, &request.plaintext, &wrap_aad).await?;
// Wrap the ciphertext in the same authenticated envelope that
// generate_data_key emits, so decrypt() round-trips it and resolves
@@ -1237,6 +1251,7 @@ impl VaultKmsClient {
encryption_context: request.encryption_context.clone(),
created_at: Zoned::now(),
master_key_version: Some(key_data.version),
context_binding,
};
let ciphertext = serde_json::to_vec(&envelope)?;
@@ -1261,13 +1276,12 @@ impl VaultKmsClient {
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)
.map_err(|e| KmsError::cryptographic_error("parse", format!("Failed to parse data key envelope: {e}")))?;
// NOTE: this comparison is an authorization check, not a cryptographic
// binding. `DekCrypto` seals only the plaintext, so `encryption_context`
// rides in the envelope unauthenticated: anyone able to rewrite the
// stored envelope can rewrite this field and present a matching context.
// The Static and Vault Transit backends do bind it (as AEAD AAD and as
// the Transit KDF context respectively); closing the gap here needs a
// versioned envelope, since existing ciphertext was sealed without AAD.
// Two layers guard the context. On envelopes with a context binding,
// the stored `encryption_context` is authenticated: it was sealed into
// the wrap as AAD, so rewriting the stored field (or stripping the
// binding flag) makes the unwrap below fail. On legacy envelopes the
// field rides unauthenticated and only this comparison covers it —
// which is an authorization check, not a cryptographic binding.
// Verify encryption context matches
// Check that all keys in envelope.encryption_context are present in request.encryption_context
// and their values match. This ensures the context used for decryption matches what was used for encryption.
@@ -1294,9 +1308,10 @@ impl VaultKmsClient {
let key_material = self
.get_key_material_for_version(&envelope.master_key_id, &key_data, version)
.await?;
let wrap_aad = envelope_wrap_aad(&envelope)?;
let plaintext = match self
.dek_crypto
.decrypt(&key_material, &envelope.encrypted_key, &envelope.nonce)
.decrypt(&key_material, &envelope.encrypted_key, &envelope.nonce, &wrap_aad)
.await
{
Ok(plaintext) => plaintext,
@@ -1340,8 +1355,12 @@ impl VaultKmsClient {
// pre-versioning envelope resolves to the current version while
// saying nothing, and `rewrap_data_key` rewrites exactly those to
// stamp the version — so reporting them as current here would leave
// the sweep and the scan permanently disagreeing.
is_current: envelope.master_key_version == Some(current_version),
// the sweep and the scan permanently disagreeing. The context
// binding enters the same way: an envelope below the desired
// binding is one the sweep will rewrite, so the scan must not
// count it as done.
is_current: envelope.master_key_version == Some(current_version)
&& envelope.context_binding == desired_context_binding(envelope.context_binding),
})
}
@@ -1379,11 +1398,17 @@ impl VaultKmsClient {
ensure_key_status_permits(&envelope.master_key_id, &key_data.status, StateGatedOperation::Encrypt)?;
let current_version = key_data.version;
if envelope.master_key_version == Some(current_version) {
// Already on the current version and saying so. Hand the input back
// untouched rather than producing an equivalent envelope with a
// fresh nonce: a re-run of a sweep must converge to zero writes, and
// the storage layer keys its write decision off these bytes.
// The binding never regresses and upgrades follow the write switch;
// the shared rule keeps this no-op condition and the scan's
// `is_current` in agreement (see `desired_context_binding`).
let destination_binding = desired_context_binding(envelope.context_binding);
if envelope.master_key_version == Some(current_version) && envelope.context_binding == destination_binding {
// Already on the current version and the desired binding, and
// saying so. Hand the input back untouched rather than producing an
// equivalent envelope with a fresh nonce: a re-run of a sweep must
// converge to zero writes, and the storage layer keys its write
// decision off these bytes.
return Ok(RewrapDataKeyResponse {
ciphertext: request.ciphertext.clone(),
key_id: envelope.master_key_id,
@@ -1409,9 +1434,16 @@ impl VaultKmsClient {
let destination_material = decode_stored_key_material(&envelope.master_key_id, &key_data.encrypted_key_material)
.inspect_err(|error| warn!(key_id = %envelope.master_key_id, %error, "Vault KMS key material failed validation"))?;
// Both AADs are resolved before the plaintext exists, keeping the
// zeroize window free of fallible steps.
let source_aad = envelope_wrap_aad(&envelope)?;
let destination_aad = match destination_binding {
Some(_) => context_aad(&envelope.encryption_context)?,
None => Vec::new(),
};
let mut plaintext_key = match self
.dek_crypto
.decrypt(&source_material, &envelope.encrypted_key, &envelope.nonce)
.decrypt(&source_material, &envelope.encrypted_key, &envelope.nonce, &source_aad)
.await
{
Ok(plaintext) => plaintext,
@@ -1421,7 +1453,10 @@ impl VaultKmsClient {
.await);
}
};
let rewrapped = self.dek_crypto.encrypt(&destination_material, &plaintext_key).await;
let rewrapped = self
.dek_crypto
.encrypt(&destination_material, &plaintext_key, &destination_aad)
.await;
plaintext_key.zeroize();
let (encrypted_key, nonce) = rewrapped?;
@@ -1434,6 +1469,7 @@ impl VaultKmsClient {
encryption_context: envelope.encryption_context,
created_at: envelope.created_at,
master_key_version: Some(current_version),
context_binding: destination_binding,
};
let ciphertext = serde_json::to_vec(&rewrapped_envelope)?;
@@ -4329,7 +4365,7 @@ mod tests {
// guard this decrypt would *succeed*, which is exactly the masked
// rollback this test pins down.
let (encrypted_key, nonce) = AesDekCrypto::new()
.encrypt(&material_v2, b"dek-plaintext")
.encrypt(&material_v2, b"dek-plaintext", &[])
.await
.expect("wrap test DEK");
let envelope = DataKeyEnvelope {
@@ -4341,6 +4377,7 @@ mod tests {
encryption_context: HashMap::new(),
created_at: Zoned::now(),
master_key_version: Some(2),
context_binding: None,
};
let ciphertext = serde_json::to_vec(&envelope).expect("serialize envelope");
@@ -4754,7 +4791,7 @@ mod tests {
async fn wired_decrypt_reports_erased_baseline_for_pre_versioning_envelope() {
let baseline_material = [0x41u8; 32];
let (encrypted_key, nonce) = AesDekCrypto::new()
.encrypt(&baseline_material, b"dek-plaintext")
.encrypt(&baseline_material, b"dek-plaintext", &[])
.await
.expect("wrap test DEK under the baseline material");
// A pre-versioning envelope: no master_key_version field.
@@ -4767,6 +4804,7 @@ mod tests {
encryption_context: HashMap::new(),
created_at: Zoned::now(),
master_key_version: None,
context_binding: None,
};
let ciphertext = serde_json::to_vec(&envelope).expect("serialize envelope");
@@ -4814,7 +4852,7 @@ mod tests {
#[tokio::test]
async fn wired_decrypt_keeps_original_error_when_key_was_never_rotated() {
let (encrypted_key, nonce) = AesDekCrypto::new()
.encrypt(&[0x41u8; 32], b"dek-plaintext")
.encrypt(&[0x41u8; 32], b"dek-plaintext", &[])
.await
.expect("wrap test DEK");
let envelope = DataKeyEnvelope {
@@ -4826,6 +4864,7 @@ mod tests {
encryption_context: HashMap::new(),
created_at: Zoned::now(),
master_key_version: None,
context_binding: None,
};
let ciphertext = serde_json::to_vec(&envelope).expect("serialize envelope");
@@ -4865,7 +4904,7 @@ mod tests {
.decode(&key_data.encrypted_key_material)
.expect("decode fixture material");
let (encrypted_key, nonce) = AesDekCrypto::new()
.encrypt(&key_material, b"dek-plaintext")
.encrypt(&key_material, b"dek-plaintext", &[])
.await
.expect("wrap test DEK under the current material");
let envelope = DataKeyEnvelope {
@@ -4877,6 +4916,7 @@ mod tests {
encryption_context: HashMap::new(),
created_at: Zoned::now(),
master_key_version: None,
context_binding: None,
};
let ciphertext = serde_json::to_vec(&envelope).expect("serialize envelope");
@@ -5280,6 +5320,170 @@ mod tests {
assert_eq!(again.ciphertext, encrypted_v2.ciphertext);
}
/// With the AAD write switch on, the stored encryption context is sealed
/// into the wrap. Rewriting the stored field — or stripping the binding
/// flag — must fail authentication even when the presented request context
/// matches the rewritten stored one, which the legacy field comparison
/// alone would accept.
#[tokio::test]
async fn wired_kv2_bound_envelope_authenticates_its_stored_context() {
let state = KeyState::new(healthy_key_data());
let encrypted = temp_env::async_with_vars(
[(crate::config::ENV_KMS_ENVELOPE_AAD, Some("true"))],
encrypt_scripted(&state, b"bound-data-key"),
)
.await;
let envelope: DataKeyEnvelope = serde_json::from_slice(&encrypted.ciphertext).expect("envelope must parse");
assert_eq!(envelope.context_binding, Some(CONTEXT_BINDING_AAD_V1));
// The bound envelope round-trips; reading needs no switch.
let (plaintext, _) = decrypt_scripted(&state, &encrypted.ciphertext).await;
assert_eq!(plaintext, b"bound-data-key".to_vec());
// Rewrite the stored context and present a matching request context:
// the comparison passes, the authentication does not.
let mut tampered: serde_json::Value = serde_json::from_slice(&encrypted.ciphertext).expect("envelope must parse");
tampered["encryption_context"] = serde_json::json!({"bucket": "stolen"});
let (_vault, client) = scripted_client(vec![ScriptedResponse::ok(kv2_read_data(&state.key_data))]).await;
let error = client
.decrypt(
&DecryptRequest {
ciphertext: serde_json::to_vec(&tampered).expect("serialize tampered envelope"),
encryption_context: HashMap::from([("bucket".to_string(), "stolen".to_string())]),
grant_tokens: Vec::new(),
},
None,
)
.await
.expect_err("a rewritten stored context must fail authentication");
assert!(
!matches!(error, KmsError::ContextMismatch { .. }),
"the failure must come from the AAD, not the field comparison: {error:?}"
);
// Strip the binding flag: the unwrap then runs with empty AAD against
// ciphertext sealed with the context bound, and must fail.
let mut stripped: serde_json::Value = serde_json::from_slice(&encrypted.ciphertext).expect("envelope must parse");
stripped
.as_object_mut()
.expect("envelope is a JSON object")
.remove("context_binding")
.expect("the bound envelope must carry the flag");
let (_vault, client) = scripted_client(vec![ScriptedResponse::ok(kv2_read_data(&state.key_data))]).await;
let error = client
.decrypt(
&DecryptRequest {
ciphertext: serde_json::to_vec(&stripped).expect("serialize stripped envelope"),
encryption_context: HashMap::new(),
grant_tokens: Vec::new(),
},
None,
)
.await
.expect_err("stripping the binding flag must fail authentication");
assert!(!matches!(error, KmsError::ContextMismatch { .. }), "got {error:?}");
}
/// The write switch defaults off and legacy interchange holds in both
/// directions: default writes keep the historical JSON shape (no
/// `context_binding` key at all), and an unbound envelope written that way
/// still decrypts on a node whose write switch is already on.
#[tokio::test]
async fn wired_kv2_write_switch_defaults_off_and_legacy_envelopes_interchange() {
let state = KeyState::new(healthy_key_data());
let encrypted = encrypt_scripted(&state, b"legacy-data-key").await;
let value: serde_json::Value = serde_json::from_slice(&encrypted.ciphertext).expect("envelope must parse");
assert!(
!value
.as_object()
.expect("envelope is a JSON object")
.contains_key("context_binding"),
"default writes must keep the historical envelope shape"
);
let plaintext = temp_env::async_with_vars(
[(crate::config::ENV_KMS_ENVELOPE_AAD, Some("true"))],
decrypt_scripted(&state, &encrypted.ciphertext),
)
.await
.0;
assert_eq!(plaintext, b"legacy-data-key".to_vec());
}
/// An unrecognized binding version is a format from a newer release;
/// decrypting it while ignoring the binding would silently drop an
/// authentication the writer relied on, so it fails closed instead.
#[tokio::test]
async fn wired_kv2_unknown_context_binding_version_fails_closed() {
let state = KeyState::new(healthy_key_data());
let encrypted = encrypt_scripted(&state, b"future-data-key").await;
let mut future: serde_json::Value = serde_json::from_slice(&encrypted.ciphertext).expect("envelope must parse");
future["context_binding"] = serde_json::json!(9);
let (_vault, client) = scripted_client(vec![ScriptedResponse::ok(kv2_read_data(&state.key_data))]).await;
let error = client
.decrypt(
&DecryptRequest {
ciphertext: serde_json::to_vec(&future).expect("serialize future envelope"),
encryption_context: HashMap::new(),
grant_tokens: Vec::new(),
},
None,
)
.await
.expect_err("an unknown binding version must fail closed");
assert!(error.to_string().contains("context binding version"), "got {error:?}");
}
/// Rewrap is how existing envelopes migrate to the bound format: with the
/// switch on, an unbound envelope already on the current version is
/// rewritten to carry the binding — and the result converges, so a sweep
/// re-run performs zero writes. With the switch off the binding never
/// regresses: a bound envelope stays bound.
#[tokio::test]
async fn wired_kv2_rewrap_migrates_binding_without_ever_regressing_it() {
let state = KeyState::new(healthy_key_data());
let unbound = encrypt_scripted(&state, b"data-key-to-upgrade").await;
// With the switch off, the unbound envelope is current: the scan must
// not demand a rewrite the sweep would refuse to perform.
let described = describe_wrapping_scripted(&state, &unbound.ciphertext).await;
assert!(described.is_current, "an unbound envelope is current while the switch is off");
let (upgraded, again) = temp_env::async_with_vars([(crate::config::ENV_KMS_ENVELOPE_AAD, Some("true"))], async {
// With the switch on, the scan and the sweep agree the envelope
// needs rewriting — is_current flips before anything is rewrapped.
let described = describe_wrapping_scripted(&state, &unbound.ciphertext).await;
assert!(!described.is_current, "an unbound envelope is not current once the switch is on");
let (upgraded, _) = rewrap_scripted(&state, &unbound.ciphertext).await;
let described = describe_wrapping_scripted(&state, &upgraded.ciphertext).await;
assert!(described.is_current, "the scan must agree the upgraded envelope is done");
let (again, _) = rewrap_scripted(&state, &upgraded.ciphertext).await;
(upgraded, again)
})
.await;
assert!(upgraded.rewrapped, "an unbound envelope on the current version must still be upgraded");
let upgraded_envelope: DataKeyEnvelope =
serde_json::from_slice(&upgraded.ciphertext).expect("upgraded envelope must parse");
assert_eq!(upgraded_envelope.context_binding, Some(CONTEXT_BINDING_AAD_V1));
assert_eq!(upgraded.source_key_version, upgraded.destination_key_version);
assert!(!again.rewrapped, "the upgrade must converge on the second pass");
assert_eq!(again.ciphertext, upgraded.ciphertext);
// The upgraded envelope still yields the data key.
let (plaintext, _) = decrypt_scripted(&state, &upgraded.ciphertext).await;
assert_eq!(plaintext, b"data-key-to-upgrade".to_vec());
// Switch off again: the bound envelope is not downgraded.
let (kept, _) = rewrap_scripted(&state, &upgraded.ciphertext).await;
assert!(!kept.rewrapped, "a bound envelope must never regress to the unbound format");
assert_eq!(kept.ciphertext, upgraded.ciphertext);
}
/// A pre-versioning envelope carries no version at all, so it can never
/// satisfy a retirement scan however current its material happens to be.
/// Rewrap therefore rewrites it to stamp the version — including on a
+12
View File
@@ -955,6 +955,10 @@ impl VaultTransitKmsClient {
// Transit ciphertext already self-describes its key version
// ("vault:vN:..."), so the envelope never carries one.
master_key_version: None,
// The context is bound as Vault Transit associated_data over
// `encrypted_key`; this field describes only the local DekCrypto
// binding, which Transit envelopes never use.
context_binding: None,
};
let ciphertext = serde_json::to_vec(&envelope)?;
@@ -996,6 +1000,10 @@ impl VaultTransitKmsClient {
// Transit ciphertext already self-describes its key version
// ("vault:vN:..."), so the envelope never carries one.
master_key_version: None,
// The context is bound as Vault Transit associated_data over
// `encrypted_key`; this field describes only the local DekCrypto
// binding, which Transit envelopes never use.
context_binding: None,
};
let ciphertext = serde_json::to_vec(&envelope)?;
@@ -1150,6 +1158,10 @@ impl VaultTransitKmsClient {
// Transit ciphertext still self-describes its version, so the
// envelope field stays absent exactly as generate_data_key leaves it.
master_key_version: None,
// The context is bound as Vault Transit associated_data over
// `encrypted_key`; this field describes only the local DekCrypto
// binding, which Transit envelopes never use.
context_binding: None,
};
let ciphertext = serde_json::to_vec(&rewrapped_envelope)?;
+5
View File
@@ -24,6 +24,11 @@ use std::time::Duration;
use url::Url;
pub const ENV_KMS_ALLOW_INSECURE_DEV_DEFAULTS: &str = "RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS";
/// Write-side switch for binding the encryption context into DEK envelopes as
/// AES-GCM additional data. Read-side support is unconditional; see
/// [`crate::encryption::dek::envelope_aad_write_enabled`] for the rollout
/// constraint that keeps this default-off for one release.
pub const ENV_KMS_ENVELOPE_AAD: &str = "RUSTFS_KMS_ENVELOPE_AAD";
pub const ENV_KMS_ALLOW_IMMEDIATE_DELETION: &str = "RUSTFS_KMS_ALLOW_IMMEDIATE_DELETION";
pub const ENV_KMS_VAULT_ADDRESS: &str = "RUSTFS_KMS_VAULT_ADDRESS";
pub const ENV_KMS_VAULT_TOKEN: &str = "RUSTFS_KMS_VAULT_TOKEN";
+202 -14
View File
@@ -73,6 +73,80 @@ pub struct DataKeyEnvelope {
/// byte-identical to the historical seven-field JSON shape.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub master_key_version: Option<u32>,
/// How `encryption_context` is cryptographically bound into `encrypted_key`.
///
/// `None` on legacy envelopes: the context rides in the envelope
/// unauthenticated and is checked only by field comparison.
/// [`CONTEXT_BINDING_AAD_V1`] means the canonical context bytes
/// ([`context_aad`]) were passed as AES-GCM additional data when the DEK
/// was wrapped, so rewriting the stored context (or the flag) makes the
/// unwrap fail authentication. Any other value belongs to a newer format
/// and must fail closed rather than decrypt without the binding.
///
/// Optional and omitted when `None` so legacy-writing nodes and readers
/// keep exchanging the historical JSON shape unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_binding: Option<u8>,
}
/// `context_binding` value: the canonical encryption context is bound as
/// AES-GCM additional data over `encrypted_key`.
pub const CONTEXT_BINDING_AAD_V1: u8 = 1;
/// Resolve the AAD bytes an envelope's wrap was sealed with.
///
/// Legacy envelopes were sealed without additional data, which for AES-GCM is
/// byte-identical to an empty AAD — so `None` maps to empty bytes and both
/// generations decrypt through the same code path. An unrecognized binding
/// version is a format from a newer release: decrypting it while ignoring its
/// binding would silently drop an authentication the writer relied on, so it
/// fails closed instead.
pub fn envelope_wrap_aad(envelope: &DataKeyEnvelope) -> Result<Vec<u8>> {
match envelope.context_binding {
None => Ok(Vec::new()),
Some(CONTEXT_BINDING_AAD_V1) => context_aad(&envelope.encryption_context),
Some(version) => Err(KmsError::cryptographic_error(
"context_binding",
format!("unsupported data-key envelope context binding version {version}; written by a newer RustFS release"),
)),
}
}
/// The binding a rewrap of this envelope must produce.
///
/// Never below the envelope's existing binding — a bound envelope must not
/// regress to the unbound format whatever the write switch says — and upgraded
/// to [`CONTEXT_BINDING_AAD_V1`] when the write switch is on. Shared by
/// `rewrap_data_key` and `describe_data_key_wrapping` so the sweep and the
/// scan agree on which envelopes still need rewriting; two divergent copies of
/// this rule would leave a sweep that never converges.
pub fn desired_context_binding(existing: Option<u8>) -> Option<u8> {
if existing == Some(CONTEXT_BINDING_AAD_V1) || envelope_aad_write_enabled() {
Some(CONTEXT_BINDING_AAD_V1)
} else {
existing
}
}
/// Whether newly wrapped DEK envelopes bind their encryption context as AAD.
///
/// Default off for one release: an envelope written with the binding cannot be
/// opened by a node that predates it (the unwrap fails authentication), so the
/// switch must only be enabled once every node in the cluster runs a release
/// that understands `context_binding`. Reading bound envelopes needs no switch.
pub fn envelope_aad_write_enabled() -> bool {
use crate::config::ENV_KMS_ENVELOPE_AAD;
use rustfs_utils::get_env_bool;
#[cfg(test)]
{
get_env_bool(ENV_KMS_ENVELOPE_AAD, false)
}
#[cfg(not(test))]
{
static ENABLED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| get_env_bool(ENV_KMS_ENVELOPE_AAD, false));
*ENABLED
}
}
impl<'de> Deserialize<'de> for DataKeyEnvelope {
@@ -89,6 +163,7 @@ impl<'de> Deserialize<'de> for DataKeyEnvelope {
EncryptionContext,
CreatedAt,
MasterKeyVersion,
ContextBinding,
Unknown(BoundedUnknownFieldName),
}
@@ -119,6 +194,7 @@ impl<'de> Deserialize<'de> for DataKeyEnvelope {
"encryption_context" => Field::EncryptionContext,
"created_at" => Field::CreatedAt,
"master_key_version" => Field::MasterKeyVersion,
"context_binding" => Field::ContextBinding,
_ => Field::Unknown(BoundedUnknownFieldName::new(value)),
})
}
@@ -161,6 +237,7 @@ impl<'de> Deserialize<'de> for DataKeyEnvelope {
let mut encryption_context = None;
let mut created_at: Option<ZonedValue> = None;
let mut master_key_version = None;
let mut context_binding = None;
let mut unknown_fields = UnknownFieldSummary::default();
while let Some(field) = map.next_key()? {
@@ -173,6 +250,7 @@ impl<'de> Deserialize<'de> for DataKeyEnvelope {
Field::EncryptionContext => read_field!(encryption_context, "encryption_context"),
Field::CreatedAt => read_field!(created_at, "created_at"),
Field::MasterKeyVersion => read_field!(master_key_version, "master_key_version"),
Field::ContextBinding => read_field!(context_binding, "context_binding"),
Field::Unknown(field) => {
let _: IgnoredAny = map.next_value()?;
unknown_fields.observe(field);
@@ -189,6 +267,7 @@ impl<'de> Deserialize<'de> for DataKeyEnvelope {
encryption_context: encryption_context.ok_or_else(|| de::Error::missing_field("encryption_context"))?,
created_at: created_at.ok_or_else(|| de::Error::missing_field("created_at"))?.0,
master_key_version: master_key_version.unwrap_or(None),
context_binding: context_binding.unwrap_or(None),
};
unknown_fields.record_for_data_key_envelope();
Ok(envelope)
@@ -204,6 +283,7 @@ impl<'de> Deserialize<'de> for DataKeyEnvelope {
"encryption_context",
"created_at",
"master_key_version",
"context_binding",
];
deserializer.deserialize_struct("DataKeyEnvelope", FIELDS, DataKeyEnvelopeVisitor)
}
@@ -267,7 +347,10 @@ pub trait DekCrypto: Send + Sync {
/// A tuple of (ciphertext, nonce) where:
/// - `ciphertext` - The encrypted data
/// - `nonce` - The nonce used for encryption (should be stored with ciphertext)
async fn encrypt(&self, key_material: &[u8], plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>)>;
///
/// `aad` is authenticated but not encrypted; pass empty bytes for the
/// legacy unbound format (for AES-GCM the two are byte-identical).
async fn encrypt(&self, key_material: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<(Vec<u8>, Vec<u8>)>;
/// Decrypt ciphertext data using a master key material
///
@@ -278,7 +361,10 @@ pub trait DekCrypto: Send + Sync {
///
/// # Returns
/// The decrypted plaintext data
async fn decrypt(&self, key_material: &[u8], ciphertext: &[u8], nonce: &[u8]) -> Result<Vec<u8>>;
///
/// `aad` must be byte-identical to the value used at encryption time or
/// authentication fails; pass empty bytes for legacy unbound ciphertext.
async fn decrypt(&self, key_material: &[u8], ciphertext: &[u8], nonce: &[u8], aad: &[u8]) -> Result<Vec<u8>>;
/// Get the algorithm name used by this implementation
#[allow(dead_code)] // May be used by implementations or for debugging
@@ -301,10 +387,10 @@ impl AesDekCrypto {
#[async_trait]
impl DekCrypto for AesDekCrypto {
async fn encrypt(&self, key_material: &[u8], plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
async fn encrypt(&self, key_material: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
use aes_gcm::{
Aes256Gcm, Key, Nonce,
aead::{Aead, KeyInit},
aead::{Aead, KeyInit, Payload},
};
// Validate key material length
@@ -325,18 +411,18 @@ impl DekCrypto for AesDekCrypto {
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from(nonce_bytes);
// Encrypt plaintext
// Encrypt plaintext; an empty `aad` produces the same bytes as no AAD.
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.encrypt(&nonce, Payload { msg: plaintext, aad })
.map_err(|e| KmsError::cryptographic_error("encrypt", e.to_string()))?;
Ok((ciphertext, nonce_bytes.to_vec()))
}
async fn decrypt(&self, key_material: &[u8], ciphertext: &[u8], nonce: &[u8]) -> Result<Vec<u8>> {
async fn decrypt(&self, key_material: &[u8], ciphertext: &[u8], nonce: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
use aes_gcm::{
Aes256Gcm, Key, Nonce,
aead::{Aead, KeyInit},
aead::{Aead, KeyInit, Payload},
};
// Validate nonce length
@@ -362,9 +448,9 @@ impl DekCrypto for AesDekCrypto {
nonce_array.copy_from_slice(nonce);
let nonce_ref = Nonce::from(nonce_array);
// Decrypt ciphertext
// Decrypt ciphertext; the AAD must match the encryption-time bytes.
let plaintext = cipher
.decrypt(&nonce_ref, ciphertext)
.decrypt(&nonce_ref, Payload { msg: ciphertext, aad })
.map_err(|e| KmsError::cryptographic_error("decrypt", e.to_string()))?;
Ok(plaintext)
@@ -424,7 +510,7 @@ mod tests {
// Test encryption
let (ciphertext, nonce) = crypto
.encrypt(&key_material, plaintext)
.encrypt(&key_material, plaintext, &[])
.await
.expect("Encryption should succeed");
@@ -434,7 +520,7 @@ mod tests {
// Test decryption
let decrypted = crypto
.decrypt(&key_material, &ciphertext, &nonce)
.decrypt(&key_material, &ciphertext, &nonce, &[])
.await
.expect("Decryption should succeed");
@@ -447,7 +533,7 @@ mod tests {
let invalid_key = vec![0u8; 16]; // Too short
let plaintext = b"test";
let result = crypto.encrypt(&invalid_key, plaintext).await;
let result = crypto.encrypt(&invalid_key, plaintext, &[]).await;
assert!(result.is_err());
}
@@ -458,10 +544,109 @@ mod tests {
let ciphertext = vec![0u8; 16];
let invalid_nonce = vec![0u8; 8]; // Too short
let result = crypto.decrypt(&key_material, &ciphertext, &invalid_nonce).await;
let result = crypto.decrypt(&key_material, &ciphertext, &invalid_nonce, &[]).await;
assert!(result.is_err());
}
/// The AAD parameter genuinely binds the ciphertext: the same bytes must
/// be presented at decrypt time, and empty AAD is byte-compatible with the
/// legacy no-AAD format so both generations share one code path.
#[tokio::test]
async fn test_aad_binds_the_ciphertext() {
let crypto = AesDekCrypto::new();
let key_material = generate_key_material("AES_256").expect("Failed to generate key material");
let context = HashMap::from([("bucket".to_string(), "aad-bucket".to_string())]);
let aad = context_aad(&context).expect("context must canonicalize");
let (ciphertext, nonce) = crypto
.encrypt(&key_material, b"bound-dek", &aad)
.await
.expect("encryption with AAD should succeed");
assert_eq!(
crypto
.decrypt(&key_material, &ciphertext, &nonce, &aad)
.await
.expect("matching AAD must decrypt"),
b"bound-dek"
);
assert!(
crypto.decrypt(&key_material, &ciphertext, &nonce, &[]).await.is_err(),
"stripping the AAD must fail authentication"
);
let other = context_aad(&HashMap::from([("bucket".to_string(), "other".to_string())])).expect("canonicalize");
assert!(
crypto.decrypt(&key_material, &ciphertext, &nonce, &other).await.is_err(),
"a different AAD must fail authentication"
);
}
/// `envelope_wrap_aad` maps the binding flag to the exact AAD bytes the
/// wrap was sealed with, and fails closed on versions from the future.
#[test]
fn test_envelope_wrap_aad_mapping() {
let mut envelope = DataKeyEnvelope {
key_id: "test-key-id".to_string(),
master_key_id: "master-key-id".to_string(),
key_spec: "AES_256".to_string(),
encrypted_key: vec![1, 2, 3, 4],
nonce: vec![5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
encryption_context: HashMap::from([("bucket".to_string(), "b".to_string())]),
created_at: Zoned::now(),
master_key_version: None,
context_binding: None,
};
assert!(
envelope_wrap_aad(&envelope).expect("legacy envelopes are valid").is_empty(),
"legacy envelopes were sealed without AAD"
);
envelope.context_binding = Some(CONTEXT_BINDING_AAD_V1);
assert_eq!(
envelope_wrap_aad(&envelope).expect("v1 binding is valid"),
context_aad(&envelope.encryption_context).expect("canonicalize"),
"the v1 binding must reproduce the canonical context bytes"
);
envelope.context_binding = Some(9);
let error = envelope_wrap_aad(&envelope).expect_err("an unknown binding version must fail closed");
assert!(error.to_string().contains("context binding version"), "got {error:?}");
}
/// The binding flag round-trips through JSON, stays absent for `None` so
/// legacy writers and readers keep the historical shape, and defaults to
/// `None` on envelopes that predate it.
#[test]
fn test_context_binding_serde_round_trip() {
let mut envelope = DataKeyEnvelope {
key_id: "test-key-id".to_string(),
master_key_id: "master-key-id".to_string(),
key_spec: "AES_256".to_string(),
encrypted_key: vec![1, 2, 3, 4],
nonce: vec![5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
encryption_context: HashMap::new(),
created_at: Zoned::now(),
master_key_version: None,
context_binding: Some(CONTEXT_BINDING_AAD_V1),
};
let serialized = serde_json::to_vec(&envelope).expect("serialize envelope");
let value: serde_json::Value = serde_json::from_slice(&serialized).expect("parse serialized envelope");
assert_eq!(value.get("context_binding"), Some(&serde_json::json!(1)));
let deserialized: DataKeyEnvelope = serde_json::from_slice(&serialized).expect("deserialize envelope");
assert_eq!(deserialized.context_binding, Some(CONTEXT_BINDING_AAD_V1));
envelope.context_binding = None;
let value = serde_json::to_value(&envelope).expect("serialize envelope");
assert!(
!value
.as_object()
.expect("envelope is an object")
.contains_key("context_binding"),
"None must keep the historical shape"
);
}
#[tokio::test]
async fn test_generate_key_material() {
let key_256 = generate_key_material("AES_256").expect("Should generate AES_256 key");
@@ -493,6 +678,7 @@ mod tests {
},
created_at: Zoned::now(),
master_key_version: None,
context_binding: None,
};
// Test serialization
@@ -628,6 +814,7 @@ mod tests {
encryption_context: HashMap::new(),
created_at: Zoned::now(),
master_key_version: None,
context_binding: None,
};
let value = serde_json::to_value(&envelope).expect("serialize envelope");
@@ -647,6 +834,7 @@ mod tests {
encryption_context: HashMap::new(),
created_at: Zoned::now(),
master_key_version: Some(7),
context_binding: None,
};
let serialized = serde_json::to_vec(&envelope).expect("serialize envelope");
+4 -1
View File
@@ -17,4 +17,7 @@
pub mod ciphers;
pub mod dek;
pub use dek::{AesDekCrypto, DataKeyEnvelope, DekCrypto, context_aad, generate_key_material, is_data_key_envelope};
pub use dek::{
AesDekCrypto, CONTEXT_BINDING_AAD_V1, DataKeyEnvelope, DekCrypto, context_aad, desired_context_binding,
envelope_aad_write_enabled, envelope_wrap_aad, generate_key_material, is_data_key_envelope,
};
+6
View File
@@ -172,6 +172,12 @@ Nothing in this list requires a coordinated format cutover. The compatibility is
The one-way hazard is the rotation constraint above: an older binary reading a *new* envelope silently ignores the version field and decrypts with the current material.
### DEK envelope context binding (`RUSTFS_KMS_ENVELOPE_AAD`)
Historically the KV2 and Local backends sealed only the DEK plaintext; the `encryption_context` rode in the envelope unauthenticated and was checked by field comparison alone, so a party able to rewrite the stored envelope could rewrite the context to match whatever it presented. With `RUSTFS_KMS_ENVELOPE_AAD=true`, newly wrapped envelopes bind the canonical context bytes as AES-GCM additional data and carry `context_binding: 1`; rewriting the stored context, or stripping the flag, then fails authentication. (Static, Vault Transit and AWS already bound the context through their own mechanisms and are unaffected.)
Rollout constraint: **reading bound envelopes needs no switch, but a node that predates the field cannot open them** — its unwrap runs without the additional data and fails authentication. Enable the switch only after every node in the cluster runs a release that understands `context_binding`; the default stays off for one release for exactly this reason, mirroring the `RUSTFS_ENCRYPTION_FRAME_V2` rollout. Rewrap migrates existing envelopes: with the switch on, a rewrap sweep upgrades unbound envelopes to the bound format (converging to zero writes on re-run), and a bound envelope never regresses to the unbound shape whatever the switch says. An envelope carrying an unrecognized `context_binding` value is refused rather than decrypted without its binding.
### Guarantees that hold only once every node is upgraded
These are properties of the upgraded code, so a single node left behind removes them for the whole cluster.