Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/kms-router-one-vendor-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/auth': patch
---

A key router now holds ZeroKMS and at most one key vendor backend by its type. The ZeroKMS side of a router must be a ZeroKMS key source, and a router is not one, so a router that holds a second router does not compile. No runtime behaviour changes.
16 changes: 13 additions & 3 deletions docs/plans/2026-11-05-key-vendor-backends-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,15 @@ the alternatives and the reason.
in `stack-kms-vendor`. A keyset id or name that a vendor record has goes
to the vendor (a local record name wins over a ZeroKMS keyset of the same
name); everything else, and the default keyset, goes to ZeroKMS. The type
holds exactly one vendor; `VendorKeySource::new` refuses records of two
vendors with `RecordError::SecondVendor`, and a record of another vendor
than the backend with `RecordError::ForeignVendor`.
holds at most one vendor: its ZeroKMS side must implement the marker
`stack_kms::ZeroKmsKeySource`, which no router implements, so
`KeyRouter<KeyRouter<_, Aws>, Gcp>` does not compile (a `compile_fail`
doc test shows it). This was not true when first written: the ZeroKMS
side took any `DataKeySource`, so a router could hold a second router; the
review after CIP-5313 found it and the marker closes it.
`VendorKeySource::new` refuses records of two vendors with
`RecordError::SecondVendor`, and a record of another vendor than the
backend with `RecordError::ForeignVendor`.
- **Decision:** the static key backend (`StaticKeyVendor`) wraps with
AES-256-GCM under a random 96-bit nonce: `version(1)=1 ‖ nonce(12) ‖
ciphertext ‖ tag`. It holds several keys by reference, for rotation.
Expand Down Expand Up @@ -1810,6 +1816,10 @@ ticket that recorded it.
- CIP-5311 → CIP-5313: the capability matrix on the Rust side.
- CIP-5312 → CIP-5313: the CLI README section for `stash keyset` (the docs
site is still open, above).
- **Review after CIP-5313 (FIXED):** "at most one vendor" was not held by
the type: `KeyRouter`'s ZeroKMS side took any `DataKeySource`, so a router
could nest a second router and vendor. The ZeroKMS side must now implement
`stack_kms::ZeroKmsKeySource`, which no router implements.
- **CIP-5313 (FIXED, defect):** a rewrap of a wrapped-data-key record to
another vendor key made its stored values unreadable. `KeysetRecord::rewrap`
now refuses it (`RecordError::RewrapToAnotherKey`; `REWRAP_REFUSED` in
Expand Down
3 changes: 3 additions & 0 deletions languages/golang/encrypt/guest/src/zerokms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub enum MaybeZeroKms<Z> {
Absent,
}

// MaybeZeroKms stands for ZeroKMS (present or not), never for a router.
impl<Z: stack_kms::ZeroKmsKeySource> stack_kms::ZeroKmsKeySource for MaybeZeroKms<Z> {}

/// The cause of [`no_zerokms`]: its type is what the status layer finds to
/// report `STATUS_NO_ZEROKMS`.
#[derive(Debug, Clone, Copy)]
Expand Down
26 changes: 22 additions & 4 deletions packages/stack-kms-vendor/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::borrow::Cow;
use stack_kms::{
DataKey, DataKeySource, DataKeyWithTag, Error, GenerateKeyPayload, IdentifiedBy, IndexKey,
IndexKeySource, KeyBackend, KeyMode, LoadedIndexKey, RetrieveKeyPayload, SelectedKeyset,
UnverifiedContext,
UnverifiedContext, ZeroKmsKeySource,
};
use uuid::Uuid;

Expand All @@ -17,8 +17,24 @@ use crate::vendor::KeyVendorBackend;
///
/// A cipher holds ZeroKMS and **at most one** key vendor backend. The type
/// has room for one or none: [`new`](Self::new) gives it one, and
/// [`zerokms_only`](Self::zerokms_only) none. [`VendorKeySource::new`]
/// refuses records that name a second vendor
/// [`zerokms_only`](Self::zerokms_only) none. The ZeroKMS side must be a
/// [`ZeroKmsKeySource`], which a router is not, so a router cannot hold a
/// second router and its vendor:
///
/// ```compile_fail
/// use stack_kms::FakeDataKeySource;
/// use stack_kms_vendor::{FakeVendor, KeyRouter, VendorKeySource};
///
/// fn nested(
/// inner: KeyRouter<FakeDataKeySource, FakeVendor>,
/// vendor: VendorKeySource<FakeVendor>,
/// ) {
/// // A router is not a ZeroKmsKeySource.
/// let _ = KeyRouter::new(inner, vendor);
/// }
/// ```
///
/// [`VendorKeySource::new`] refuses records that name a second vendor
/// ([`RecordError::SecondVendor`](crate::RecordError::SecondVendor)).
///
/// Routing:
Expand Down Expand Up @@ -52,7 +68,7 @@ impl<Z, B> std::fmt::Debug for KeyRouter<Z, B> {
}
}

impl<Z, B: KeyVendorBackend> KeyRouter<Z, B> {
impl<Z: ZeroKmsKeySource, B: KeyVendorBackend> KeyRouter<Z, B> {
/// Route between `zerokms` and `vendor`.
pub fn new(zerokms: Z, vendor: VendorKeySource<B>) -> Self {
Self {
Expand All @@ -71,7 +87,9 @@ impl<Z, B: KeyVendorBackend> KeyRouter<Z, B> {
vendor_default: false,
}
}
}

impl<Z, B: KeyVendorBackend> KeyRouter<Z, B> {
/// Make naming no keyset select the vendor's default keyset (its only
/// record, or the one set with
/// [`VendorKeySource::with_default_keyset`]), not ZeroKMS's. For a
Expand Down
16 changes: 16 additions & 0 deletions packages/stack-kms/src/key_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,18 @@ impl LoadedIndexKey {
}
}

/// 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-0009: 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<C, Conn> ZeroKmsKeySource for crate::StackKms<C, Conn> {}

impl<C, Conn> DataKeySource for crate::StackKms<C, Conn>
where
C: stack_auth::AuthStrategyBounds,
Expand Down Expand Up @@ -403,6 +415,8 @@ mod fake {
}
}

impl super::ZeroKmsKeySource for FakeDataKeySource {}

impl DataKeySource for FakeDataKeySource {
async fn generate_keys(
&self,
Expand Down Expand Up @@ -529,6 +543,8 @@ mod deterministic {
}
}

impl super::ZeroKmsKeySource for DeterministicSource {}

impl DataKeySource for DeterministicSource {
async fn generate_keys(
&self,
Expand Down
4 changes: 3 additions & 1 deletion packages/stack-kms/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ pub use backend::{KeyBackend, KeyMode, KeyVendor};
pub use key_source::DeterministicSource;
#[cfg(feature = "test-support")]
pub use key_source::FakeDataKeySource;
pub use key_source::{DataKeySource, IndexKeySource, KeyLifetime, LoadedIndexKey, SelectedKeyset};
pub use key_source::{
DataKeySource, IndexKeySource, KeyLifetime, LoadedIndexKey, SelectedKeyset, ZeroKmsKeySource,
};

// Key providers
pub use key_provider::{
Expand Down
Loading