Files
rustfs/crates/kms/src/backends/mod.rs
T

897 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! KMS backend implementations
use crate::error::{KmsError, Result};
use crate::types::*;
use async_trait::async_trait;
use jiff::Zoned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub mod aws;
#[cfg(test)]
mod contract_tests;
pub mod local;
#[cfg(test)]
pub(crate) mod scripted_vault;
pub mod static_kms;
pub mod vault;
pub(crate) mod vault_credentials;
pub mod vault_transit;
/// Operations whose availability depends on the key's lifecycle state.
///
/// Decryption is deliberately absent: RustFS allows decryption with
/// `Disabled` and `PendingDeletion` keys — an explicit deviation from AWS
/// KMS — because rejecting it would break reads of every object encrypted
/// under a key the moment it is disabled. Deletion cancellation is also
/// absent: it is valid exactly when the key is `PendingDeletion`, which call
/// sites enforce directly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StateGatedOperation {
Encrypt,
GenerateDataKey,
Rotate,
Enable,
Disable,
ScheduleDeletion,
}
impl StateGatedOperation {
fn describe(self) -> &'static str {
match self {
Self::Encrypt => "encryption",
Self::GenerateDataKey => "data key generation",
Self::Rotate => "rotation",
Self::Enable => "enabling",
Self::Disable => "disabling",
Self::ScheduleDeletion => "deletion scheduling",
}
}
}
/// Enforce the shared key state × operation matrix.
///
/// - `Enabled`: every operation is allowed.
/// - `Disabled`: enabling, disabling (idempotent) and deletion scheduling are
/// allowed; encryption, data key generation and rotation are rejected.
/// - `PendingDeletion`: every state-gated operation is rejected, including a
/// repeated deletion schedule; only cancellation and decryption proceed.
/// - `PendingImport`/`Unavailable`: the key is not usable and is reported as
/// not found.
pub(crate) fn ensure_key_state_permits(key_id: &str, state: &KeyState, operation: StateGatedOperation) -> Result<()> {
match state {
KeyState::Enabled => Ok(()),
KeyState::Disabled => match operation {
StateGatedOperation::Enable | StateGatedOperation::Disable | StateGatedOperation::ScheduleDeletion => Ok(()),
StateGatedOperation::Encrypt | StateGatedOperation::GenerateDataKey | StateGatedOperation::Rotate => Err(
KmsError::invalid_key_state(format!("Key {key_id} is disabled: {} is not allowed", operation.describe())),
),
},
KeyState::PendingDeletion => Err(KmsError::invalid_key_state(format!(
"Key {key_id} is pending deletion: {} is not allowed",
operation.describe()
))),
KeyState::PendingImport | KeyState::Unavailable => Err(KmsError::key_not_found(key_id)),
}
}
/// [`ensure_key_state_permits`] for backends that persist [`KeyStatus`].
pub(crate) fn ensure_key_status_permits(key_id: &str, status: &KeyStatus, operation: StateGatedOperation) -> Result<()> {
let state = match status {
KeyStatus::Active => KeyState::Enabled,
KeyStatus::Disabled => KeyState::Disabled,
KeyStatus::PendingDeletion => KeyState::PendingDeletion,
KeyStatus::Deleted => KeyState::Unavailable,
};
ensure_key_state_permits(key_id, &state, operation)
}
/// Tag key that carries the key's identity rather than user metadata.
///
/// The key-creation path lifts `name` out of the caller's tag map and uses it
/// as the key id, so a key's `name` tag and its id are the same string.
/// Rewriting or dropping it after creation would leave the key addressable
/// under an id its own metadata no longer states. Metadata updates therefore
/// reject it; only creation may set it.
pub const RESERVED_KEY_NAME_TAG: &str = "name";
/// Reject a metadata update that would rewrite or remove
/// [`RESERVED_KEY_NAME_TAG`].
///
/// Enforced by every backend that implements tag updates, so no call path —
/// including a direct [`KmsBackend`] user that bypasses the manager — can
/// detach a key from its identity.
pub(crate) fn ensure_tag_keys_are_mutable<'a>(tag_keys: impl IntoIterator<Item = &'a str>) -> Result<()> {
for tag_key in tag_keys {
if tag_key == RESERVED_KEY_NAME_TAG {
return Err(KmsError::invalid_parameter(format!(
"Tag '{RESERVED_KEY_NAME_TAG}' identifies the key and cannot be updated or removed"
)));
}
}
Ok(())
}
/// Reject a rewrap whose caller cannot reproduce the envelope's encryption
/// context.
///
/// Deliberately the same rule the decrypt paths apply — every context entry the
/// envelope carries must be matched, and an entirely empty request context is
/// accepted for envelopes written before contexts were recorded. Rewrap must
/// never be *stricter* than decrypt: a retirement sweep has to be able to
/// rewrap every envelope that still reads, or the old master key version it is
/// trying to retire stays referenced forever by objects that are perfectly
/// readable.
///
/// It must not be *laxer* either. The context binds an envelope to one
/// bucket/object pair, and a rewrap that skipped the check would let a caller
/// launder an envelope it has no claim on into a freshly wrapped one.
pub(crate) fn ensure_rewrap_context_matches(
envelope_context: &HashMap<String, String>,
request_context: &HashMap<String, String>,
) -> Result<()> {
for (key, expected_value) in envelope_context {
match request_context.get(key) {
Some(actual_value) if actual_value == expected_value => {}
Some(actual_value) => {
return Err(KmsError::context_mismatch(format!(
"Context mismatch for key '{key}': expected '{expected_value}', got '{actual_value}'"
)));
}
None if request_context.is_empty() => {}
None => return Err(KmsError::context_mismatch(format!("Missing context key '{key}'"))),
}
}
Ok(())
}
/// Page size used when a [`ListKeysRequest`] does not ask for one.
pub(crate) const DEFAULT_LIST_KEYS_PAGE_SIZE: u32 = 100;
/// One page of a key set the backend has to slice itself.
pub(crate) struct KeyPage<'a, T> {
/// The identifiers this page covers, in listing order.
pub(crate) items: &'a [T],
/// Where the next page resumes; `None` when this page is the last one.
pub(crate) next_marker: Option<String>,
/// Whether keys remain beyond this page.
pub(crate) truncated: bool,
}
/// Cut the page `request` asks for out of `sorted`.
///
/// `sorted` must be ordered by the identifier `key_id_of` returns, and the
/// marker is an *exclusive lower bound* on that identifier rather than an index
/// into the sequence. That is what makes paging survive concurrent mutation:
/// the next page resumes at the first identifier greater than the marker, so a
/// key added or removed elsewhere in the ordering — including the marker key
/// itself, which the deletion sweep routinely destroys — cannot make the
/// listing skip keys or restart from the beginning.
///
/// A `limit` of zero is honoured as written: the caller asked for no keys and
/// gets an empty, non-truncated page (see [`list_keys_page_size`]).
///
/// Filters are applied by the caller to `items` after this slice, so a filtered
/// page can be shorter than `limit` — and even empty — while more keys remain.
/// Callers must page until `truncated` is false rather than until a page comes
/// back short.
pub(crate) fn paginate_keys<'a, T>(sorted: &'a [T], request: &ListKeysRequest, key_id_of: impl Fn(&T) -> &str) -> KeyPage<'a, T> {
let Some(limit) = list_keys_page_size(request.limit) else {
return KeyPage {
items: &[],
next_marker: None,
truncated: false,
};
};
let start = match request.marker.as_deref() {
Some(marker) => sorted.partition_point(|item| key_id_of(item) <= marker),
None => 0,
};
// `partition_point` never exceeds the length, so both bounds stay in range
// however large `limit` is.
let end = start.saturating_add(limit).min(sorted.len());
let items = &sorted[start..end];
let truncated = end < sorted.len();
KeyPage {
items,
// Resuming from the last identifier on the page, not from an index,
// keeps the cursor meaningful after the key it names disappears.
next_marker: if truncated {
items.last().map(|item| key_id_of(item).to_string())
} else {
None
},
truncated,
}
}
/// Resolve the page size of a [`ListKeysRequest`]; `None` means the caller
/// asked for no keys at all.
///
/// `Some(0)` is a well-formed request for an empty page — the reading rustfs
/// already gives `max-keys=0` on the S3 listing path — not a malformed one and
/// not an omitted value. Rounding it up to a default would hand back a full
/// page of keys to a caller that explicitly asked for none.
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),
}
}
/// The response to a request for zero keys.
///
/// `truncated` is false even when keys exist: a zero-length page carries no
/// identifier to resume from, so claiming more results would hand back a cursor
/// the caller can never advance and turn a `while truncated` loop into a
/// non-terminating one.
pub(crate) fn empty_key_page() -> ListKeysResponse {
ListKeysResponse {
keys: Vec::new(),
next_marker: None,
truncated: false,
}
}
/// Simplified KMS backend interface for manager
#[async_trait]
pub trait KmsBackend: Send + Sync {
/// Create a new master key
async fn create_key(&self, request: CreateKeyRequest) -> Result<CreateKeyResponse>;
/// Encrypt data
async fn encrypt(&self, request: EncryptRequest) -> Result<EncryptResponse>;
/// Decrypt data
async fn decrypt(&self, request: DecryptRequest) -> Result<DecryptResponse>;
/// Generate a data key
async fn generate_data_key(&self, request: GenerateDataKeyRequest) -> Result<GenerateDataKeyResponse>;
/// Describe a key
async fn describe_key(&self, request: DescribeKeyRequest) -> Result<DescribeKeyResponse>;
/// List keys.
///
/// `status_filter` and `usage_filter` narrow a page after it has been cut,
/// so a filtered page can be shorter than the requested limit — and even
/// empty — while keys remain: a caller must page until `truncated` is false
/// rather than until a page comes back short. Every backend applies both
/// filters; a backend that cannot answer a filter must fail rather than
/// return the unfiltered set.
///
/// Backends that slice their own key set do so with [`paginate_keys`],
/// which fixes the ordering and the marker semantics; a backend paging
/// through a remote API passes that API's own cursor through instead.
async fn list_keys(&self, request: ListKeysRequest) -> Result<ListKeysResponse>;
/// Delete a key
async fn delete_key(&self, request: DeleteKeyRequest) -> Result<DeleteKeyResponse>;
/// Cancel key deletion
async fn cancel_key_deletion(&self, request: CancelKeyDeletionRequest) -> Result<CancelKeyDeletionResponse>;
/// Enable a disabled key so it can be used for cryptographic operations
/// again.
///
/// Backends that advertise [`BackendCapabilities::enable_disable`] must
/// override this method; the default rejects the operation.
async fn enable_key(&self, _key_id: &str) -> Result<()> {
Err(KmsError::unsupported_capability("backend without enable/disable support", "enable_key"))
}
/// Disable a key, rejecting new cryptographic use while existing data
/// remains decryptable.
///
/// Backends that advertise [`BackendCapabilities::enable_disable`] must
/// override this method; the default rejects the operation.
async fn disable_key(&self, _key_id: &str) -> Result<()> {
Err(KmsError::unsupported_capability("backend without enable/disable support", "disable_key"))
}
/// Rotate a key to a new version while prior versions remain available
/// for decryption.
///
/// Only backends that advertise [`BackendCapabilities::rotate`] (that is,
/// backends with retained version history) may override this method; the
/// default rejects the operation.
async fn rotate_key(&self, _key_id: &str) -> Result<()> {
Err(KmsError::unsupported_capability("backend without rotation support", "rotate_key"))
}
/// Re-wrap an existing data key envelope under the key's current version,
/// returning an envelope that protects the same data key.
///
/// This is the primitive that makes retiring an old master key version
/// possible at all: until every envelope a version wrapped has been moved
/// onto the current version, destroying that version's material orphans
/// every object whose data key it wrapped. Rotation alone does not shrink
/// the blast radius of a leaked master key version, because envelopes
/// written before it stay decryptable under the leaked material forever.
///
/// Contract for implementations:
///
/// - The plaintext data key must not be persisted, logged, or returned. It
/// is either never materialized at all (backends with a native rewrap) or
/// held only for the length of the re-wrap.
/// - The destination version is always the key's *current* version. Wrapping
/// to any older version would create objects that nodes which resolve
/// envelopes to the current version cannot read.
/// - Everything except the wrapping is carried over verbatim, encryption
/// context included, so the result is the same data key rewrapped and not
/// a new one. An envelope must never be moved between objects.
/// - Idempotence: an envelope already wrapped by the current version comes
/// back byte for byte with [`RewrapDataKeyResponse::rewrapped`] false, so
/// re-running a sweep converges and performs no metadata writes.
///
/// Only backends that advertise [`BackendCapabilities::rewrap`] may override
/// this method; the default rejects the operation. A backend without
/// retained version history has nothing to rewrap *to*, so it reports the
/// capability gap rather than performing a re-wrap that changes no version
/// and buys no security.
async fn rewrap_data_key(&self, _request: RewrapDataKeyRequest) -> Result<RewrapDataKeyResponse> {
Err(KmsError::unsupported_capability(
"backend without versioned key material to rewrap onto",
"rewrap_data_key",
))
}
/// Report which master key version wraps an existing data key envelope, and
/// whether that is the key's current version.
///
/// The read-only counterpart of [`Self::rewrap_data_key`], and the only
/// supported way to ask the question: each rotating backend records the
/// version somewhere else, so every caller that parsed envelopes itself
/// would carry its own copy of that knowledge outside the KMS boundary.
///
/// [`DescribeDataKeyWrappingResponse::is_current`] must be the exact
/// negation of what [`RewrapDataKeyResponse::rewrapped`] would report for
/// the same envelope. The two answers drive a single loop — scan to find
/// work, rewrap to do it, scan again to prove it is done — and a
/// disagreement would make that loop either never terminate or declare
/// success while envelopes still reference a version somebody is about to
/// destroy.
///
/// Deliberately not gated on key state: an inventory has to be able to
/// count envelopes under keys that are disabled or already scheduled for
/// deletion, which are precisely the keys whose retirement is in question.
///
/// Gated by the same [`BackendCapabilities::rewrap`] flag, because a backend
/// that cannot rewrap has no version to report progress against.
async fn describe_data_key_wrapping(
&self,
_request: DescribeDataKeyWrappingRequest,
) -> Result<DescribeDataKeyWrappingResponse> {
Err(KmsError::unsupported_capability(
"backend without versioned key material to rewrap onto",
"describe_data_key_wrapping",
))
}
/// Replace a key's free-form description; `None` clears it.
///
/// Backends that advertise [`BackendCapabilities::update_key_metadata`]
/// must override this method; the default rejects the operation.
async fn update_key_description(&self, _key_id: &str, _description: Option<&str>) -> Result<()> {
Err(KmsError::unsupported_capability(
"backend without key metadata updates",
"update_key_description",
))
}
/// Add or overwrite the given tags, leaving every other tag untouched.
///
/// Implementations must run [`ensure_tag_keys_are_mutable`] before
/// persisting anything. Backends that advertise
/// [`BackendCapabilities::update_key_metadata`] must override this method;
/// the default rejects the operation.
async fn tag_key(&self, _key_id: &str, _tags: &HashMap<String, String>) -> Result<()> {
Err(KmsError::unsupported_capability("backend without key metadata updates", "tag_key"))
}
/// Remove the given tags.
///
/// Tags that are not set are ignored, so repeating the call is a no-op
/// rather than an error. Implementations must run
/// [`ensure_tag_keys_are_mutable`] before persisting anything. Backends
/// that advertise [`BackendCapabilities::update_key_metadata`] must
/// override this method; the default rejects the operation.
async fn untag_key(&self, _key_id: &str, _tag_keys: &[String]) -> Result<()> {
Err(KmsError::unsupported_capability("backend without key metadata updates", "untag_key"))
}
/// Health check
async fn health_check(&self) -> Result<bool>;
/// Report which operations this backend actually supports.
///
/// The default is conservative: only the operations every backend is
/// required to implement by this trait are advertised. Optional lifecycle
/// operations (rotation, enable/disable, deletion scheduling, ...) must be
/// opted in by overriding this method.
fn capabilities(&self) -> BackendCapabilities {
BackendCapabilities::minimal()
}
/// Remove a key whose scheduled deletion deadline has passed.
///
/// Used by the background deletion worker. Implementations must re-check
/// state and deadline under their own write synchronization so that a
/// concurrent cancellation observed after the caller's inspection wins
/// ([`ExpiredKeyRemoval::StateChanged`]), must write a tombstone (a
/// `Deleted`/`Unavailable` record) before destroying material so a crashed
/// removal can simply be re-run, and must treat an already-removed key as
/// success so the operation stays idempotent across restarts and nodes.
///
/// The default rejects the operation for backends without deletion
/// support.
async fn remove_expired_key(&self, _key_id: &str, _now: &Zoned) -> Result<ExpiredKeyRemoval> {
Err(KmsError::unsupported_capability("backend without deletion support", "remove_expired_key"))
}
/// The running client to export a full-material backup bundle from.
///
/// Only the Local backend owns key material RustFS is allowed to export in
/// full (see [`crate::backup::BackupResponsibility`]); every other backend
/// keeps its cryptographic root outside RustFS and returns `None` here.
///
/// The export must run against the *running* client so that its fence
/// actually blocks concurrent create/delete work — a second client opened
/// on the same key directory would fence nothing.
fn local_backup_client(&self) -> Option<&local::LocalKmsClient> {
None
}
}
/// Outcome of [`KmsBackend::remove_expired_key`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExpiredKeyRemoval {
/// The key's record and material were removed, or were already gone.
Removed,
/// The key is no longer pending deletion (for example the deletion was
/// cancelled after the caller inspected it); nothing was removed.
StateChanged,
/// The key is pending deletion but its deadline has not passed, or it has
/// no persisted deadline (legacy record) and is never auto-removed.
NotExpired,
}
/// Set of operations a KMS backend supports.
///
/// Reported by [`KmsBackend::capabilities`] so callers (manager, admin API)
/// can discover what the active backend can do without probing individual
/// operations. Marked `#[non_exhaustive]` so new capability flags can be
/// added without breaking downstream code; construct values through
/// [`BackendCapabilities::minimal`] and the `with_*` builders.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackendCapabilities {
/// Direct encryption of caller-provided plaintext with a master key
pub encrypt: bool,
/// Decryption of previously produced ciphertext
pub decrypt: bool,
/// Data encryption key (DEK) generation
pub generate_data_key: bool,
/// Key rotation that retains prior versions for decryption
pub rotate: bool,
/// Enabling and disabling keys
pub enable_disable: bool,
/// Scheduling key deletion with a pending window
pub schedule_deletion: bool,
/// Multiple key versions addressable after rotation
pub versioning: bool,
/// Irreversible physical deletion of key material
pub physical_delete: bool,
/// Updating a key's description and tags after creation
pub update_key_metadata: bool,
/// Re-wrapping an existing data key envelope onto the key's current version
pub rewrap: bool,
}
impl BackendCapabilities {
/// Conservative baseline: only the operations that every [`KmsBackend`]
/// implementation is required to provide by the trait. All optional
/// lifecycle capabilities default to unsupported.
pub const fn minimal() -> Self {
Self {
encrypt: true,
decrypt: true,
generate_data_key: true,
rotate: false,
enable_disable: false,
schedule_deletion: false,
versioning: false,
physical_delete: false,
update_key_metadata: false,
rewrap: false,
}
}
/// Set whether direct encryption is supported
pub const fn with_encrypt(mut self, encrypt: bool) -> Self {
self.encrypt = encrypt;
self
}
/// Set whether decryption is supported
pub const fn with_decrypt(mut self, decrypt: bool) -> Self {
self.decrypt = decrypt;
self
}
/// Set whether data key generation is supported
pub const fn with_generate_data_key(mut self, generate_data_key: bool) -> Self {
self.generate_data_key = generate_data_key;
self
}
/// Set whether version-retaining key rotation is supported
pub const fn with_rotate(mut self, rotate: bool) -> Self {
self.rotate = rotate;
self
}
/// Set whether enabling/disabling keys is supported
pub const fn with_enable_disable(mut self, enable_disable: bool) -> Self {
self.enable_disable = enable_disable;
self
}
/// Set whether scheduled deletion with a pending window is supported
pub const fn with_schedule_deletion(mut self, schedule_deletion: bool) -> Self {
self.schedule_deletion = schedule_deletion;
self
}
/// Set whether multiple key versions are supported
pub const fn with_versioning(mut self, versioning: bool) -> Self {
self.versioning = versioning;
self
}
/// Set whether physical deletion of key material is supported
pub const fn with_physical_delete(mut self, physical_delete: bool) -> Self {
self.physical_delete = physical_delete;
self
}
/// Set whether description and tag updates are supported
pub const fn with_update_key_metadata(mut self, update_key_metadata: bool) -> Self {
self.update_key_metadata = update_key_metadata;
self
}
/// Set whether envelope rewrap onto the current key version is supported
pub const fn with_rewrap(mut self, rewrap: bool) -> Self {
self.rewrap = rewrap;
self
}
}
impl Default for BackendCapabilities {
fn default() -> Self {
Self::minimal()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::KmsConfig;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
/// Backend that implements only the trait-mandated operations and relies
/// on the default `capabilities` implementation.
struct MinimalBackend;
#[async_trait]
impl KmsBackend for MinimalBackend {
async fn create_key(&self, _request: CreateKeyRequest) -> Result<CreateKeyResponse> {
unimplemented!("not exercised by capability tests")
}
async fn encrypt(&self, _request: EncryptRequest) -> Result<EncryptResponse> {
unimplemented!("not exercised by capability tests")
}
async fn decrypt(&self, _request: DecryptRequest) -> Result<DecryptResponse> {
unimplemented!("not exercised by capability tests")
}
async fn generate_data_key(&self, _request: GenerateDataKeyRequest) -> Result<GenerateDataKeyResponse> {
unimplemented!("not exercised by capability tests")
}
async fn describe_key(&self, _request: DescribeKeyRequest) -> Result<DescribeKeyResponse> {
unimplemented!("not exercised by capability tests")
}
async fn list_keys(&self, _request: ListKeysRequest) -> Result<ListKeysResponse> {
unimplemented!("not exercised by capability tests")
}
async fn delete_key(&self, _request: DeleteKeyRequest) -> Result<DeleteKeyResponse> {
unimplemented!("not exercised by capability tests")
}
async fn cancel_key_deletion(&self, _request: CancelKeyDeletionRequest) -> Result<CancelKeyDeletionResponse> {
unimplemented!("not exercised by capability tests")
}
async fn health_check(&self) -> Result<bool> {
Ok(true)
}
}
fn capabilities_snapshot(capabilities: BackendCapabilities) -> std::collections::BTreeMap<String, bool> {
serde_json::from_value(serde_json::to_value(capabilities).expect("capabilities should serialize"))
.expect("capabilities should deserialize into a flat bool map")
}
#[test]
fn default_capabilities_are_conservative() {
let capabilities = MinimalBackend.capabilities();
assert_eq!(capabilities, BackendCapabilities::minimal());
assert_eq!(capabilities, BackendCapabilities::default());
// The conservative baseline advertises only trait-mandated operations.
assert!(capabilities.encrypt);
assert!(capabilities.decrypt);
assert!(capabilities.generate_data_key);
assert!(!capabilities.rotate);
assert!(!capabilities.enable_disable);
assert!(!capabilities.schedule_deletion);
assert!(!capabilities.versioning);
assert!(!capabilities.physical_delete);
assert!(!capabilities.update_key_metadata);
assert!(!capabilities.rewrap);
}
#[tokio::test]
async fn default_lifecycle_operations_are_unsupported() {
for (operation, result) in [
("enable_key", MinimalBackend.enable_key("any-key").await),
("disable_key", MinimalBackend.disable_key("any-key").await),
("rotate_key", MinimalBackend.rotate_key("any-key").await),
(
"update_key_description",
MinimalBackend.update_key_description("any-key", Some("new")).await,
),
("tag_key", MinimalBackend.tag_key("any-key", &HashMap::new()).await),
("untag_key", MinimalBackend.untag_key("any-key", &[]).await),
(
"rewrap_data_key",
MinimalBackend
.rewrap_data_key(RewrapDataKeyRequest {
ciphertext: b"{}".to_vec(),
encryption_context: HashMap::new(),
})
.await
.map(|_| ()),
),
(
"describe_data_key_wrapping",
MinimalBackend
.describe_data_key_wrapping(DescribeDataKeyWrappingRequest {
ciphertext: b"{}".to_vec(),
encryption_context: HashMap::new(),
})
.await
.map(|_| ()),
),
] {
let error = result.expect_err("backends must opt in to lifecycle operations by overriding them");
assert!(
matches!(error, KmsError::UnsupportedCapability { .. }),
"expected UnsupportedCapability for {operation}, got {error:?}"
);
}
}
#[tokio::test]
async fn default_remove_expired_key_is_unsupported() {
let error = MinimalBackend
.remove_expired_key("any-key", &jiff::Zoned::now())
.await
.expect_err("backends without deletion support must reject expired-key removal");
assert!(
matches!(error, KmsError::UnsupportedCapability { .. }),
"expected UnsupportedCapability, got {error:?}"
);
}
/// The rewrap context guard has to sit exactly where the decrypt guard
/// sits: strict enough that an envelope cannot be laundered under a
/// different context, lax enough that everything decrypt accepts can still
/// be rewrapped — otherwise a retirement sweep stalls on readable objects.
#[test]
fn rewrap_context_guard_matches_the_decrypt_rule() {
let envelope = HashMap::from([
("bucket".to_string(), "b/o".to_string()),
("tenant".to_string(), "acme".to_string()),
]);
ensure_rewrap_context_matches(&envelope, &envelope).expect("an exact context must be accepted");
// An empty request context is the pre-context-recording compatibility
// case decrypt allows; rewrap must not be stricter.
ensure_rewrap_context_matches(&envelope, &HashMap::new()).expect("an empty request context must stay accepted");
// Extra entries the envelope does not carry are ignored, as on decrypt.
let mut superset = envelope.clone();
superset.insert("extra".to_string(), "ignored".to_string());
ensure_rewrap_context_matches(&envelope, &superset).expect("extra request context entries must be ignored");
let mut tampered = envelope.clone();
tampered.insert("bucket".to_string(), "other/o".to_string());
assert!(
matches!(ensure_rewrap_context_matches(&envelope, &tampered), Err(KmsError::ContextMismatch { .. })),
"a tampered context value must be rejected"
);
let partial = HashMap::from([("tenant".to_string(), "acme".to_string())]);
assert!(
matches!(ensure_rewrap_context_matches(&envelope, &partial), Err(KmsError::ContextMismatch { .. })),
"a non-empty context missing an envelope entry must be rejected"
);
}
#[test]
fn identity_tag_is_rejected_by_metadata_updates() {
let error = ensure_tag_keys_are_mutable([RESERVED_KEY_NAME_TAG])
.expect_err("the identity tag must not be writable through a metadata update");
assert!(
matches!(&error, KmsError::InvalidOperation { message } if message.contains(RESERVED_KEY_NAME_TAG)),
"expected a typed rejection naming the tag, got {error:?}"
);
// Ordinary tags — including ones that merely contain the reserved name
// — stay writable.
ensure_tag_keys_are_mutable(["team", "nickname", "Name"]).expect("ordinary tags must remain writable");
}
#[tokio::test]
async fn local_backend_capabilities_golden() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let config = KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults();
let backend = local::LocalKmsBackend::new(config).await.expect("local backend should build");
insta::assert_json_snapshot!("local_backend_capabilities", capabilities_snapshot(backend.capabilities()));
}
#[tokio::test]
async fn vault_kv2_backend_capabilities_golden() {
let config = KmsConfig::vault(
url::Url::parse("http://127.0.0.1:8200").expect("vault URL should parse"),
"dev-token".to_string(),
)
.with_insecure_development_defaults();
// Constructing the client performs no network I/O with token auth.
let backend = vault::VaultKmsBackend::new(config)
.await
.expect("vault kv2 backend should build");
insta::assert_json_snapshot!("vault_kv2_backend_capabilities", capabilities_snapshot(backend.capabilities()));
}
#[tokio::test]
async fn vault_transit_backend_capabilities_golden() {
let config = KmsConfig::vault_transit(
url::Url::parse("http://127.0.0.1:8200").expect("vault URL should parse"),
"dev-token".to_string(),
)
.with_insecure_development_defaults();
// Constructing the client performs no network I/O with token auth.
let backend = vault_transit::VaultTransitKmsBackend::new(config)
.await
.expect("vault transit backend should build");
insta::assert_json_snapshot!("vault_transit_backend_capabilities", capabilities_snapshot(backend.capabilities()));
}
#[tokio::test]
async fn static_backend_capabilities_golden() {
let config = KmsConfig::static_kms("static-key".to_string(), BASE64.encode([0u8; 32]));
let backend = static_kms::StaticKmsBackend::new(config)
.await
.expect("static backend should build");
insta::assert_json_snapshot!("static_backend_capabilities", capabilities_snapshot(backend.capabilities()));
}
// -- Pagination boundaries ----------------------------------------------
fn key_ids(count: usize) -> Vec<String> {
(0..count).map(|index| format!("key-{index:02}")).collect()
}
fn page_request(limit: Option<u32>, marker: Option<&str>) -> ListKeysRequest {
ListKeysRequest {
limit,
marker: marker.map(str::to_string),
usage_filter: None,
status_filter: None,
}
}
fn page_of(keys: &[String], limit: Option<u32>, marker: Option<&str>) -> (Vec<String>, Option<String>, bool) {
let page = paginate_keys(keys, &page_request(limit, marker), String::as_str);
(page.items.to_vec(), page.next_marker, page.truncated)
}
/// Zero keys requested, zero keys returned — and no cursor, so a caller
/// looping on `truncated` terminates instead of asking forever. Slicing a
/// zero-length page out of a non-empty key set must not reach for the
/// element before the page either.
#[test]
fn zero_limit_returns_an_empty_untruncated_page() {
let keys = key_ids(3);
assert_eq!(page_of(&keys, Some(0), None), (Vec::new(), None, false));
assert_eq!(page_of(&keys, Some(0), Some("key-01")), (Vec::new(), None, false));
// Also at the ends of the key set, where a page has no predecessor.
assert_eq!(page_of(&[], Some(0), None), (Vec::new(), None, false));
assert_eq!(page_of(&keys, Some(0), Some("key-02")), (Vec::new(), None, false));
assert_eq!(list_keys_page_size(Some(0)), None);
assert_eq!(list_keys_page_size(None), Some(DEFAULT_LIST_KEYS_PAGE_SIZE as usize));
assert_eq!(list_keys_page_size(Some(7)), Some(7));
}
/// A limit past the end of the key set is not an overflow.
#[test]
fn oversized_limit_returns_the_whole_key_set_once() {
let keys = key_ids(3);
assert_eq!(page_of(&keys, Some(u32::MAX), None), (keys.clone(), None, false));
assert_eq!(page_of(&keys, Some(u32::MAX), Some("key-01")), (vec![keys[2].clone()], None, false));
}
/// 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]
fn marker_for_a_removed_key_resumes_after_it() {
let keys = vec!["key-00".to_string(), "key-02".to_string()];
assert_eq!(page_of(&keys, Some(10), Some("key-01")), (vec!["key-02".to_string()], None, false));
// A marker past every key ends the listing rather than wrapping.
assert_eq!(page_of(&keys, Some(10), Some("key-99")), (Vec::new(), None, false));
// A marker before every key yields the whole set.
assert_eq!(page_of(&keys, Some(10), Some("key")), (keys.clone(), None, false));
}
/// Truncation flips exactly at the page boundary, and paging covers the
/// key set once end to end.
#[test]
fn pages_tile_the_key_set_exactly_at_the_limit_boundary() {
let keys = key_ids(4);
assert_eq!(page_of(&keys, Some(4), None), (keys.clone(), None, false));
assert_eq!(page_of(&keys, Some(3), None), (keys[..3].to_vec(), Some("key-02".to_string()), true));
let mut seen = Vec::new();
let mut marker = None;
loop {
let (items, next_marker, truncated) = page_of(&keys, Some(2), marker.as_deref());
seen.extend(items);
if !truncated {
assert!(next_marker.is_none(), "a final page must not offer a cursor");
break;
}
marker = Some(next_marker.expect("a truncated page must offer a cursor"));
}
assert_eq!(seen, keys, "paging must tile the key set exactly once");
}
}