fix(kms): harden key list contract

This commit is contained in:
overtrue
2026-08-02 18:36:07 +08:00
parent f440a14e53
commit 77b712dcb6
6 changed files with 182 additions and 13 deletions
+20
View File
@@ -3101,6 +3101,26 @@ mod tests {
);
}
#[tokio::test]
async fn list_keys_fails_closed_on_a_corrupt_record() {
let (client, _temp_dir) = create_test_client().await;
client.create_key("corrupt", "AES_256", None).await.expect("create key");
let key_path = client.master_key_path("corrupt").expect("valid key id");
fs::write(&key_path, b"{\"key_id\":\"corrupt\"")
.await
.expect("write corrupt record");
let error = client
.list_keys(&ListKeysRequest::default(), None)
.await
.expect_err("a corrupt record must not disappear from a listing");
assert!(
matches!(error, KmsError::MaterialCorrupt { key_id, .. } if key_id == "corrupt"),
"got {error:?}"
);
}
#[tokio::test]
async fn missing_salt_with_pre_marker_key_records_still_initializes() {
let (dev_client, temp_dir) = create_dev_mode_client().await;
+17 -2
View File
@@ -160,7 +160,7 @@ pub(crate) fn ensure_rewrap_context_matches(
}
/// Page size used when a [`ListKeysRequest`] does not ask for one.
pub(crate) const DEFAULT_LIST_KEYS_PAGE_SIZE: u32 = 100;
pub(crate) const DEFAULT_LIST_KEYS_PAGE_SIZE: u32 = DEFAULT_LIST_KEYS_LIMIT;
/// One page of a key set the backend has to slice itself.
pub(crate) struct KeyPage<'a, T> {
@@ -231,7 +231,7 @@ pub(crate) fn paginate_keys<'a, T>(sorted: &'a [T], request: &ListKeysRequest, k
pub(crate) fn list_keys_page_size(limit: Option<u32>) -> Option<usize> {
match limit.unwrap_or(DEFAULT_LIST_KEYS_PAGE_SIZE) {
0 => None,
size => Some(size as usize),
size => Some(size.min(MAX_LIST_KEYS_LIMIT) as usize),
}
}
@@ -860,6 +860,21 @@ mod tests {
assert_eq!(page_of(&keys, Some(u32::MAX), Some("key-01")), (vec![keys[2].clone()], None, false));
}
#[test]
fn list_limit_is_bounded_at_the_contract_maximum() {
assert_eq!(list_keys_page_size(Some(MAX_LIST_KEYS_LIMIT)), Some(MAX_LIST_KEYS_LIMIT as usize));
assert_eq!(
list_keys_page_size(Some(MAX_LIST_KEYS_LIMIT + 1)),
Some(MAX_LIST_KEYS_LIMIT as usize),
"one past the maximum must not expand a backend page"
);
assert_eq!(
list_keys_page_size(Some(u32::MAX)),
Some(MAX_LIST_KEYS_LIMIT as usize),
"the largest wire value must use the same bounded page"
);
}
/// The cursor is an identifier, so a marker naming a key that no longer
/// exists resumes after where it would have been instead of restarting.
#[test]
+26 -2
View File
@@ -59,6 +59,12 @@ pub struct StaticKmsBackend {
key_id: String,
/// The raw 32-byte AES-256 key material (zeroed on drop).
key: Zeroizing<[u8; KEY_SIZE]>,
/// The time this configured key became visible to the backend.
///
/// Static KMS has no persisted key record, so this startup timestamp is
/// the only stable creation date it can report across list and describe
/// calls. It must not be regenerated for every response.
created_at: Zoned,
}
impl StaticKmsBackend {
@@ -82,6 +88,7 @@ impl StaticKmsBackend {
Ok(Self {
key_id: static_config.key_id.clone(),
key: key.into(),
created_at: Zoned::now(),
})
}
@@ -92,7 +99,7 @@ impl StaticKmsBackend {
key_state: KeyState::Enabled,
key_usage: KeyUsage::EncryptDecrypt,
description: Some("Static single-key KMS backend".to_string()),
creation_date: Zoned::now(),
creation_date: self.created_at.clone(),
deletion_date: None,
origin: "EXTERNAL".to_string(),
key_manager: "STATIC".to_string(),
@@ -281,7 +288,7 @@ impl StaticKmsBackend {
version: 1,
metadata: HashMap::new(),
tags: HashMap::new(),
created_at: Zoned::now(),
created_at: self.created_at.clone(),
rotated_at: None,
created_by: None,
};
@@ -677,6 +684,23 @@ mod tests {
assert!(!response.truncated);
}
#[tokio::test]
async fn list_key_creation_date_is_stable_for_the_backend_lifetime() {
let (backend, key_id, _key) = create_test_backend().await;
let first = backend
.list_configured_key(&ListKeysRequest::default())
.expect("first list should succeed");
tokio::task::yield_now().await;
let second = backend
.list_configured_key(&ListKeysRequest::default())
.expect("second list should succeed");
let described = backend.configured_key_info(&key_id).expect("describe should succeed");
assert_eq!(first.keys[0].created_at, second.keys[0].created_at);
assert_eq!(first.keys[0].created_at, described.created_at);
}
/// A zero limit means zero keys, even for a backend whose whole key set is
/// a single configured key.
#[tokio::test]
+33 -2
View File
@@ -1213,8 +1213,13 @@ impl VaultKmsClient {
// A key that disappeared between the listing and the read is
// dropped from the page rather than failing it; the cursor comes
// from the identifier list, so the listing still advances past it.
let Ok(key_info) = self.describe_key(key_id, None).await else {
continue;
// Any other read error means the record is present but this build
// cannot interpret it. Hiding that entry would make the listing
// claim a complete key set while silently omitting corrupt data.
let key_info = match self.describe_key(key_id, None).await {
Ok(key_info) => key_info,
Err(KmsError::KeyNotFound { .. }) => continue,
Err(error) => return Err(error),
};
if request
.status_filter
@@ -1955,6 +1960,32 @@ mod tests {
);
}
#[tokio::test]
async fn list_keys_fails_closed_on_an_unreadable_kv2_record() {
let malformed_record = serde_json::json!({
"data": {"algorithm": "AES_256"},
"metadata": {
"created_time": "2026-01-01T00:00:00Z",
"deletion_time": "",
"custom_metadata": null,
"destroyed": false,
"version": 1
}
});
let (vault, client) = scripted_client(vec![
ScriptedResponse::ok(serde_json::json!({ "keys": ["poisoned"] })),
ScriptedResponse::ok(malformed_record),
])
.await;
let error = client
.list_keys(&ListKeysRequest::default(), None)
.await
.expect_err("a malformed KV2 record must not disappear from a listing");
assert!(matches!(error, KmsError::BackendError { .. }), "got {error:?}");
assert_eq!(vault.requests().len(), 2, "listing must read the listed record before failing");
}
#[tokio::test]
async fn wired_read_retries_transient_status_then_succeeds() {
let (vault, client) = scripted_client(vec![
+10 -2
View File
@@ -415,7 +415,15 @@ impl DecryptRequest {
}
}
/// Request to list keys
/// Default page size used when a request omits `limit`.
pub const DEFAULT_LIST_KEYS_LIMIT: u32 = 100;
/// Maximum page size accepted by KMS list implementations.
pub const MAX_LIST_KEYS_LIMIT: u32 = 1000;
/// Request to list keys.
///
/// List pages default to [`DEFAULT_LIST_KEYS_LIMIT`] entries and are capped at
/// [`MAX_LIST_KEYS_LIMIT`]. A zero limit is a valid request for an empty page.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListKeysRequest {
/// Maximum number of keys to return
@@ -431,7 +439,7 @@ pub struct ListKeysRequest {
impl Default for ListKeysRequest {
fn default() -> Self {
Self {
limit: Some(100),
limit: Some(DEFAULT_LIST_KEYS_LIMIT),
marker: None,
usage_filter: None,
status_filter: None,