diff --git a/packages/stack-kms/src/backend.rs b/packages/stack-kms/src/backend.rs new file mode 100644 index 000000000..41a99d317 --- /dev/null +++ b/packages/stack-kms/src/backend.rs @@ -0,0 +1,254 @@ +//! Which key backend a key source is, and the key mode of a key vendor +//! backend's keyset. +//! +//! A keyset is in exactly one key backend. A sealed leaf names its keyset, +//! and the key source routes it by that id (`stack_kms_vendor::KeyRouter`), +//! so the leaf itself names no backend: every leaf is the v1 leaf. +//! +//! A key vendor backend's keyset record stores one byte for the vendor +//! ([`KeyVendor::byte`]) and one byte for the key mode ([`KeyMode::byte`]). +//! `stack-kms-vendor` binds both bytes into the associated data of the +//! record's wrapped key and into the derivation of every data key and +//! index key, so a record whose vendor or mode was changed opens nothing. +//! +//! # Stable byte values +//! +//! The byte values are a storage commitment: a keyset record holds them, +//! and the keys of stored values are derived under them. A value is never +//! reused and never renumbered. The value `0` is reserved as invalid for +//! both fields, so a zeroed field never decodes. +//! +//! | vendor | byte | +//! |----------------------|------| +//! | (invalid) | 0 | +//! | AWS KMS | 1 | +//! | Google Cloud KMS | 2 | +//! | Azure Key Vault | 3 | +//! | HashiCorp Vault | 4 | +//! | static key | 5 | +//! +//! | key mode | byte | +//! |----------------------|------| +//! | (invalid) | 0 | +//! | (reserved) | 1 | +//! | derived data key | 2 | + +use std::fmt; + +/// The service a keyset's data keys and index key come from: ZeroKMS, or +/// one key vendor backend. +/// +/// The `backend` of [`Error::Backend`](crate::Error::Backend) names it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum KeyBackend { + /// ZeroKMS. + ZeroKms, + /// A key vendor backend. + Vendor(KeyVendor), +} + +impl KeyBackend { + /// A stable, lower-case name for logs and error payloads. + pub const fn name(self) -> &'static str { + match self { + Self::ZeroKms => "zerokms", + Self::Vendor(vendor) => vendor.name(), + } + } +} + +impl From for KeyBackend { + fn from(vendor: KeyVendor) -> Self { + Self::Vendor(vendor) + } +} + +impl fmt::Display for KeyBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// A key vendor backend: a key backend that is not ZeroKMS. +/// +/// Each variant has a stable byte ([`byte`](Self::byte)), which a keyset +/// record stores. See the module docs for the table. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +#[repr(u8)] +pub enum KeyVendor { + /// AWS KMS. + Aws = 1, + /// Google Cloud KMS. + Gcp = 2, + /// Azure Key Vault. + Azure = 3, + /// HashiCorp Vault Transit. + Vault = 4, + /// A static key, held in memory. + Static = 5, +} + +impl KeyVendor { + /// Every vendor, in byte order. + pub const ALL: [Self; 5] = [Self::Aws, Self::Gcp, Self::Azure, Self::Vault, Self::Static]; + + /// The byte a keyset record stores for this vendor. + pub const fn byte(self) -> u8 { + self as u8 + } + + /// The vendor a record's vendor byte names, or `None` for a byte that + /// names no vendor (`0` included). + pub const fn from_byte(byte: u8) -> Option { + match byte { + 1 => Some(Self::Aws), + 2 => Some(Self::Gcp), + 3 => Some(Self::Azure), + 4 => Some(Self::Vault), + 5 => Some(Self::Static), + _ => None, + } + } + + /// A stable, lower-case name. It is the one spelling of the vendor: the + /// `vendor` field of [`Error::Backend`](crate::Error::Backend) and + /// [`Error::ZeroKmsOnly`](crate::Error::ZeroKmsOnly) payloads, the + /// `key_vendor` value of the Go guest's config, and the `backend` that + /// `stash keyset` reports. + pub const fn name(self) -> &'static str { + match self { + Self::Aws => "aws-kms", + Self::Gcp => "gcp-kms", + Self::Azure => "azure-key-vault", + Self::Vault => "vault-transit", + Self::Static => "static", + } + } + + /// The vendor that [`name`](Self::name) spells `name`, or `None` for a + /// name that is not one of them. The match is exact. + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|vendor| vendor.name() == name) + } +} + +impl fmt::Display for KeyVendor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// The key mode of a keyset in a key vendor backend (ADR-0013). +/// +/// Each variant has a stable byte ([`byte`](Self::byte)), which a keyset +/// record stores. ZeroKMS has no key mode. +/// +/// There is one mode today. The type is `#[non_exhaustive]`, so a mode +/// added later changes no format. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +#[repr(u8)] +pub enum KeyMode { + /// Derived data key: each data key is derived from the keyset's root + /// key. The leaf's tag is empty, and its iv is the derivation salt. + Derived = 2, +} + +impl KeyMode { + /// Every mode, in byte order. + pub const ALL: [Self; 1] = [Self::Derived]; + + /// The byte a keyset record stores for this mode. + pub const fn byte(self) -> u8 { + self as u8 + } + + /// The mode a record's mode byte names, or `None` for a byte that names + /// no mode (`0` and the reserved `1` included). + pub const fn from_byte(byte: u8) -> Option { + match byte { + 2 => Some(Self::Derived), + _ => None, + } + } + + /// A stable, lower-case name for logs and error payloads. + pub const fn name(self) -> &'static str { + match self { + Self::Derived => "derived", + } + } +} + +impl fmt::Display for KeyMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The byte values are a storage commitment. This pin fails if one + /// moves. + #[test] + fn the_byte_values_are_pinned() { + let vendors: Vec<(u8, &str)> = KeyVendor::ALL + .iter() + .map(|vendor| (vendor.byte(), vendor.name())) + .collect(); + assert_eq!( + vendors, + [ + (1, "aws-kms"), + (2, "gcp-kms"), + (3, "azure-key-vault"), + (4, "vault-transit"), + (5, "static"), + ] + ); + for vendor in KeyVendor::ALL { + assert_eq!(KeyVendor::from_name(vendor.name()), Some(vendor)); + } + for name in ["aws", "AWS-KMS", "zerokms", ""] { + assert_eq!(KeyVendor::from_name(name), None, "{name:?}"); + } + let modes: Vec<(u8, &str)> = KeyMode::ALL + .iter() + .map(|mode| (mode.byte(), mode.name())) + .collect(); + assert_eq!(modes, [(2, "derived")]); + } + + #[test] + fn from_byte_inverts_byte_and_refuses_every_other_value() { + for byte in 0..=u8::MAX { + match KeyVendor::from_byte(byte) { + Some(vendor) => assert_eq!(vendor.byte(), byte), + None => assert!(!KeyVendor::ALL.iter().any(|v| v.byte() == byte), "{byte}"), + } + match KeyMode::from_byte(byte) { + Some(mode) => assert_eq!(mode.byte(), byte), + None => assert!(!KeyMode::ALL.iter().any(|m| m.byte() == byte), "{byte}"), + } + } + assert_eq!(KeyVendor::from_byte(0), None); + assert_eq!(KeyMode::from_byte(0), None); + assert_eq!(KeyMode::from_byte(1), None, "byte 1 is reserved"); + } + + #[test] + fn names_display_as_themselves() { + assert_eq!(KeyBackend::ZeroKms.to_string(), "zerokms"); + assert_eq!(KeyBackend::ZeroKms.name(), "zerokms"); + for vendor in KeyVendor::ALL { + assert_eq!(vendor.to_string(), vendor.name()); + assert_eq!(KeyBackend::Vendor(vendor).to_string(), vendor.name()); + } + for mode in KeyMode::ALL { + assert_eq!(mode.to_string(), mode.name()); + } + } +} diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index 9287e6f18..4c405b7cf 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -340,6 +340,15 @@ mod codes { }, Error::InvalidEndpoint(_) => Error::InvalidEndpoint(InvalidEndpoint::Userinfo), Error::Unexpected(_) => Error::Unexpected("unexpected".into()), + Error::Backend { .. } => Error::Backend { + backend: crate::KeyVendor::Static.into(), + kind: BackendErrorKind::NotFound, + source: Box::new(std::io::Error::other("no key")), + }, + Error::ZeroKmsOnly { .. } => Error::ZeroKmsOnly { + vendor: crate::KeyVendor::Static, + feature: crate::payload::ZeroKmsFeature::Context, + }, ])); errors.extend(boxed(variants![ InvalidEndpoint::Parse(_) => InvalidEndpoint::Parse(url::ParseError::EmptyHost), @@ -500,6 +509,53 @@ mod codes { } } + /// A backend failure gives its vendor and kind, never the vendor's own + /// error, which stays behind `source()`. + #[test] + fn a_backend_failure_gives_the_vendor_and_kind_and_not_the_source() { + use serde_json::json; + for kind in [ + BackendErrorKind::NotFound, + BackendErrorKind::AccessDenied, + BackendErrorKind::Throttled, + BackendErrorKind::Unavailable, + BackendErrorKind::InvalidCiphertext, + ] { + let error = Error::Backend { + backend: crate::KeyVendor::Aws.into(), + kind, + source: Box::new(std::io::Error::other("marker-detail")), + }; + assert_eq!( + serde_json::Value::Object(error.payload()), + json!({ "vendor": "aws-kms", "kind": format!("{kind:?}") }) + ); + assert_eq!(kind.to_string(), format!("{kind:?}")); + let shown = error.to_string(); + assert_eq!(shown, format!("Key backend aws-kms failed: {kind:?}")); + assert!(!shown.contains("marker-detail"), "{shown}"); + assert!(std::error::Error::source(&error) + .is_some_and(|source| source.to_string() == "marker-detail")); + } + } + + #[test] + fn a_zerokms_only_refusal_names_the_vendor_and_the_feature() { + use serde_json::json; + let error = Error::ZeroKmsOnly { + vendor: crate::KeyVendor::Vault, + feature: crate::payload::ZeroKmsFeature::DecryptionPolicy, + }; + assert_eq!( + serde_json::Value::Object(error.payload()), + json!({ "vendor": "vault-transit", "feature": "decryption_policy" }) + ); + assert_eq!( + error.to_string(), + "Key backend vault-transit does not support the ZeroKMS feature decryption_policy" + ); + } + /// The builder's help names the variable that is wrong. #[cfg(feature = "http")] #[test] @@ -751,6 +807,76 @@ pub enum Error { #[error("Unexpected error: {0}")] #[diagnostic(code(stack_kms::unexpected))] Unexpected(String), + + /// A key backend failed. `backend` names it (the payload's `vendor` + /// field is its [`KeyBackend::name`](crate::KeyBackend::name), for + /// example `"aws-kms"`) and `kind` says what failed, in terms that hold + /// for every vendor. The vendor's own error is behind `source()`; the + /// message carries only the backend and the kind. + #[error("Key backend {backend} failed: {kind}")] + #[diagnostic( + code(stack_kms::backend), + help("Read the `kind` field. NotFound and AccessDenied are configuration; Throttled and Unavailable can be retried; InvalidCiphertext means the stored key or value is damaged, or belongs to another key.") + )] + Backend { + backend: crate::KeyBackend, + kind: BackendErrorKind, + #[source] + source: Box, + }, + + /// A key vendor backend was asked for an input that only ZeroKMS can + /// honour (a key context, a decryption policy, an unverified context). + /// The backend refuses it rather than ignore it. + #[error("Key backend {vendor} does not support the ZeroKMS feature {feature}")] + #[diagnostic( + code(stack_kms::zerokms_only), + help("Lock contexts, decryption policies and unverified contexts need ZeroKMS. Use a ZeroKMS keyset, or remove the feature from the request.") + )] + ZeroKmsOnly { + vendor: crate::KeyVendor, + feature: crate::payload::ZeroKmsFeature, + }, +} + +/// What failed in a key vendor backend, in terms that hold for every vendor. +/// +/// Each vendor client maps its own errors to one of these, so that a caller +/// can act on a failure without knowing the vendor. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum BackendErrorKind { + /// The vendor key, or the key material it protects, does not exist. + NotFound, + /// The vendor refused the credential, or the credential has no grant + /// for this key or operation. + AccessDenied, + /// The vendor rate-limited the request. Retry later. + Throttled, + /// The vendor could not be reached, or did not answer. Retry later. + Unavailable, + /// The vendor could not unwrap the key material: it is damaged, or it + /// was wrapped by another key. + InvalidCiphertext, +} + +impl BackendErrorKind { + /// The kind as error payloads spell it. + pub const fn as_str(self) -> &'static str { + match self { + Self::NotFound => "NotFound", + Self::AccessDenied => "AccessDenied", + Self::Throttled => "Throttled", + Self::Unavailable => "Unavailable", + Self::InvalidCiphertext => "InvalidCiphertext", + } + } +} + +impl std::fmt::Display for BackendErrorKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } } impl ErrorPayload for Error { @@ -760,6 +886,14 @@ impl ErrorPayload for Error { Self::RetrieveKey(error) => error.payload(), Self::LoadKeyset(error) => error.payload(), Self::Auth(error) => error.payload(), + Self::Backend { backend, kind, .. } => payload([ + ("vendor", backend.name().into()), + ("kind", kind.as_str().into()), + ]), + Self::ZeroKmsOnly { vendor, feature } => payload([ + ("vendor", vendor.name().into()), + ("feature", feature.as_str().into()), + ]), Self::ConnectionInit(_) | Self::InvalidEndpoint(_) | Self::Unexpected(_) => { serde_json::Map::new() } diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index c919ba9a1..695d05050 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use sha2::{Digest, Sha256}; use std::ops::Deref; use uuid::Uuid; -use vitaminc::protected::{OpaqueDebug, TimingSafeEq}; +use vitaminc::protected::{Controlled, OpaqueDebug, Protected, TimingSafeEq}; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use zerokms_protocol::{DecryptionPolicy, ViturKeyMaterial}; @@ -226,6 +226,19 @@ impl IndexKey { Ok(Self(key)) } + /// Build the index key from raw key material that a key vendor backend + /// derived. + /// + /// The bytes must be the keyset's index key exactly as every writer and + /// every reader of that keyset obtains it: a different key builds terms + /// that match nothing. A ZeroKMS keyset never needs this; use + /// [`from_key_material`](Self::from_key_material). The input is + /// [`Protected`], so the caller's copy is wiped when it drops, and the + /// index key wipes its own copy on drop. + pub fn from_key_bytes(key: Protected) -> Self { + Self(*key.risky_ref()) + } + pub fn key(&self) -> &Key { &self.0 } @@ -460,6 +473,17 @@ mod tests { ); } + #[test] + fn from_key_bytes_keeps_the_bytes_it_is_given() { + let index_key = + IndexKey::from_key_bytes(vitaminc::protected::Protected::new([9u8; 32])); + assert_eq!(index_key.key(), &[9u8; 32]); + assert!( + !format!("{index_key:?}").contains("9"), + "the key must not print" + ); + } + #[test] fn from_key_material_is_deterministic() { let a = IndexKey::from_key_material(&kat_client_key(), &kat_material()).unwrap(); diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs index 844301ead..c188f6aff 100644 --- a/packages/stack-kms/src/key_source.rs +++ b/packages/stack-kms/src/key_source.rs @@ -1,7 +1,17 @@ -//! An abstraction over the ZeroKMS data-key operations that higher-level -//! encryption layers (e.g. `stack-encrypt`) depend on. +//! An abstraction over the data-key operations of a **key backend** that +//! higher-level encryption layers (e.g. `stack-encrypt`) depend on. //! -//! [`StackKms`](crate::StackKms) is the production implementation. Downstream +//! A key backend is ZeroKMS or one key vendor backend (AWS KMS, Azure Key +//! Vault, HashiCorp Vault Transit, Google Cloud KMS, or a static key). The +//! vendor-neutral part of each request is its descriptor, its iv and tag, +//! and its keyset id. The key context, the decryption policy and the +//! [`UnverifiedContext`] are ZeroKMS-only extensions: a key vendor backend +//! refuses them with [`Error::ZeroKmsOnly`] (see +//! [`refuse_zerokms_extensions`](crate::refuse_zerokms_extensions)) and +//! never ignores them. A key vendor backend reports its own failures as +//! [`Error::Backend`]. +//! +//! [`StackKms`](crate::StackKms) is the ZeroKMS implementation. Downstream //! crates take a `K: DataKeySource` rather than a concrete client so their //! encrypt/decrypt logic can be unit-tested against a deterministic in-memory //! fake (see [`FakeDataKeySource`], enabled by the `test-support` feature) @@ -72,6 +82,18 @@ pub trait IndexKeySource { ) -> impl Future> + MaybeSend; } +/// A key source that stands for ZeroKMS: the ZeroKMS side of a router that +/// adds a key vendor backend (`stack_kms_vendor::KeyRouter`). +/// +/// It is a marker. The router asks for it so that its ZeroKMS side cannot be +/// another router, which would put two key vendor backends in one cipher +/// (ADR-0013: ZeroKMS and at most one vendor). Implement it only for a +/// source that talks to ZeroKMS, or that takes its place in a test. Never +/// implement it for a source that routes to a key vendor backend. +pub trait ZeroKmsKeySource {} + +impl ZeroKmsKeySource for crate::StackKms {} + impl DataKeySource for crate::StackKms where C: stack_auth::AuthStrategyBounds, @@ -220,6 +242,8 @@ mod fake { } } + impl super::ZeroKmsKeySource for FakeDataKeySource {} + impl DataKeySource for FakeDataKeySource { async fn generate_keys( &self, @@ -346,6 +370,8 @@ mod deterministic { } } + impl super::ZeroKmsKeySource for DeterministicSource {} + impl DataKeySource for DeterministicSource { async fn generate_keys( &self, diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index ee484f39e..068cf28a8 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -15,6 +15,14 @@ //! Encryption/decryption, keyset and client management, and config save/load //! all remain in `cipherstash-client` for now. //! +//! # Key backends +//! +//! ZeroKMS is one key backend. The `stack-kms-vendor` crate adds the key +//! vendor backends (AWS KMS, Google Cloud KMS, Azure Key Vault, HashiCorp +//! Vault Transit and a static key) on the [`DataKeySource`] and +//! [`IndexKeySource`] traits of this crate. Its crate documentation has a +//! capability matrix that compares each of them with ZeroKMS. +//! //! # Quick start //! // The quick start goes through `StackKmsBuilder`, which configures the default @@ -76,6 +84,7 @@ assert_eq!(keys.len(), 1); #![cfg_attr(test, allow(clippy::panic))] #![cfg_attr(test, allow(unused_results))] +mod backend; #[cfg(feature = "http")] mod builder; mod client; @@ -120,7 +129,8 @@ pub use maybe_send::MaybeSend; // Errors pub use errors::{ - Error, GenerateKeyError, InvalidKeyMaterialError, LoadKeysetError, RetrieveKeyError, + BackendErrorKind, Error, GenerateKeyError, InvalidKeyMaterialError, LoadKeysetError, + RetrieveKeyError, }; /// The trait every error from this crate implements to hand over its /// structured fields, with the rule for what an error may contain, and the @@ -131,12 +141,15 @@ pub use stack_auth::{diagnostic, ErrorPayload}; // Key material pub use key::{ClientKey, DataKey, DataKeyWithTag, IndexKey, V1KeySet}; +// Key backends and key modes (the bytes a keyset record stores) +pub use backend::{KeyBackend, KeyMode, KeyVendor}; + // Key source abstractions (production = `StackKms`; tests = `FakeDataKeySource`) #[cfg(feature = "test-support")] pub use key_source::DeterministicSource; #[cfg(feature = "test-support")] pub use key_source::FakeDataKeySource; -pub use key_source::{DataKeySource, IndexKeySource}; +pub use key_source::{DataKeySource, IndexKeySource, ZeroKmsKeySource}; // Key providers pub use key_provider::{ @@ -149,7 +162,10 @@ pub use secret_key::SecretKey; pub use stack_profile::ProfileStore; // Operation payloads -pub use payload::{GenerateKeyPayload, RetrieveKeyPayload}; +pub use payload::{ + refuse_zerokms_extensions, GenerateKeyPayload, RetrieveKeyPayload, ZeroKmsExtensions, + ZeroKmsFeature, +}; // Commonly needed re-exports from the protocol / crypto layers pub use recipher::key::{GenRandom, Iv}; diff --git a/packages/stack-kms/src/payload.rs b/packages/stack-kms/src/payload.rs index 0c4ada822..60385a83f 100644 --- a/packages/stack-kms/src/payload.rs +++ b/packages/stack-kms/src/payload.rs @@ -1,8 +1,86 @@ use recipher::key::Iv; use std::borrow::Cow; -use zerokms_protocol::{Context, DecryptionPolicy, KeyId, RetrieveKeySpec}; +use zerokms_protocol::{Context, DecryptionPolicy, KeyId, RetrieveKeySpec, UnverifiedContext}; -/// The requirements for generating a data key from ZeroKMS. +/// An input on a data-key request that only ZeroKMS can honour. +/// +/// The vendor-neutral part of a request is its descriptor, its iv and tag, +/// and its keyset id. A key vendor backend works from these alone. The +/// inputs named here have no meaning there, so a key vendor backend refuses +/// a request that carries one ([`Error::ZeroKmsOnly`](crate::Error::ZeroKmsOnly)) +/// and never ignores it. Find them with [`ZeroKmsExtensions`], or refuse them +/// in one call with [`refuse_zerokms_extensions`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ZeroKmsFeature { + /// A non-empty key [`Context`]: tags and identity claims bound into the + /// data key. + Context, + /// A [`DecryptionPolicy`] that ZeroKMS checks on each retrieve. + DecryptionPolicy, + /// A non-empty [`UnverifiedContext`] sent with the request. + UnverifiedContext, +} + +impl ZeroKmsFeature { + /// The feature as error payloads spell it. + pub const fn as_str(self) -> &'static str { + match self { + Self::Context => "context", + Self::DecryptionPolicy => "decryption_policy", + Self::UnverifiedContext => "unverified_context", + } + } +} + +impl std::fmt::Display for ZeroKmsFeature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A data-key payload that can carry ZeroKMS-only inputs. +/// +/// Implemented by [`GenerateKeyPayload`] and [`RetrieveKeyPayload`]. A key +/// vendor backend calls [`refuse_zerokms_extensions`] (or this trait +/// directly) before it sends anything. +pub trait ZeroKmsExtensions { + /// The first ZeroKMS-only input this payload carries, if any. An empty + /// context is not an input: it binds nothing. + fn zerokms_feature(&self) -> Option; +} + +/// Refuse a request that carries a ZeroKMS-only input, for the key vendor +/// backend `vendor`. +/// +/// Returns [`Error::ZeroKmsOnly`](crate::Error::ZeroKmsOnly) for the first +/// such input: a payload's context or decryption policy, else a non-empty +/// `unverified_context`. A key vendor backend calls this first in each +/// [`DataKeySource`](crate::DataKeySource) method, so that a lock context +/// or a policy is refused, not silently dropped. +pub fn refuse_zerokms_extensions( + vendor: crate::KeyVendor, + payloads: &[P], + unverified_context: Option<&UnverifiedContext>, +) -> Result<(), crate::Error> { + let feature = payloads + .iter() + .find_map(ZeroKmsExtensions::zerokms_feature) + .or_else(|| { + unverified_context + .filter(|context| !context.0.is_empty()) + .map(|_| ZeroKmsFeature::UnverifiedContext) + }); + match feature { + Some(feature) => Err(crate::Error::ZeroKmsOnly { vendor, feature }), + None => Ok(()), + } +} + +/// The requirements for generating a data key. +/// +/// [`descriptor`](Self::descriptor) is vendor-neutral. The context and the +/// decryption policy are ZeroKMS-only extensions ([`ZeroKmsFeature`]). #[derive(Clone)] pub struct GenerateKeyPayload<'a> { pub descriptor: &'a str, @@ -24,9 +102,38 @@ impl<'a> GenerateKeyPayload<'a> { self.decryption_policy = Some(policy); self } + + /// The descriptor the data key is bound to. + pub fn descriptor(&self) -> &'a str { + self.descriptor + } } -/// The requirements for retrieving a data key from ZeroKMS. +impl ZeroKmsExtensions for GenerateKeyPayload<'_> { + fn zerokms_feature(&self) -> Option { + feature_of(&self.context, self.decryption_policy.as_ref()) + } +} + +/// The ZeroKMS-only input a context and a policy carry, context first. +fn feature_of( + context: &[Context], + decryption_policy: Option<&DecryptionPolicy>, +) -> Option { + if !context.is_empty() { + Some(ZeroKmsFeature::Context) + } else if decryption_policy.is_some() { + Some(ZeroKmsFeature::DecryptionPolicy) + } else { + None + } +} + +/// The requirements for retrieving a data key. +/// +/// The iv, the descriptor and the tag are vendor-neutral (see +/// [`iv_bytes`](Self::iv_bytes)). The context and the decryption policy are +/// ZeroKMS-only extensions ([`ZeroKmsFeature`]). pub struct RetrieveKeyPayload<'a> { pub iv: KeyId, pub descriptor: &'a str, @@ -56,6 +163,17 @@ impl<'a> RetrieveKeyPayload<'a> { self.decryption_policy = Some(policy); self } + + /// The iv the data key was generated with, as plain bytes. + pub fn iv_bytes(&self) -> Iv { + *self.iv.as_ref() + } +} + +impl ZeroKmsExtensions for RetrieveKeyPayload<'_> { + fn zerokms_feature(&self) -> Option { + feature_of(&self.context, self.decryption_policy.as_ref()) + } } impl<'a> From> for RetrieveKeySpec<'a> { @@ -66,6 +184,7 @@ impl<'a> From> for RetrieveKeySpec<'a> { tag, context, decryption_policy, + .. }: RetrieveKeyPayload<'a>, ) -> Self { let mut spec = Self::new(iv, tag, descriptor).with_context(context); @@ -120,6 +239,126 @@ mod tests { } } + mod zerokms_extensions { + use super::*; + use crate::{Error, KeyVendor}; + use std::collections::HashMap; + + fn tag() -> Vec { + vec![Context::Tag("tenant-1".into())] + } + + #[test] + fn a_plain_payload_carries_no_zerokms_feature() { + let generate = GenerateKeyPayload::new("d", Cow::Owned(vec![])); + let retrieve = RetrieveKeyPayload::new([1u8; 16], "d", b"tag"); + assert_eq!(generate.zerokms_feature(), None); + assert_eq!(retrieve.zerokms_feature(), None); + assert_eq!(generate.descriptor(), "d"); + assert_eq!(retrieve.iv_bytes(), [1u8; 16]); + } + + #[test] + fn a_context_or_a_policy_is_a_zerokms_feature() { + let ctx = tag(); + assert_eq!( + GenerateKeyPayload::new("d", Cow::Borrowed(&ctx)).zerokms_feature(), + Some(ZeroKmsFeature::Context) + ); + assert_eq!( + GenerateKeyPayload::new("d", Cow::Owned(vec![])) + .with_decryption_policy(policy()) + .zerokms_feature(), + Some(ZeroKmsFeature::DecryptionPolicy) + ); + assert_eq!( + RetrieveKeyPayload::new([0u8; 16], "d", b"t") + .with_context(Cow::Borrowed(&ctx)) + .zerokms_feature(), + Some(ZeroKmsFeature::Context) + ); + assert_eq!( + RetrieveKeyPayload::new([0u8; 16], "d", b"t") + .with_decryption_policy(policy()) + .zerokms_feature(), + Some(ZeroKmsFeature::DecryptionPolicy) + ); + } + + fn refused(result: Result<(), Error>) -> ZeroKmsFeature { + match result { + Err(Error::ZeroKmsOnly { vendor, feature }) => { + assert_eq!(vendor, KeyVendor::Static); + feature + } + other => panic!("expected a ZeroKmsOnly refusal, got {other:?}"), + } + } + + #[test] + fn a_vendor_refuses_every_zerokms_feature() { + let ctx = tag(); + let plain = [GenerateKeyPayload::new("d", Cow::Owned(vec![]))]; + assert!(refuse_zerokms_extensions(KeyVendor::Static, &plain, None).is_ok()); + + let with_context = [ + GenerateKeyPayload::new("d", Cow::Owned(vec![])), + GenerateKeyPayload::new("d", Cow::Borrowed(&ctx)), + ]; + assert_eq!( + refused(refuse_zerokms_extensions( + KeyVendor::Static, + &with_context, + None + )), + ZeroKmsFeature::Context + ); + + let with_policy = + [RetrieveKeyPayload::new([0u8; 16], "d", b"t").with_decryption_policy(policy())]; + assert_eq!( + refused(refuse_zerokms_extensions( + KeyVendor::Static, + &with_policy, + None + )), + ZeroKmsFeature::DecryptionPolicy + ); + + let mut claims = HashMap::new(); + let _ = claims.insert("sub".to_string(), None); + let unverified = UnverifiedContext(claims); + assert_eq!( + refused(refuse_zerokms_extensions( + KeyVendor::Static, + &plain, + Some(&unverified) + )), + ZeroKmsFeature::UnverifiedContext + ); + } + + /// An empty unverified context carries nothing, so it is no input. + #[test] + fn an_empty_unverified_context_is_not_refused() { + let plain = [GenerateKeyPayload::new("d", Cow::Owned(vec![]))]; + let empty = UnverifiedContext(HashMap::new()); + assert!(refuse_zerokms_extensions(KeyVendor::Static, &plain, Some(&empty)).is_ok()); + } + + #[test] + fn every_feature_keeps_its_spelling() { + for (feature, text) in [ + (ZeroKmsFeature::Context, "context"), + (ZeroKmsFeature::DecryptionPolicy, "decryption_policy"), + (ZeroKmsFeature::UnverifiedContext, "unverified_context"), + ] { + assert_eq!(feature.as_str(), text); + assert_eq!(feature.to_string(), text); + } + } + } + #[test] fn generate_key_payload_with_decryption_policy_sets_the_policy() { let payload = GenerateKeyPayload::new("d", Cow::Owned(vec![]));