test(kms): cover decryption of pre-rotation envelopes offline (#5591)

* test(kms): cover KV2 decrypt of pre-rotation envelopes offline

The forward half of the rotation contract - an envelope written before a
rotation still decrypts after it - was only exercised by the #[ignore]
live-Vault tests, so CI never verified it. The offline layer only had the
negative cases (a regressed version pointer must fail closed).

Drive encrypt -> rotate -> decrypt over the scripted Vault responder,
folding what each rotation writes back into the served state so the
material the decrypt resolves is the material the rotation persisted.
Also cover two consecutive rotations and pin that new envelopes carry the
rotated version.

* test(kms): cover Transit decrypt of pre-rotation data keys offline

Vault owns the transit crypto, so the offline responder cannot prove the
round trip - that stays in the #[ignore] live test. What it can pin is the
client-side wiring: after a rotation records the version bump, decrypting
a data key generated before it must forward the historical vault:v1:
ciphertext to Vault byte for byte and return the material Vault hands
back.
This commit is contained in:
Zhengchao An
2026-08-02 03:50:12 +08:00
committed by GitHub
parent f1a85c6a93
commit 105af08a10
2 changed files with 307 additions and 0 deletions
+222
View File
@@ -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<VaultKeyVersionRecord>,
}
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<String> = 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<u8>, Vec<String>) {
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<u32> = 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:?}"
);
}
}
}
+85
View File
@@ -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}"
);
}
}