diff --git a/crates/kms/src/backends/vault.rs b/crates/kms/src/backends/vault.rs index e719e1ed8..e7c12a32a 100644 --- a/crates/kms/src/backends/vault.rs +++ b/crates/kms/src/backends/vault.rs @@ -3571,4 +3571,226 @@ mod tests { "a successful read must not pay for the lost-baseline diagnosis" ); } + + /// The Vault-side state of one KV2 key: the top-level record plus the + /// immutable version records rotations froze. + /// + /// The scripted responder serves canned responses, so a multi-operation + /// scenario has to carry the state between operations itself. Rotations + /// fold what they *wrote* back into this state (see [`Self::apply_writes`]), + /// which is what makes the rotation regressions below real: the material a + /// later decrypt resolves is the material the rotation persisted, not a + /// fixture the test invented. + struct KeyState { + key_data: VaultKeyData, + version_records: Vec, + } + + impl KeyState { + /// A never-rotated key: no version records exist yet. + fn new(key_data: VaultKeyData) -> Self { + Self { + key_data, + version_records: Vec::new(), + } + } + + fn version_record(&self, version: u32) -> &VaultKeyVersionRecord { + self.version_records + .iter() + .find(|record| record.version == version) + .unwrap_or_else(|| panic!("no version record was frozen for version {version}")) + } + + /// The versions-directory listing; a key with no records has no + /// directory at all. + fn versions_listing(&self) -> ScriptedResponse { + if self.version_records.is_empty() { + return ScriptedResponse::error(404, "not found"); + } + let keys: Vec = self.version_records.iter().map(|record| record.version.to_string()).collect(); + ScriptedResponse::ok(serde_json::json!({ "keys": keys })) + } + + /// Fold the writes an operation made into the state, so the next + /// operation reads exactly what Vault would now hold. + fn apply_writes(&mut self, requests: &[String], bodies: &[String]) { + for (line, body) in requests.iter().zip(bodies) { + let Some(path) = line.strip_prefix("POST ") else { + continue; + }; + let data = parse_write_body(body)["data"].clone(); + if path.contains("/versions/") { + let record: VaultKeyVersionRecord = serde_json::from_value(data).expect("version record write body"); + self.version_records.retain(|existing| existing.version != record.version); + self.version_records.push(record); + } else { + self.key_data = serde_json::from_value(data).expect("key record write body"); + } + } + } + } + + /// Encrypt against a scripted Vault serving `state`. + async fn encrypt_scripted(state: &KeyState, plaintext: &[u8]) -> EncryptResponse { + let (_vault, client) = scripted_client(vec![ScriptedResponse::ok(kv2_read_data(&state.key_data))]).await; + client + .encrypt( + &EncryptRequest { + key_id: "wired-key".to_string(), + plaintext: plaintext.to_vec(), + encryption_context: HashMap::new(), + grant_tokens: Vec::new(), + }, + None, + ) + .await + .expect("encrypt must produce an envelope") + } + + /// Rotate against a scripted Vault seeded with `state`, and return the state + /// Vault holds afterwards. + /// + /// The first rotation freezes the baseline before creating the next version + /// (four writes); later rotations skip that step (two writes). + async fn rotate_scripted(state: &KeyState) -> KeyState { + let writes = if state.key_data.baseline_version.is_none() { 4 } else { 2 }; + let mut responses = vec![ + ScriptedResponse::ok(kv2_metadata_read_data(1)), + ScriptedResponse::ok(kv2_read_data(&state.key_data)), + state.versions_listing(), + ]; + responses.extend((0..writes).map(|_| ScriptedResponse::ok(kv2_write_ack()))); + let (vault, client) = scripted_client(responses).await; + + let rotated = client.rotate_key("wired-key", None).await.expect("rotation must commit"); + assert_eq!(rotated.version, state.key_data.version + 1, "a rotation must advance the version"); + + let mut next = KeyState { + key_data: state.key_data.clone(), + version_records: state.version_records.clone(), + }; + next.apply_writes(&vault.requests(), &vault.request_bodies()); + next + } + + /// Decrypt against a scripted Vault serving `state`, scripting the + /// version-record read the envelope's own version calls for. Returns the + /// plaintext together with the requests the decrypt made. + async fn decrypt_scripted(state: &KeyState, ciphertext: &[u8]) -> (Vec, Vec) { + let envelope: DataKeyEnvelope = serde_json::from_slice(ciphertext).expect("envelope must parse"); + let mut responses = vec![ScriptedResponse::ok(kv2_read_data(&state.key_data))]; + if let Some(version) = envelope.master_key_version + && version != state.key_data.version + { + responses.push(ScriptedResponse::ok(kv2_read_version_record_data(state.version_record(version)))); + } + let (vault, client) = scripted_client(responses).await; + + let plaintext = client + .decrypt( + &DecryptRequest { + ciphertext: ciphertext.to_vec(), + encryption_context: HashMap::new(), + grant_tokens: Vec::new(), + }, + None, + ) + .await + .expect("the envelope must decrypt against the rotated key"); + (plaintext, vault.requests()) + } + + /// The forward half of the rotation contract: data written before a rotation + /// stays readable after it, with no live Vault involved. + /// + /// The negative half (a regressed pointer must fail closed) is covered by + /// `wired_decrypt_fails_closed_when_current_version_regressed`; this pins the + /// path that must keep working, which the fail-closed guards could otherwise + /// tighten into a rotation that orphans every existing object. + #[tokio::test] + async fn wired_kv2_envelope_from_before_rotation_still_decrypts() { + const PLAINTEXT: &[u8] = b"written-before-the-rotation"; + let state_v1 = KeyState::new(healthy_key_data()); + + let encrypted_v1 = encrypt_scripted(&state_v1, PLAINTEXT).await; + let envelope_v1: DataKeyEnvelope = serde_json::from_slice(&encrypted_v1.ciphertext).expect("envelope must parse"); + assert_eq!(envelope_v1.master_key_version, Some(1)); + + let state_v2 = rotate_scripted(&state_v1).await; + assert_eq!(state_v2.key_data.version, 2); + assert_eq!(state_v2.key_data.baseline_version, Some(1)); + assert_ne!( + state_v2.key_data.encrypted_key_material, state_v1.key_data.encrypted_key_material, + "the rotation must have replaced the current material, or the decrypt below proves nothing" + ); + + let (plaintext, requests) = decrypt_scripted(&state_v2, &encrypted_v1.ciphertext).await; + assert_eq!( + plaintext, PLAINTEXT, + "the pre-rotation envelope must yield its original plaintext, not merely avoid an error" + ); + assert_eq!( + requests, + vec![ + "GET /v1/secret/data/rustfs/kms/keys/wired-key".to_string(), + "GET /v1/secret/data/rustfs/kms/keys/wired-key/versions/1".to_string(), + ], + "the old envelope must resolve through the immutable v1 record" + ); + + // The rotation is not cosmetic: new writes go to the rotated version and + // still round-trip, so both generations are live at once. + let encrypted_v2 = encrypt_scripted(&state_v2, b"written-after-the-rotation").await; + let envelope_v2: DataKeyEnvelope = serde_json::from_slice(&encrypted_v2.ciphertext).expect("envelope must parse"); + assert_eq!(envelope_v2.master_key_version, Some(2), "new envelopes must carry the rotated version"); + assert_eq!(encrypted_v2.key_version, 2); + let (plaintext_v2, requests_v2) = decrypt_scripted(&state_v2, &encrypted_v2.ciphertext).await; + assert_eq!(plaintext_v2, b"written-after-the-rotation".to_vec()); + assert_eq!( + requests_v2.len(), + 1, + "an envelope on the current version must not read a version record: {requests_v2:?}" + ); + } + + /// Old envelopes must survive more than one generation: the baseline is + /// frozen once and every intermediate version keeps its own record, so both + /// a pre-rotation envelope and one written between the two rotations still + /// decrypt after the second. + #[tokio::test] + async fn wired_kv2_envelopes_survive_consecutive_rotations() { + let state_v1 = KeyState::new(healthy_key_data()); + let encrypted_v1 = encrypt_scripted(&state_v1, b"generation-1").await; + + let state_v2 = rotate_scripted(&state_v1).await; + let encrypted_v2 = encrypt_scripted(&state_v2, b"generation-2").await; + assert_eq!(encrypted_v2.key_version, 2); + + let state_v3 = rotate_scripted(&state_v2).await; + assert_eq!(state_v3.key_data.version, 3); + assert_eq!( + state_v3.key_data.baseline_version, + Some(1), + "the baseline is frozen once and carried through later rotations" + ); + let mut recorded: Vec = state_v3.version_records.iter().map(|record| record.version).collect(); + recorded.sort_unstable(); + assert_eq!(recorded, vec![1, 2, 3], "every version that ever wrapped a DEK must keep a record"); + + for (ciphertext, expected, version) in [ + (&encrypted_v1.ciphertext, b"generation-1".as_slice(), 1u32), + (&encrypted_v2.ciphertext, b"generation-2".as_slice(), 2), + ] { + let (plaintext, requests) = decrypt_scripted(&state_v3, ciphertext).await; + assert_eq!( + plaintext, expected, + "an envelope from version {version} must survive two rotations intact" + ); + assert!( + requests[1].ends_with(&format!("/versions/{version}")), + "the decrypt must resolve the version that wrapped it: {requests:?}" + ); + } + } } diff --git a/crates/kms/src/backends/vault_transit.rs b/crates/kms/src/backends/vault_transit.rs index b04e18dbe..096913400 100644 --- a/crates/kms/src/backends/vault_transit.rs +++ b/crates/kms/src/backends/vault_transit.rs @@ -2258,4 +2258,89 @@ mod tests { "the sweep must not touch the transit key after backing off: {requests:?}" ); } + + /// The forward half of the rotation contract on the Transit path: a data key + /// generated before a rotation must still be decryptable after it. + /// + /// Transit key material never leaves Vault, so an offline responder cannot + /// prove the cryptographic round trip — `test_transit_old_ciphertext_decrypts_after_rotate` + /// keeps that against a live Vault. What this pins is the wiring the client + /// owns and could regress on its own: the historical `vault:v1:` ciphertext + /// is forwarded to Vault byte for byte after the rotation bumped the + /// recorded version, and nothing on the decrypt path gates on that version. + #[tokio::test] + async fn wired_transit_pre_rotation_data_key_is_decrypted_unchanged() { + const CIPHERTEXT_V1: &str = "vault:v1:scripted-pre-rotation"; + const RECOVERED_DEK: [u8; 32] = [0x37u8; 32]; + let metadata = TransitKeyMetadata::from_create_request(&CreateKeyRequest::default()); + let (vault, client) = scripted_client(vec![ + // generate_data_key: state gate reads the metadata record, then the + // transit encrypt returns first-version ciphertext. + ScriptedResponse::ok(metadata_read_data(&metadata)), + ScriptedResponse::ok(serde_json::json!({ "ciphertext": CIPHERTEXT_V1 })), + // rotate: the state gate hits the metadata cache, the rotation + // commits, and the versioned read+write records the version bump. + ScriptedResponse::ok(serde_json::json!({})), + ScriptedResponse::ok(kv2_metadata_read_data(1)), + ScriptedResponse::ok(metadata_read_data(&metadata)), + ScriptedResponse::ok(kv2_write_ack()), + // decrypt of the pre-rotation envelope; Vault owns the transit + // crypto, so the recovered material is the responder's to hand back. + ScriptedResponse::ok(serde_json::json!({ "plaintext": BASE64.encode(RECOVERED_DEK) })), + ]) + .await; + + let data_key = client + .generate_data_key( + &GenerateKeyRequest { + master_key_id: "wired-key".to_string(), + key_spec: "AES_256".to_string(), + key_length: Some(32), + encryption_context: HashMap::new(), + grant_tokens: Vec::new(), + }, + None, + ) + .await + .expect("generate_data_key must produce an envelope"); + let envelope: DataKeyEnvelope = serde_json::from_slice(&data_key.ciphertext).expect("envelope must parse"); + assert_eq!(envelope.encrypted_key, CIPHERTEXT_V1.as_bytes()); + + let rotated = client.rotate_key("wired-key", None).await.expect("rotation must commit"); + assert_eq!(rotated.version, 2, "the rotation must record the version bump"); + + let plaintext = client + .decrypt( + &DecryptRequest { + ciphertext: data_key.ciphertext.clone(), + encryption_context: HashMap::new(), + grant_tokens: Vec::new(), + }, + None, + ) + .await + .expect("a pre-rotation data key must stay decryptable"); + assert_eq!( + plaintext, + RECOVERED_DEK.to_vec(), + "the decrypt must hand back the recovered material, not merely avoid an error" + ); + + let requests = vault.requests(); + assert_eq!(requests.len(), 7, "{requests:?}"); + assert_eq!(requests[6], "POST /v1/transit/decrypt/wired-key", "{requests:?}"); + + // The version the rotation recorded, and the ciphertext the decrypt sent: + // the client must forward the historical version verbatim instead of + // re-stamping it to (or pinning the request at) the current one. + let bodies = vault.request_bodies(); + let recorded: serde_json::Value = serde_json::from_str(&bodies[5]).expect("metadata write body must be JSON"); + assert_eq!(recorded["data"]["current_version"], serde_json::json!(2), "{recorded}"); + let decrypt_body: serde_json::Value = serde_json::from_str(&bodies[6]).expect("decrypt body must be JSON"); + assert_eq!( + decrypt_body["ciphertext"], + serde_json::json!(CIPHERTEXT_V1), + "the pre-rotation ciphertext must reach Vault unchanged: {decrypt_body}" + ); + } }