// 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) -> 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, request_context: &HashMap, ) -> 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, /// 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) -> Option { 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; /// Encrypt data async fn encrypt(&self, request: EncryptRequest) -> Result; /// Decrypt data async fn decrypt(&self, request: DecryptRequest) -> Result; /// Generate a data key async fn generate_data_key(&self, request: GenerateDataKeyRequest) -> Result; /// Describe a key async fn describe_key(&self, request: DescribeKeyRequest) -> Result; /// 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; /// Delete a key async fn delete_key(&self, request: DeleteKeyRequest) -> Result; /// Cancel key deletion async fn cancel_key_deletion(&self, request: CancelKeyDeletionRequest) -> Result; /// 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 { 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 { 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) -> 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; /// 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 { 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 { unimplemented!("not exercised by capability tests") } async fn encrypt(&self, _request: EncryptRequest) -> Result { unimplemented!("not exercised by capability tests") } async fn decrypt(&self, _request: DecryptRequest) -> Result { unimplemented!("not exercised by capability tests") } async fn generate_data_key(&self, _request: GenerateDataKeyRequest) -> Result { unimplemented!("not exercised by capability tests") } async fn describe_key(&self, _request: DescribeKeyRequest) -> Result { unimplemented!("not exercised by capability tests") } async fn list_keys(&self, _request: ListKeysRequest) -> Result { unimplemented!("not exercised by capability tests") } async fn delete_key(&self, _request: DeleteKeyRequest) -> Result { unimplemented!("not exercised by capability tests") } async fn cancel_key_deletion(&self, _request: CancelKeyDeletionRequest) -> Result { unimplemented!("not exercised by capability tests") } async fn health_check(&self) -> Result { Ok(true) } } fn capabilities_snapshot(capabilities: BackendCapabilities) -> std::collections::BTreeMap { 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 { (0..count).map(|index| format!("key-{index:02}")).collect() } fn page_request(limit: Option, 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, marker: Option<&str>) -> (Vec, Option, 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"); } }