diff --git a/crates/kms/Cargo.toml b/crates/kms/Cargo.toml index 57a60b1c6..f8491d5a2 100644 --- a/crates/kms/Cargo.toml +++ b/crates/kms/Cargo.toml @@ -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"] } diff --git a/crates/kms/src/backends/local.rs b/crates/kms/src/backends/local.rs index 349c9bb0d..b694f366a 100644 --- a/crates/kms/src/backends/local.rs +++ b/crates/kms/src/backends/local.rs @@ -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, Vec)> { + async fn encrypt_with_master_key(&self, key_id: &str, plaintext: &[u8], aad: &[u8]) -> Result<(Vec, Vec)> { // 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> { + async fn decrypt_with_master_key(&self, key_id: &str, ciphertext: &[u8], nonce: &[u8], aad: &[u8]) -> Result> { // 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; diff --git a/crates/kms/src/backends/static_kms.rs b/crates/kms/src/backends/static_kms.rs index 970d25da9..2f3560497 100644 --- a/crates/kms/src/backends/static_kms.rs +++ b/crates/kms/src/backends/static_kms.rs @@ -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)?; diff --git a/crates/kms/src/backends/vault.rs b/crates/kms/src/backends/vault.rs index 9e9ce6a8d..0a787631a 100644 --- a/crates/kms/src/backends/vault.rs +++ b/crates/kms/src/backends/vault.rs @@ -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 diff --git a/crates/kms/src/backends/vault_transit.rs b/crates/kms/src/backends/vault_transit.rs index e869b2689..8f9ec0991 100644 --- a/crates/kms/src/backends/vault_transit.rs +++ b/crates/kms/src/backends/vault_transit.rs @@ -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)?; diff --git a/crates/kms/src/config.rs b/crates/kms/src/config.rs index 1a4bde079..bb670609b 100644 --- a/crates/kms/src/config.rs +++ b/crates/kms/src/config.rs @@ -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"; diff --git a/crates/kms/src/encryption/dek.rs b/crates/kms/src/encryption/dek.rs index 2b411128d..81d31a4ca 100644 --- a/crates/kms/src/encryption/dek.rs +++ b/crates/kms/src/encryption/dek.rs @@ -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, + /// 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, +} + +/// `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> { + 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) -> Option { + 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 = 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 = 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, Vec)>; + /// + /// `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, Vec)>; /// 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>; + /// + /// `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>; /// 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, Vec)> { + async fn encrypt(&self, key_material: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<(Vec, Vec)> { 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> { + async fn decrypt(&self, key_material: &[u8], ciphertext: &[u8], nonce: &[u8], aad: &[u8]) -> Result> { 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"); diff --git a/crates/kms/src/encryption/mod.rs b/crates/kms/src/encryption/mod.rs index fff0073a2..67d14ead9 100644 --- a/crates/kms/src/encryption/mod.rs +++ b/crates/kms/src/encryption/mod.rs @@ -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, +}; diff --git a/docs/operations/kms-backend-security.md b/docs/operations/kms-backend-security.md index 5d1d4fa95..cdf275027 100644 --- a/docs/operations/kms-backend-security.md +++ b/docs/operations/kms-backend-security.md @@ -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.