diff --git a/.spelling b/.spelling index b255437f6..48c4d10fb 100644 --- a/.spelling +++ b/.spelling @@ -11,6 +11,8 @@ 5xx = >= +AAD +AEAD ABA ABI ACLs @@ -134,6 +136,7 @@ RAII RDME RMW RMWs +RNG RPC RSS Rc @@ -250,6 +253,7 @@ chainable chrono chunked chunkless +ciphertext clippt clippy clonable @@ -273,6 +277,14 @@ covariant coverage.json crates.io crypto +cryptographic +cryptographically +CryptProtectData +DPAPI +keystream +SymCrypt +undecryptable +unprotect customizable cutover dSMS @@ -285,6 +297,8 @@ dec decrement decrementer decrementers +decrypt +decrypts deduplicating deduplication deps @@ -334,6 +348,7 @@ freelist freezable frontend fundle +GCM gRPC getter getters @@ -444,6 +459,8 @@ passthrough performant pessimizes pointee +plaintext +pluggable polyfill pre-approved pre-generate diff --git a/Cargo.lock b/Cargo.lock index 8eae0adda..35f28fd9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,6 +696,7 @@ dependencies = [ "dashmap", "futures", "layered", + "mutants", "parking_lot", "pin-project-lite", "postcard", diff --git a/crates/cachet/Cargo.toml b/crates/cachet/Cargo.toml index 562baf490..1d0fa1515 100644 --- a/crates/cachet/Cargo.toml +++ b/crates/cachet/Cargo.toml @@ -48,6 +48,7 @@ test-util = ["cachet_tier/test-util", "tick/test-util"] memory = ["dep:cachet_memory"] service = ["dep:cachet_service", "dep:layered"] serialize = ["dep:serde", "dep:postcard", "dep:bytesbuf"] +encrypt = ["dep:bytesbuf"] telemetry = [] [dependencies] @@ -74,6 +75,7 @@ cachet_tier = { path = "../cachet_tier", features = ["test-util"] } criterion = { workspace = true } ctor = { workspace = true } dashmap = { workspace = true } +mutants = { workspace = true } postcard = { workspace = true } recoverable = { path = "../recoverable" } seatbelt = { path = "../seatbelt", features = ["retry", "tower-service"] } diff --git a/crates/cachet/README.md b/crates/cachet/README.md index bb8a97556..5c73fe3db 100644 --- a/crates/cachet/README.md +++ b/crates/cachet/README.md @@ -165,6 +165,7 @@ most commonly used types from all of them. |`logs`|❌|Enables structured `tracing` log events for every cache operation. Subscribe via [`telemetry::attributes`][__link18] constants.| |`service`|❌|Enables `ServiceAdapter`, `CacheServiceExt`, and `CacheOperation`/`CacheResponse` types for service middleware integration.| |`serialize`|❌|Enables `.serialize()` on builders for automatic postcard serialization of keys and values to `BytesView`.| +|`encrypt`|❌|Enables `.protect_with(protector)` on serialized builders and the `ValueProtector` trait for authenticated value protection with a caller-supplied implementation.| |`test-util`|❌|Enables `MockCache`, frozen-clock utilities, and other test helpers.| ## Examples @@ -226,6 +227,114 @@ let cache = Cache::builder::(clock) cache.insert("key".to_string(), "value".to_string()).await?; ``` +### Encryption Boundary + +With the `encrypt` feature, chain `.protect_with(protector)` after `.serialize()` to +protect values with a caller-supplied `ValueProtector` before they reach the +fallback tier. The cachet crate ships only the protection *mechanism* — it has **no +cryptographic dependency of its own**, so you plug in a protector backed by whichever +approved cryptographic library your project mandates. The protector receives each +value’s storage key as its context and must bind it, which cryptographically binds +every value to its key. (The protect/unprotect contract mirrors OS data-protection +APIs such as the Windows DPAPI `CryptProtectData` function.) + +Only values are protected: keys are left serialized-but-unprotected so they remain +deterministic and can be looked up — so do not place secrets or PII in cache keys. +A stored value that fails to unprotect (corrupt, truncated, wrong key, tampered, or +relocated to a different key) is treated as a cache miss and emits a +`cache.unprotect_failed` telemetry event. + +```rust +use cachet::Cache; +use tick::Clock; + +let clock = Clock::new_tokio(); +let remote = Cache::builder::(clock.clone()).memory(); + +let cache = Cache::builder::(clock) + .memory() + .serialize() + .protect_with(my_protector) // any `ValueProtector` implementation + .fallback(remote) + .build(); + +cache.insert("key".to_string(), "value".to_string()).await?; +``` + +#### Example: a `SymCrypt`-backed AES-256-GCM protector + +[SymCrypt][__link20] is a FIPS-certifiable, +SDL-approved cryptographic library. The following `ValueProtector` implementation +wraps it using the [`symcrypt`][__link21] crate; it stores +each value as `nonce || ciphertext || tag` with a fresh random 96-bit nonce and +binds the storage key as associated data. It is shown here as a reference rather +than shipped as a compiled feature, because `SymCrypt` requires the native library +to be present at build and run time. Add `symcrypt` and `getrandom` to your own +crate to use it. + +```rust +use bytesbuf::BytesView; +use cachet::{DecodeOutcome, Error, ValueProtector}; +use symcrypt::cipher::BlockCipherType; +use symcrypt::gcm::GcmExpandedKey; + +const NONCE_SIZE: usize = 12; +const TAG_SIZE: usize = 16; + +pub struct Aes256GcmProtector { + key: GcmExpandedKey, +} + +impl Aes256GcmProtector { + pub fn new(key: &[u8; 32]) -> Self { + let key = GcmExpandedKey::new(key, BlockCipherType::AesBlock) + .expect("AES-256-GCM key expansion cannot fail for a valid 32-byte key"); + Self { key } + } +} + +impl ValueProtector for Aes256GcmProtector { + fn protect(&self, context: &[u8], plaintext: &BytesView) -> Result { + let mut nonce = [0u8; NONCE_SIZE]; + getrandom::fill(&mut nonce).map_err(|e| Error::from_message(format!("nonce: {e}")))?; + + // Assemble `nonce || plaintext || tag`, copying the plaintext in once, then + // encrypt the ciphertext region in place and write the tag into the tail. + let plaintext_len = plaintext.len(); + let mut result = vec![0u8; NONCE_SIZE + plaintext_len + TAG_SIZE]; + result[..NONCE_SIZE].copy_from_slice(&nonce); + let mut offset = NONCE_SIZE; + for (slice, _) in plaintext.slices() { + result[offset..offset + slice.len()].copy_from_slice(slice); + offset += slice.len(); + } + let (head, tag) = result.split_at_mut(NONCE_SIZE + plaintext_len); + self.key.encrypt_in_place(&nonce, context, &mut head[NONCE_SIZE..], tag); + Ok(result.into()) + } + + fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error> { + let bytes = protected.to_vec(); + if bytes.len() < NONCE_SIZE + TAG_SIZE { + return Ok(DecodeOutcome::SoftFailure("ciphertext too short")); + } + let (nonce, rest) = bytes.split_at(NONCE_SIZE); + let (body, tag) = rest.split_at(rest.len() - TAG_SIZE); + let nonce: &[u8; NONCE_SIZE] = nonce.try_into().expect("exactly 12 bytes"); + + let mut buffer = body.to_vec(); + match self.key.decrypt_in_place(nonce, context, &mut buffer, tag) { + // Any authentication failure is a soft failure: the entry reads as a miss. + Ok(()) => Ok(DecodeOutcome::Value(buffer.into())), + Err(_) => Ok(DecodeOutcome::SoftFailure("AES-GCM decryption failed")), + } + } +} +``` + +Because each protect uses a fresh random 96-bit nonce, rotate the key periodically +under extreme write volumes to stay well within the birthday bound. + ## Telemetry Cachet provides two complementary telemetry channels: @@ -233,13 +342,13 @@ Cachet provides two complementary telemetry channels: ### Tracing events Enable with the `logs` feature and `.enable_logs()` on the cache builder. -Each tier outcome and operation completion emits a structured [`tracing`][__link20] event. +Each tier outcome and operation completion emits a structured [`tracing`][__link22] event. **Tier events** carry `cache.name`, `cache.event`, and `cache.duration_ns`. **Operation-complete events** carry `cache.name`, `cache.operation`, `cache.duration_ns`, and `cache.coalesced`. -Use [`telemetry::attributes`][__link21] constants to filter and match events in a +Use [`telemetry::attributes`][__link23] constants to filter and match events in a custom `tracing_subscriber::Layer`: ```rust @@ -265,10 +374,10 @@ See the `telemetry_subscriber` example for a complete demonstration. ### Event handler callback API -Register a [`CacheEventHandler`][__link22] via +Register a [`CacheEventHandler`][__link24] via `.event_handler(handler)` on the cache builder to receive typed -[`CacheTierEvent`][__link23] and -[`CacheOperationEvent`][__link24] callbacks. +[`CacheTierEvent`][__link25] and +[`CacheOperationEvent`][__link26] callbacks. Events carry a `request_id` for correlating tier outcomes with their parent operation. Works independently of the `logs` feature. @@ -280,7 +389,7 @@ See the `telemetry_accumulator` example for a DashMap-based accumulation pattern This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQb11VxC_uAPOQbtUn4Wx2-BfAbid3Nt1Y27Pobprn8Z6FjFy9hYvRhcoQb_xlIDv3a6WgboIYzdhk5tYwbm8NaNvZXwrcbhIXs0eaeycFhZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC45LjCCbWNhY2hldF9tZW1vcnllMC41LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQb11VxC_uAPOQbtUn4Wx2-BfAbid3Nt1Y27Pobprn8Z6FjFy9hYvRhcoQbuPGLEPMCj2MbgA5ER0E3BJMbfCIg-AE0UIgbL0L1ZOiGk45hZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC45LjCCbWNhY2hldF9tZW1vcnllMC41LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA [__link0]: https://docs.rs/cachet/0.9.0/cachet/?search=TimeToRefresh [__link1]: https://crates.io/crates/uniflight/0.3.0 [__link10]: https://docs.rs/cachet_tier/0.2.6/cachet_tier/?search=CacheTier @@ -294,11 +403,13 @@ This crate was developed as part of ()) + .field("V", &std::any::type_name::()) + .finish_non_exhaustive() + } +} + +// ── .protect_with() on TransformBuilder ── + +impl TransformBuilder { + /// Protects values with the given [`ValueProtector`](crate::ValueProtector) before + /// they reach the post-transform tier. + /// + /// Available once values are [`BytesView`] (typically after + /// [`serialize`](crate::CacheBuilder::serialize)). Supply a protector backed by + /// your approved cryptographic library; it receives the storage key as its context + /// and must bind it (see the [`ValueProtector`](crate::ValueProtector) contract). + /// Keys themselves are never protected, and a value that fails authentication is + /// treated as a cache miss. + /// + /// # Examples + /// + /// ```ignore + /// use cachet::{Cache, DecodeOutcome, Error, ValueProtector}; + /// use tick::Clock; + /// + /// struct MyProtector; + /// impl ValueProtector for MyProtector { + /// fn protect( + /// &self, + /// context: &[u8], + /// plaintext: &bytesbuf::BytesView, + /// ) -> Result { + /// # unimplemented!() + /// // ... protect with your approved library, binding `context` ... + /// } + /// fn unprotect( + /// &self, + /// context: &[u8], + /// protected: &bytesbuf::BytesView, + /// ) -> Result, Error> { + /// # unimplemented!() + /// // ... recover, returning SoftFailure on any authentication failure ... + /// } + /// } + /// + /// let clock = Clock::new_tokio(); + /// let remote = Cache::builder::(clock.clone()).memory(); + /// + /// let cache = Cache::builder::(clock) + /// .memory() + /// .serialize() + /// .protect_with(MyProtector) + /// .fallback(remote) + /// .build(); + /// ``` + #[must_use] + pub fn protect_with(self, protector: impl ValueProtector + 'static) -> ProtectedTransformBuilder { + ProtectedTransformBuilder { + pre: self.pre, + post: self.post, + key_encoder: self.key_encoder, + value_codec: self.value_codec, + protector: Box::new(protector), + clock: self.clock, + telemetry: self.telemetry, + stampede_protection: self.stampede_protection, + } + } +} + +// ── .fallback() on ProtectedTransformBuilder ── + +impl ProtectedTransformBuilder { + /// Sets the first post-transform storage tier (speaks encrypted `BytesView`). + pub fn fallback(self, fallback: FB) -> ProtectedTransformBuilder + where + FB: CacheTierBuilder, + { + ProtectedTransformBuilder { + pre: self.pre, + post: fallback, + key_encoder: self.key_encoder, + value_codec: self.value_codec, + protector: self.protector, + clock: self.clock, + telemetry: self.telemetry, + stampede_protection: self.stampede_protection, + } + } +} + +impl ProtectedTransformBuilder +where + Post: CacheTierBuilder, +{ + /// Adds another post-transform fallback tier (speaks encrypted `BytesView`). + pub fn fallback(self, fallback: FB) -> ProtectedTransformBuilder> + where + FB: CacheTierBuilder, + { + let clock = self.clock.clone(); + let telemetry = self.telemetry.clone(); + let stampede_protection = self.stampede_protection; + + let post_chain = FallbackBuilder { + name: None, + primary_builder: self.post, + fallback_builder: fallback, + clock: clock.clone(), + refresh: None, + telemetry: telemetry.clone(), + stampede_protection, + _phantom: PhantomData, + }; + + ProtectedTransformBuilder { + pre: self.pre, + post: post_chain, + key_encoder: self.key_encoder, + value_codec: self.value_codec, + protector: self.protector, + clock, + telemetry, + stampede_protection, + } + } +} + +// ── Sealed + CacheTierBuilder (allow nesting an encrypted transform) ── + +impl Sealed for ProtectedTransformBuilder +where + K: Clone + Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, +{ +} + +impl CacheTierBuilder for ProtectedTransformBuilder +where + K: Clone + Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, +{ +} + +// ── .build() on ProtectedTransformBuilder ── + +#[expect(private_bounds, reason = "Buildable is an internal trait")] +impl ProtectedTransformBuilder +where + K: Clone + Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + Pre: Buildable, + Post: Buildable, +{ + /// Builds the full cache hierarchy with the encrypted transform boundary. + pub fn build(self) -> crate::Cache { + >::build(self) + } +} + +impl Buildable for ProtectedTransformBuilder +where + K: Clone + Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + Pre: Buildable, + Post: Buildable, +{ + type TierOutput = DynamicCache; + + fn build(self) -> crate::Cache { + let clock = self.clock.clone(); + let telemetry = self.telemetry.clone(); + let stampede_protection = self.stampede_protection; + let tier = self.build_tier(clock.clone(), telemetry.clone(), false); + + crate::Cache::new(type_name::(None), tier, clock, telemetry, stampede_protection) + } + + fn build_tier(self, clock: Clock, telemetry: CacheTelemetry, fallback: bool) -> Self::TierOutput { + let pre_tier = self.pre.build_tier(clock.clone(), telemetry.clone(), fallback); + + // Build the post-transform tier chain and wrap it so values are protected + // (and key-bound) before reaching it. + let post_tier = self.post.build_tier(clock.clone(), telemetry.clone(), true); + let protected = ProtectedTier::new( + post_tier, + self.protector, + telemetry.clone(), + type_name::>(None), + ); + let adapted = TransformAdapter::from_boxed(protected, self.key_encoder, self.value_codec); + + let fallback = crate::fallback::FallbackCache::new(type_name::(None), pre_tier, adapted, clock, None, telemetry); + + DynamicCache::new(fallback) + } +} diff --git a/crates/cachet/src/builder/mod.rs b/crates/cachet/src/builder/mod.rs index 292f417cb..aad95013a 100644 --- a/crates/cachet/src/builder/mod.rs +++ b/crates/cachet/src/builder/mod.rs @@ -8,6 +8,8 @@ mod buildable; mod cache; +#[cfg(feature = "encrypt")] +mod encrypt; mod fallback; mod sealed; #[cfg(any(feature = "serialize", test))] @@ -15,6 +17,8 @@ mod serialize; mod transform; pub use cache::CacheBuilder; +#[cfg(feature = "encrypt")] +pub use encrypt::ProtectedTransformBuilder; pub use fallback::FallbackBuilder; pub use sealed::CacheTierBuilder; pub use transform::TransformBuilder; diff --git a/crates/cachet/src/builder/transform.rs b/crates/cachet/src/builder/transform.rs index 97a48e6f7..000d0a794 100644 --- a/crates/cachet/src/builder/transform.rs +++ b/crates/cachet/src/builder/transform.rs @@ -26,14 +26,14 @@ use crate::{CacheTier, Codec, Encoder}; /// At build time, both sides are built into tiers, the post-transform tier is wrapped /// in a `TransformAdapter`, and combined with the pre-transform tier via fallback. pub struct TransformBuilder { - pre: Pre, - post: Post, - key_encoder: Box>, - value_codec: Box>, - clock: Clock, - telemetry: CacheTelemetry, - stampede_protection: bool, - _phantom: PhantomData<(K, V, KT, VT)>, + pub(super) pre: Pre, + pub(super) post: Post, + pub(super) key_encoder: Box>, + pub(super) value_codec: Box>, + pub(super) clock: Clock, + pub(super) telemetry: CacheTelemetry, + pub(super) stampede_protection: bool, + pub(super) _phantom: PhantomData<(K, V, KT, VT)>, } impl Debug for TransformBuilder { diff --git a/crates/cachet/src/lib.rs b/crates/cachet/src/lib.rs index 8a11de185..0ccfe5a3f 100644 --- a/crates/cachet/src/lib.rs +++ b/crates/cachet/src/lib.rs @@ -155,6 +155,7 @@ //! | `logs` | ❌ | Enables structured `tracing` log events for every cache operation. Subscribe via [`telemetry::attributes`] constants. | //! | `service` | ❌ | Enables `ServiceAdapter`, `CacheServiceExt`, and `CacheOperation`/`CacheResponse` types for service middleware integration. | //! | `serialize` | ❌ | Enables `.serialize()` on builders for automatic postcard serialization of keys and values to `BytesView`. | +//! | `encrypt` | ❌ | Enables `.protect_with(protector)` on serialized builders and the `ValueProtector` trait for authenticated value protection with a caller-supplied implementation. | //! | `test-util` | ❌ | Enables `MockCache`, frozen-clock utilities, and other test helpers. | //! //! # Examples @@ -224,6 +225,117 @@ //! # }; //! ``` //! +//! ## Encryption Boundary +//! +//! With the `encrypt` feature, chain `.protect_with(protector)` after `.serialize()` to +//! protect values with a caller-supplied `ValueProtector` before they reach the +//! fallback tier. The cachet crate ships only the protection *mechanism* — it has **no +//! cryptographic dependency of its own**, so you plug in a protector backed by whichever +//! approved cryptographic library your project mandates. The protector receives each +//! value's storage key as its context and must bind it, which cryptographically binds +//! every value to its key. (The protect/unprotect contract mirrors OS data-protection +//! APIs such as the Windows DPAPI `CryptProtectData` function.) +//! +//! Only values are protected: keys are left serialized-but-unprotected so they remain +//! deterministic and can be looked up — so do not place secrets or PII in cache keys. +//! A stored value that fails to unprotect (corrupt, truncated, wrong key, tampered, or +//! relocated to a different key) is treated as a cache miss and emits a +//! `cache.unprotect_failed` telemetry event. +//! +//! ```ignore +//! use cachet::Cache; +//! use tick::Clock; +//! # async { +//! +//! let clock = Clock::new_tokio(); +//! let remote = Cache::builder::(clock.clone()).memory(); +//! +//! let cache = Cache::builder::(clock) +//! .memory() +//! .serialize() +//! .protect_with(my_protector) // any `ValueProtector` implementation +//! .fallback(remote) +//! .build(); +//! +//! cache.insert("key".to_string(), "value".to_string()).await?; +//! # Ok::<(), cachet::Error>(()) +//! # }; +//! ``` +//! +//! ### Example: a `SymCrypt`-backed AES-256-GCM protector +//! +//! [SymCrypt](https://github.com/microsoft/SymCrypt) is a FIPS-certifiable, +//! SDL-approved cryptographic library. The following `ValueProtector` implementation +//! wraps it using the [`symcrypt`](https://crates.io/crates/symcrypt) crate; it stores +//! each value as `nonce || ciphertext || tag` with a fresh random 96-bit nonce and +//! binds the storage key as associated data. It is shown here as a reference rather +//! than shipped as a compiled feature, because `SymCrypt` requires the native library +//! to be present at build and run time. Add `symcrypt` and `getrandom` to your own +//! crate to use it. +//! +//! ```ignore +//! use bytesbuf::BytesView; +//! use cachet::{DecodeOutcome, Error, ValueProtector}; +//! use symcrypt::cipher::BlockCipherType; +//! use symcrypt::gcm::GcmExpandedKey; +//! +//! const NONCE_SIZE: usize = 12; +//! const TAG_SIZE: usize = 16; +//! +//! pub struct Aes256GcmProtector { +//! key: GcmExpandedKey, +//! } +//! +//! impl Aes256GcmProtector { +//! pub fn new(key: &[u8; 32]) -> Self { +//! let key = GcmExpandedKey::new(key, BlockCipherType::AesBlock) +//! .expect("AES-256-GCM key expansion cannot fail for a valid 32-byte key"); +//! Self { key } +//! } +//! } +//! +//! impl ValueProtector for Aes256GcmProtector { +//! fn protect(&self, context: &[u8], plaintext: &BytesView) -> Result { +//! let mut nonce = [0u8; NONCE_SIZE]; +//! getrandom::fill(&mut nonce).map_err(|e| Error::from_message(format!("nonce: {e}")))?; +//! +//! // Assemble `nonce || plaintext || tag`, copying the plaintext in once, then +//! // encrypt the ciphertext region in place and write the tag into the tail. +//! let plaintext_len = plaintext.len(); +//! let mut result = vec![0u8; NONCE_SIZE + plaintext_len + TAG_SIZE]; +//! result[..NONCE_SIZE].copy_from_slice(&nonce); +//! let mut offset = NONCE_SIZE; +//! for (slice, _) in plaintext.slices() { +//! result[offset..offset + slice.len()].copy_from_slice(slice); +//! offset += slice.len(); +//! } +//! let (head, tag) = result.split_at_mut(NONCE_SIZE + plaintext_len); +//! self.key.encrypt_in_place(&nonce, context, &mut head[NONCE_SIZE..], tag); +//! Ok(result.into()) +//! } +//! +//! fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error> { +//! let bytes = protected.to_vec(); +//! if bytes.len() < NONCE_SIZE + TAG_SIZE { +//! return Ok(DecodeOutcome::SoftFailure("ciphertext too short")); +//! } +//! let (nonce, rest) = bytes.split_at(NONCE_SIZE); +//! let (body, tag) = rest.split_at(rest.len() - TAG_SIZE); +//! let nonce: &[u8; NONCE_SIZE] = nonce.try_into().expect("exactly 12 bytes"); +//! +//! let mut buffer = body.to_vec(); +//! match self.key.decrypt_in_place(nonce, context, &mut buffer, tag) { +//! // Any authentication failure is a soft failure: the entry reads as a miss. +//! Ok(()) => Ok(DecodeOutcome::Value(buffer.into())), +//! Err(_) => Ok(DecodeOutcome::SoftFailure("AES-GCM decryption failed")), +//! } +//! } +//! } +//! ``` +//! +//! Because each protect uses a fresh random 96-bit nonce, rotate the key periodically +//! under extreme write volumes to stay well within the birthday bound. +//! //! # Telemetry //! //! Cachet provides two complementary telemetry channels: @@ -285,6 +397,9 @@ pub mod telemetry; mod transform; mod wrapper; +#[cfg(feature = "encrypt")] +#[doc(inline)] +pub use builder::ProtectedTransformBuilder; #[doc(inline)] pub use builder::{CacheBuilder, CacheTierBuilder, FallbackBuilder, TransformBuilder}; #[doc(inline)] @@ -308,6 +423,12 @@ pub use policy::InsertPolicy; pub use refresh::TimeToRefresh; #[doc(inline)] pub use telemetry::handler::{CacheEventHandler, CacheOperationEvent, CacheTierEvent}; +#[cfg(all(feature = "encrypt", any(feature = "test-util", test)))] +#[doc(inline)] +pub use transform::MockValueProtector; +#[cfg(feature = "encrypt")] +#[doc(inline)] +pub use transform::ValueProtector; #[doc(inline)] pub use transform::{Codec, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, infallible, infallible_owned}; diff --git a/crates/cachet/src/telemetry/attributes.rs b/crates/cachet/src/telemetry/attributes.rs index b867cf094..0f8e590d4 100644 --- a/crates/cachet/src/telemetry/attributes.rs +++ b/crates/cachet/src/telemetry/attributes.rs @@ -8,8 +8,9 @@ //! //! **Tier events** (hit, miss, expired, etc.) carry `FIELD_NAME`, `FIELD_EVENT`, //! and `FIELD_DURATION_NS`. Some events intentionally omit `FIELD_DURATION_NS` -//! to indicate "not timed": `EVENT_INSERT_REJECTED`, `EVENT_EVICTION`, and -//! background `EVENT_EXPIRED` events emitted from eviction listeners. +//! to indicate "not timed": `EVENT_INSERT_REJECTED`, `EVENT_EVICTION`, +//! `EVENT_UNPROTECT_FAILED`, and background `EVENT_EXPIRED` events emitted from +//! eviction listeners. //! //! **Operation-complete events** carry `FIELD_NAME`, `FIELD_OPERATION`, //! `FIELD_DURATION_NS`, and `FIELD_COALESCED`. @@ -99,6 +100,13 @@ pub const EVENT_REFRESH_MISS: &str = "cache.refresh_miss"; /// Only emitted when eviction telemetry is enabled. pub const EVENT_EVICTION: &str = "cache.eviction"; +/// A stored value failed authentication and could not be recovered, so it was +/// treated as a miss. +/// +/// Only emitted when the `encrypt` feature is enabled. Signals a corrupt, +/// truncated, wrong-key, tampered, or relocated value. +pub const EVENT_UNPROTECT_FAILED: &str = "cache.unprotect_failed"; + #[cfg(test)] mod tests { use super::*; @@ -130,6 +138,7 @@ mod tests { EVENT_REFRESH_HIT, EVENT_REFRESH_MISS, EVENT_EVICTION, + EVENT_UNPROTECT_FAILED, ]; for (i, a) in events.iter().enumerate() { diff --git a/crates/cachet/src/telemetry/cache.rs b/crates/cachet/src/telemetry/cache.rs index 58c060235..4b3de4e86 100644 --- a/crates/cachet/src/telemetry/cache.rs +++ b/crates/cachet/src/telemetry/cache.rs @@ -372,6 +372,33 @@ impl CacheTelemetry { ); } + /// Records that a stored value failed authentication and could not be + /// recovered, so it was treated as a cache miss. + /// + /// Fires from a `ProtectedTier` on the `get` path, so the thread-local + /// request ID is set and correlates the failure with the operation that + /// observed it. Signals a corrupt, truncated, wrong-key, tampered, or + /// relocated value. The protected tier always sits on the post-transform + /// (fallback) side of the hierarchy, so the event is tagged + /// `fallback = true` to match the tier's other events. Like other validation + /// outcomes (e.g. `insert_rejected`), it is not a timed operation and so omits + /// `cache.duration_ns`. + #[cfg(feature = "encrypt")] + pub(crate) fn record_unprotect_failure(&self, cache_name: CacheName) { + #[cfg(any(feature = "logs", test))] + if self.logging_enabled { + tracing::warn!(cache.name = cache_name, cache.event = attributes::EVENT_UNPROTECT_FAILED); + } + + self.emit_tier_event( + Self::current_request_id(), + cache_name, + attributes::EVENT_UNPROTECT_FAILED, + Duration::ZERO, + true, + ); + } + pub(crate) fn complete_operation( &self, request_id: RequestId, diff --git a/crates/cachet/src/transform/encrypt/mock.rs b/crates/cachet/src/transform/encrypt/mock.rs new file mode 100644 index 000000000..a2bfe599a --- /dev/null +++ b/crates/cachet/src/transform/encrypt/mock.rs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! A deterministic, crypto-free [`ValueProtector`] test double. + +use bytesbuf::BytesView; + +use super::ValueProtector; +use crate::Error; +use crate::transform::DecodeOutcome; + +/// Length of the mock protector's nonce prefix, in bytes. +const MOCK_NONCE_SIZE: usize = 12; + +/// A deterministic, crypto-free [`ValueProtector`] for tests. +/// +/// Available with the `test-util` feature. Use it to exercise a +/// [`protect_with`](crate::TransformBuilder::protect_with) pipeline — round-trips, key +/// binding, and unprotect failures — without a real cryptographic library or a source +/// of entropy, keeping tests fast and reproducible. +/// +/// The stored form is `nonce || context_len(8, LE) || context || masked_body`. The +/// nonce comes from a monotonic counter (so repeated `protect` calls of identical input +/// still differ, yet stay reproducible), and `masked_body` is the plaintext combined +/// with a nonce-derived keystream via XOR. It binds the `context`: +/// [`unprotect`](ValueProtector::unprotect) returns [`DecodeOutcome::SoftFailure`] +/// unless the caller's `context` matches the stored one *exactly* — same length and +/// bytes — so a value cannot be recovered under a different key, including one that is a +/// prefix or extension of the original. Truncated or corrupt input soft-fails too, +/// mirroring the [`ValueProtector`] security contract. +/// +/// # Security +/// +/// This provides **no confidentiality or integrity** — the transform is trivially +/// reversible and the key is ignored. It is gated behind `test-util` and must never be +/// used in production. +/// +/// # Examples +/// +/// ``` +/// # #[cfg(all(feature = "serialize", feature = "memory"))] { +/// use cachet::{Cache, MockValueProtector}; +/// use tick::Clock; +/// +/// let clock = Clock::new_frozen(); +/// let remote = Cache::builder::(clock.clone()).memory(); +/// let cache = Cache::builder::(clock) +/// .memory() +/// .serialize() +/// .protect_with(MockValueProtector::new()) +/// .fallback(remote) +/// .build(); +/// # } +/// ``` +#[derive(Debug, Default)] +pub struct MockValueProtector { + counter: std::sync::atomic::AtomicU32, +} + +impl MockValueProtector { + /// Creates a new mock protector. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Derives a deterministic nonce from the counter bytes (repeated to fill). + #[cfg_attr(test, mutants::skip)] // Test-only mock: no contract on the exact keystream, only that it is deterministic and reversible (verified by round-trip tests). + fn nonce_bytes(counter: u32) -> [u8; MOCK_NONCE_SIZE] { + let counter_bytes = counter.to_le_bytes(); + std::array::from_fn(|i| counter_bytes[i % counter_bytes.len()]) + } + + /// Reversible keystream transform: `body[i] ^= 0x5A ^ nonce[i % NONCE]`. + #[cfg_attr(test, mutants::skip)] // Test-only mock: no contract on the exact keystream, only that it is deterministic and reversible (verified by round-trip tests). + fn mask(nonce: &[u8; MOCK_NONCE_SIZE], body: &mut [u8]) { + for (i, byte) in body.iter_mut().enumerate() { + *byte ^= 0x5A ^ nonce[i % MOCK_NONCE_SIZE]; + } + } +} + +impl ValueProtector for MockValueProtector { + fn protect(&self, context: &[u8], plaintext: &BytesView) -> Result { + use std::sync::atomic::Ordering; + + let nonce = Self::nonce_bytes(self.counter.fetch_add(1, Ordering::Relaxed)); + let mut out = Vec::with_capacity(MOCK_NONCE_SIZE + 8 + context.len() + plaintext.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&(context.len() as u64).to_le_bytes()); + out.extend_from_slice(context); + let body_start = out.len(); + for (slice, _) in plaintext.slices() { + out.extend_from_slice(slice); + } + Self::mask(&nonce, &mut out[body_start..]); + Ok(BytesView::from(out)) + } + + fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error> { + let bytes = protected.to_vec(); + let Some(nonce) = bytes.get(..MOCK_NONCE_SIZE) else { + return Ok(DecodeOutcome::SoftFailure("mock: truncated nonce")); + }; + let nonce: [u8; MOCK_NONCE_SIZE] = nonce + .try_into() + .expect("nonce slice is MOCK_NONCE_SIZE bytes, guarded by get(..MOCK_NONCE_SIZE) above"); + let rest = &bytes[MOCK_NONCE_SIZE..]; + let Some(len_bytes) = rest.get(..8) else { + return Ok(DecodeOutcome::SoftFailure("mock: truncated length")); + }; + let stored_len = u64::from_le_bytes(len_bytes.try_into().expect("8 bytes, guarded by get(..8) above")); + // The stored context length must match the caller's exactly, so a context that + // is a prefix (or extension) of the stored key is rejected outright rather than + // partially matched. Compared in u64 space to avoid any usize conversion. + if stored_len != context.len() as u64 { + return Ok(DecodeOutcome::SoftFailure("mock: context length mismatch")); + } + let after_len = &rest[8..]; + let Some(stored_context) = after_len.get(..context.len()) else { + return Ok(DecodeOutcome::SoftFailure("mock: truncated context")); + }; + if stored_context != context { + return Ok(DecodeOutcome::SoftFailure("mock: context mismatch")); + } + let mut body = after_len[context.len()..].to_vec(); + Self::mask(&nonce, &mut body); + Ok(DecodeOutcome::Value(BytesView::from(body))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn view(data: &[u8]) -> BytesView { + BytesView::from(data.to_vec()) + } + + #[test] + fn mock_protector_soft_fails_on_malformed_or_mismatched_input() { + let p = MockValueProtector::new(); + let soft = |bytes: Vec| matches!(p.unprotect(b"context", &BytesView::from(bytes)), Ok(DecodeOutcome::SoftFailure(_))); + + // Too short to hold the nonce prefix. + assert!(soft(vec![0u8; 4]), "truncated nonce must soft-fail"); + // Nonce present, but no room for the 8-byte length field. + assert!(soft(vec![0u8; MOCK_NONCE_SIZE]), "truncated length must soft-fail"); + // Length field declares a 7-byte context, but fewer context bytes follow. + let mut truncated_ctx = vec![0u8; MOCK_NONCE_SIZE]; + truncated_ctx.extend_from_slice(&7u64.to_le_bytes()); + truncated_ctx.extend_from_slice(b"abc"); + assert!(soft(truncated_ctx), "truncated context must soft-fail"); + // A key of which the read context is a strict prefix must NOT match (relocation). + let extended = p.protect(b"context-long", &view(b"value")).expect("protect should succeed"); + assert!(soft(extended.to_vec()), "prefix/length-mismatched context must soft-fail"); + // A blob protected under a different (same-length) context must not match. + let other = p.protect(b"kontext", &view(b"value")).expect("protect should succeed"); + assert!(soft(other.to_vec()), "context mismatch must soft-fail"); + // A well-formed round-trip under the expected context must NOT soft-fail. + let valid = p.protect(b"context", &view(b"value")).expect("protect should succeed"); + assert!(!soft(valid.to_vec()), "a valid round-trip must recover, not soft-fail"); + } +} diff --git a/crates/cachet/src/transform/encrypt/mod.rs b/crates/cachet/src/transform/encrypt/mod.rs new file mode 100644 index 000000000..34b61eb39 --- /dev/null +++ b/crates/cachet/src/transform/encrypt/mod.rs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Authenticated protection of cache values stored in an untrusted tier. +//! +//! This provides only the protection *mechanism* — it carries no cryptographic +//! dependency of its own. [`ValueProtector`] is the pluggable contract: you supply the +//! actual implementation, backed by your approved cryptographic library, and register +//! it with [`protect_with`](crate::TransformBuilder::protect_with). [`ProtectedTier`] +//! installs that protector at the storage boundary, where both the key and value are +//! available, and binds each value to its storage key. +//! +//! See the crate-level "Encryption Boundary" docs for a reference [`ValueProtector`] +//! implementation backed by `SymCrypt` (FIPS-certifiable AES-256-GCM). + +#[cfg(any(feature = "test-util", test))] +mod mock; +mod protector; +mod tier; + +#[cfg(any(feature = "test-util", test))] +pub use mock::MockValueProtector; +pub use protector::ValueProtector; +pub(crate) use tier::ProtectedTier; diff --git a/crates/cachet/src/transform/encrypt/protector.rs b/crates/cachet/src/transform/encrypt/protector.rs new file mode 100644 index 000000000..9d030de73 --- /dev/null +++ b/crates/cachet/src/transform/encrypt/protector.rs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The [`ValueProtector`] authenticated-protection contract. + +use bytesbuf::BytesView; + +use crate::Error; +use crate::transform::DecodeOutcome; + +/// Authenticated protection of cache values before they reach an untrusted tier. +/// +/// Implementations turn a value's plaintext bytes into stored bytes and back, binding +/// a caller-supplied *context* value. `ProtectedTier` passes the entry's storage key as +/// the context, so a value is cryptographically bound to the key it was stored under. +/// +/// This trait supplies no implementation of its own: implement it with your +/// organization's approved cryptographic library and register it via +/// [`protect_with`](crate::TransformBuilder::protect_with). See the crate-level +/// "Encryption Boundary" docs for a reference `SymCrypt`-backed implementation. +/// +/// # Security contract +/// +/// Implementors **must** bind `context`: [`unprotect`](Self::unprotect) must return +/// [`DecodeOutcome::SoftFailure`] when the `context` does not match the value supplied +/// to [`protect`](Self::protect). This is what binds each value to its storage key, +/// preventing a value from being relocated to a different key in the backing store. +/// Implementors using a nonce-based scheme are responsible for nonce discipline — use +/// a fresh nonce per [`protect`](Self::protect), or a nonce-misuse-resistant scheme. +/// +/// [`unprotect`](Self::unprotect) distinguishes two failure modes: +/// - `Ok(DecodeOutcome::SoftFailure(_))` — the stored value is unrecoverable (corrupt, +/// truncated, tampered, wrong key, or context mismatch); the cache treats it as a +/// miss. +/// - `Err(_)` — the operation could not be attempted (e.g. an unavailable backend); +/// the error propagates to the caller. +pub trait ValueProtector: Send + Sync { + /// Protects `plaintext`, binding `context`, and returns the stored representation. + /// + /// # Errors + /// + /// Returns an error if protection cannot be performed. + fn protect(&self, context: &[u8], plaintext: &BytesView) -> Result; + + /// Recovers a value previously protected under `context`. + /// + /// # Errors + /// + /// Returns `Err` only if the operation could not be attempted. An authentication or + /// format failure is reported as `Ok(DecodeOutcome::SoftFailure(_))`. + fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error>; +} diff --git a/crates/cachet/src/transform/encrypt/tier.rs b/crates/cachet/src/transform/encrypt/tier.rs new file mode 100644 index 000000000..9dfc435c0 --- /dev/null +++ b/crates/cachet/src/transform/encrypt/tier.rs @@ -0,0 +1,318 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The [`ProtectedTier`] cache tier that applies a [`ValueProtector`] at the boundary. + +use std::borrow::Cow; + +use bytesbuf::BytesView; + +use super::ValueProtector; +use crate::cache::CacheName; +use crate::telemetry::CacheTelemetry; +use crate::transform::DecodeOutcome; +use crate::{CacheEntry, CacheTier, Error, SizeError}; + +/// Returns a contiguous byte slice from a [`BytesView`]. Borrows for single-span +/// views (the common case) and gathers into a `Vec` only for multi-span views. +fn to_contiguous(view: &BytesView) -> Cow<'_, [u8]> { + let first = view.first_slice(); + if first.len() == view.len() { + Cow::Borrowed(first) + } else { + let mut buf = Vec::with_capacity(view.len()); + for (slice, _) in view.slices() { + buf.extend_from_slice(slice); + } + Cow::Owned(buf) + } +} + +/// A cache tier that transparently protects values with a [`ValueProtector`]. +/// +/// It wraps an inner `CacheTier` (typically a remote tier +/// holding serialized bytes). On insert it protects the value, binding the storage key +/// as context; on get it recovers it, and an authentication failure — corrupt bytes, a +/// tampered entry, or a value relocated from a different key — surfaces as a cache miss +/// (`Ok(None)`) rather than an error. Each such failure emits a `cache.unprotect_failed` +/// telemetry event so that tampering with the backing store is observable rather than +/// silent. +pub(crate) struct ProtectedTier { + inner: S, + protector: Box, + telemetry: CacheTelemetry, + name: CacheName, +} + +impl ProtectedTier { + pub(crate) fn new(inner: S, protector: Box, telemetry: CacheTelemetry, name: CacheName) -> Self { + Self { + inner, + protector, + telemetry, + name, + } + } +} + +impl std::fmt::Debug for ProtectedTier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProtectedTier").field("inner", &self.inner).finish_non_exhaustive() + } +} + +impl CacheTier for ProtectedTier +where + S: CacheTier + Send + Sync, +{ + async fn get(&self, key: &BytesView) -> Result>, Error> { + let Some(entry) = self.inner.get(key).await? else { + return Ok(None); + }; + let ttl = entry.ttl(); + let cached_at = entry.cached_at(); + let value = entry.into_value(); + // The storage key is bound as context, so a value planted under the + // wrong key fails to unprotect and is treated as a miss. + let context = to_contiguous(key); + match self.protector.unprotect(context.as_ref(), &value)? { + DecodeOutcome::Value(value) => { + let mut recovered = CacheEntry::new(value); + if let Some(ttl) = ttl { + recovered.set_ttl(ttl); + } + if let Some(cached_at) = cached_at { + recovered.ensure_cached_at(cached_at); + } + Ok(Some(recovered)) + } + DecodeOutcome::SoftFailure(_) => { + self.telemetry.record_unprotect_failure(self.name); + Ok(None) + } + } + } + + async fn insert(&self, key: BytesView, entry: CacheEntry) -> Result<(), Error> { + let context = to_contiguous(&key); + let protected = entry.try_map_value(|value| self.protector.protect(context.as_ref(), &value))?; + self.inner.insert(key, protected).await + } + + async fn invalidate(&self, key: &BytesView) -> Result<(), Error> { + self.inner.invalidate(key).await + } + + async fn clear(&self) -> Result<(), Error> { + self.inner.clear().await + } + + async fn len(&self) -> Result { + self.inner.len().await + } +} + +#[cfg(test)] +mod tests { + use cachet_tier::MockCache; + + use super::super::MockValueProtector; + use super::*; + + fn view(data: &[u8]) -> BytesView { + BytesView::from(data.to_vec()) + } + + /// A protector whose operations always hard-error, for exercising error propagation. + struct FailingProtector; + + impl ValueProtector for FailingProtector { + fn protect(&self, _context: &[u8], _plaintext: &BytesView) -> Result { + Err(Error::from_message("protect failed")) + } + + fn unprotect(&self, _context: &[u8], _protected: &BytesView) -> Result, Error> { + Err(Error::from_message("unprotect failed")) + } + } + + fn tier(inner: S) -> ProtectedTier { + ProtectedTier::new(inner, Box::new(MockValueProtector::new()), CacheTelemetry::new(), "encrypted-test") + } + + fn failing_tier(inner: S) -> ProtectedTier { + ProtectedTier::new(inner, Box::new(FailingProtector), CacheTelemetry::new(), "failing-test") + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn round_trips_through_inner() { + let inner = MockCache::::new(); + let tier = tier(inner.clone()); + + let key = view(b"user:1"); + tier.insert(key.clone(), CacheEntry::new(view(b"profile"))) + .await + .expect("insert should succeed"); + + // The inner tier stores the sealed representation, not the plaintext value. + let stored = inner.get(&key).await.expect("inner get ok").expect("entry present"); + assert_ne!(stored.value().to_vec(), b"profile", "inner tier must not hold plaintext"); + + let fetched = tier.get(&key).await.expect("get ok").expect("entry present"); + assert_eq!(fetched.value().to_vec(), b"profile", "decrypted value must match original"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn relocated_value_is_a_miss() { + let inner = MockCache::::new(); + let tier = tier(inner.clone()); + + let key_a = view(b"key-A"); + tier.insert(key_a.clone(), CacheEntry::new(view(b"value-A"))) + .await + .expect("insert should succeed"); + let blob_a = inner.get(&key_a).await.expect("inner get ok").expect("entry present").into_value(); + + // Attacker relocates A's sealed blob under key B in the untrusted inner tier. + let key_b = view(b"key-B"); + inner + .insert(key_b.clone(), CacheEntry::new(blob_a)) + .await + .expect("insert should succeed"); + + // Reading B must NOT yield A's value: AAD (key) mismatch => miss. + assert!( + tier.get(&key_b).await.expect("get ok").is_none(), + "relocated value must fail the AAD check and read as a miss" + ); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn get_miss_returns_none() { + let tier = tier(MockCache::::new()); + assert!( + tier.get(&view(b"absent")).await.expect("get ok").is_none(), + "empty inner tier must miss" + ); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn preserves_ttl_and_cached_at() { + use std::time::{Duration, SystemTime}; + + let tier = tier(MockCache::::new()); + let ttl = Duration::from_mins(5); + let cached_at = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000); + let key = view(b"k"); + + tier.insert(key.clone(), CacheEntry::expires_at(view(b"v"), ttl, cached_at)) + .await + .expect("insert should succeed"); + + let fetched = tier.get(&key).await.expect("get ok").expect("entry present"); + assert_eq!(fetched.value().to_vec(), b"v", "decrypted value must match"); + assert_eq!(fetched.ttl(), Some(ttl), "ttl must survive the round trip"); + assert_eq!(fetched.cached_at(), Some(cached_at), "cached_at must survive the round trip"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn invalidate_clear_len_delegate() { + let tier = tier(MockCache::::new()); + tier.insert(view(b"a"), CacheEntry::new(view(b"1"))) + .await + .expect("insert should succeed"); + tier.insert(view(b"b"), CacheEntry::new(view(b"2"))) + .await + .expect("insert should succeed"); + assert_eq!(tier.len().await.expect("len ok"), 2); + + tier.invalidate(&view(b"a")).await.expect("invalidate should succeed"); + assert_eq!(tier.len().await.expect("len ok"), 1); + + tier.clear().await.expect("clear should succeed"); + assert_eq!(tier.len().await.expect("len ok"), 0); + } + + #[test] + fn debug_omits_inner_secrets() { + let tier = tier(MockCache::::new()); + assert!(format!("{tier:?}").contains("ProtectedTier")); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn decrypt_failure_emits_telemetry() { + use testing_aids::tracing_logs::Capture; + + let capture = Capture::new(); + let _guard = tracing::subscriber::set_default(capture.subscriber()); + + let inner = MockCache::::new(); + let tier = ProtectedTier::new( + inner.clone(), + Box::new(MockValueProtector::new()), + CacheTelemetry::with_logging(), + "encrypted-test", + ); + + // Plant a malformed blob that cannot be decoded. + inner + .insert(view(b"k"), CacheEntry::new(view(&[0u8; 2]))) + .await + .expect("insert should succeed"); + + assert!( + tier.get(&view(b"k")).await.expect("get ok").is_none(), + "undecodable value must read as a miss" + ); + capture.assert_contains(crate::telemetry::attributes::EVENT_UNPROTECT_FAILED); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn encrypt_error_propagates_on_insert() { + let tier = failing_tier(MockCache::::new()); + assert!( + tier.insert(view(b"k"), CacheEntry::new(view(b"v"))).await.is_err(), + "a cipher encryption error must propagate from insert" + ); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn decrypt_error_propagates_on_get() { + let inner = MockCache::::new(); + inner + .insert(view(b"k"), CacheEntry::new(view(b"stored"))) + .await + .expect("insert should succeed"); + + let tier = failing_tier(inner); + assert!( + tier.get(&view(b"k")).await.is_err(), + "a cipher decryption hard error must propagate from get" + ); + } + + #[test] + fn to_contiguous_gathers_every_span() { + // Single-span: returns the full contents (borrowed). + let single = view(b"contiguous"); + assert_eq!(to_contiguous(&single).as_ref(), b"contiguous"); + + // Multi-span: must concatenate ALL spans, not just the first one. + let mut multi = BytesView::from(b"first-".to_vec()); + multi.append(BytesView::from(b"second".to_vec())); + assert_ne!(multi.first_slice().len(), multi.len(), "fixture must be multi-span"); + assert_eq!( + to_contiguous(&multi).as_ref(), + b"first-second", + "to_contiguous must gather every span, not return only the first" + ); + } +} diff --git a/crates/cachet/src/transform/mod.rs b/crates/cachet/src/transform/mod.rs index 16e292d5d..0cce1d0df 100644 --- a/crates/cachet/src/transform/mod.rs +++ b/crates/cachet/src/transform/mod.rs @@ -36,9 +36,17 @@ //! they can be used where a fallible closure is expected. mod codec; +#[cfg(feature = "encrypt")] +mod encrypt; #[cfg(test)] pub(crate) mod testing; mod tier; pub use codec::{Codec, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, infallible, infallible_owned}; +#[cfg(all(feature = "encrypt", any(feature = "test-util", test)))] +pub use encrypt::MockValueProtector; +#[cfg(feature = "encrypt")] +pub(crate) use encrypt::ProtectedTier; +#[cfg(feature = "encrypt")] +pub use encrypt::ValueProtector; pub(crate) use tier::TransformAdapter; diff --git a/crates/cachet/tests/encrypt.rs b/crates/cachet/tests/encrypt.rs new file mode 100644 index 000000000..3a9b782f1 --- /dev/null +++ b/crates/cachet/tests/encrypt.rs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the value-protection transform via `CacheBuilder`. +//! +//! These exercise the protection *pipeline* (builder wiring, key-as-context binding, +//! relocation defense, fallback chaining) using the crypto-free [`MockValueProtector`] +//! shipped under `test-util`, so they run with no cryptographic dependency. + +#![cfg(all(feature = "encrypt", feature = "serialize", feature = "test-util"))] + +// Integration binaries link the library with `cfg(test)` false, so the crate-root +// tracing initialization does not run here. Install it directly. See docs/tracing-tests.md. +testing_aids::init_tracing!(); + +use bytesbuf::BytesView; +use cachet::{Cache, CacheEntry, CacheOp, CacheTier, MockCache, MockValueProtector}; +use tick::Clock; + +/// Returns the serialized (version byte + postcard) form of a value, matching +/// what the `serialize()` boundary produces before protection. +fn serialized(value: &str) -> Vec { + let mut out = vec![1u8]; // FORMAT_VERSION + out.extend_from_slice(&postcard::to_allocvec(value).expect("postcard serialization should not fail")); + out +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn encrypt_pipeline_stores_ciphertext_and_round_trips() { + let l1 = MockCache::::new(); + let l2 = MockCache::::new(); + + let cache = Cache::builder::(Clock::new_frozen()) + .storage(l1.clone()) + .serialize() + .protect_with(MockValueProtector::new()) + .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) + .build(); + + let key = "greeting".to_string(); + let value = "Hello, world!".to_string(); + cache.insert(key.clone(), value.clone()).await.expect("insert should succeed"); + + // Inspect what actually landed in the post-transform tier. + let after_ops = l2.operations(); + let insert = after_ops + .iter() + .find_map(|op| match op { + CacheOp::Insert { key, entry } => Some((key.clone(), entry.value().clone())), + _ => None, + }) + .expect("post-transform tier should have received an insert"); + let (stored_key, stored_value) = insert; + + // Keys are NOT encrypted (encryption is non-deterministic), so the stored key + // is exactly the serialized key and remains lookupable. + assert_eq!(stored_key.to_vec(), serialized(&key), "key must be serialized but not encrypted"); + + // Values ARE encrypted: the stored bytes differ from the plaintext-serialized + // form, and the plaintext never appears verbatim anywhere in the ciphertext. + let plaintext = serialized(&value); + let stored = stored_value.to_vec(); + assert_ne!(stored, plaintext, "stored value must be ciphertext, not plaintext"); + assert!( + !stored.windows(plaintext.len()).any(|w| w == plaintext.as_slice()), + "plaintext must not appear verbatim in the stored ciphertext" + ); + + // Force the read to fall back to the encrypted tier and decrypt. + l1.invalidate(&key).await.expect("invalidate should succeed"); + let fetched = cache.get(&key).await.expect("get should succeed").expect("value should be present"); + assert_eq!(*fetched.value(), value, "decrypted value must match the original"); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn encrypt_each_insert_uses_fresh_nonce() { + let l2 = MockCache::::new(); + let cache = Cache::builder::(Clock::new_frozen()) + .storage(MockCache::::new()) + .serialize() + .protect_with(MockValueProtector::new()) + .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) + .build(); + + // Insert the same key/value twice; the ciphertext must differ each time. + cache + .insert("k".to_string(), "same".to_string()) + .await + .expect("insert should succeed"); + cache + .insert("k".to_string(), "same".to_string()) + .await + .expect("insert should succeed"); + + let ciphertexts: Vec> = l2 + .operations() + .iter() + .filter_map(|op| match op { + CacheOp::Insert { entry, .. } => Some(entry.value().to_vec()), + _ => None, + }) + .collect(); + assert_eq!(ciphertexts.len(), 2, "both inserts should reach the encrypted tier"); + assert_ne!(ciphertexts[0], ciphertexts[1], "each encryption must use a fresh nonce"); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn encrypt_on_fallback_builder() { + // `.protect_with()` must be reachable after `.serialize()` on a FallbackBuilder path. + let l3 = MockCache::::new(); + let cache = Cache::builder::(Clock::new_frozen()) + .storage(MockCache::::new()) + .fallback(Cache::builder::(Clock::new_frozen()).storage(MockCache::::new())) + .serialize() + .protect_with(MockValueProtector::new()) + .fallback(Cache::builder::(Clock::new_frozen()).storage(l3.clone())) + .build(); + + cache + .insert("key".to_string(), "value".to_string()) + .await + .expect("insert should succeed"); + + let l3_ops = l3.operations(); + let stored_value = l3_ops + .iter() + .find_map(|op| match op { + CacheOp::Insert { entry, .. } => Some(entry.value().to_vec()), + _ => None, + }) + .expect("encrypted tier should have received an insert"); + assert_ne!( + stored_value, + serialized("value"), + "value must be encrypted through the fallback path" + ); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn relocated_ciphertext_reads_as_a_miss() { + // End-to-end: a value is bound to its key via AAD, so an attacker who moves a + // valid ciphertext blob to a different key in the untrusted remote tier cannot + // make it decrypt — the read is a miss, not a leak of the other key's value. + let l1 = MockCache::::new(); + let remote = MockCache::::new(); + let cache = Cache::builder::(Clock::new_frozen()) + .storage(l1.clone()) + .serialize() + .protect_with(MockValueProtector::new()) + .fallback(Cache::builder::(Clock::new_frozen()).storage(remote.clone())) + .build(); + + // Legitimately cache A -> "secret-A". + cache + .insert("A".to_string(), "secret-A".to_string()) + .await + .expect("insert should succeed"); + + // Recover A's stored key and ciphertext blob from the remote tier. + let stored = remote + .operations() + .iter() + .find_map(|op| match op { + CacheOp::Insert { key, entry } => Some((key.clone(), entry.value().clone())), + _ => None, + }) + .expect("remote tier should have received an insert"); + let (stored_key_a, blob_a) = stored; + assert_eq!(stored_key_a.to_vec(), serialized("A"), "sanity: key stored is serialized key A"); + + // Attacker relocates A's ciphertext under key B in the untrusted remote tier. + let key_b = BytesView::from(serialized("B")); + remote + .insert(key_b, CacheEntry::new(blob_a)) + .await + .expect("planting the blob should succeed"); + + // Reading B must fail the AAD check and read as a miss — never A's value. + let result = cache.get("B").await.expect("get should succeed"); + assert!(result.is_none(), "relocated ciphertext must not decrypt under a different key"); +} + +#[cfg_attr(miri, ignore)] +#[test] +fn encrypted_transform_builder_debug() { + let builder = Cache::builder::(Clock::new_frozen()) + .storage(MockCache::::new()) + .serialize() + .protect_with(MockValueProtector::new()); + assert!(format!("{builder:?}").contains("ProtectedTransformBuilder")); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn encrypt_chained_post_transform_fallbacks() { + // Chain two post-transform fallback tiers after `.protect_with()`, exercising the + // second `.fallback()` that folds the existing post tier into a FallbackBuilder. + let l1 = MockCache::::new(); + let l2 = MockCache::::new(); + let l3 = MockCache::::new(); + let cache = Cache::builder::(Clock::new_frozen()) + .storage(l1.clone()) + .serialize() + .protect_with(MockValueProtector::new()) + .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) + .fallback(Cache::builder::(Clock::new_frozen()).storage(l3.clone())) + .build(); + + cache.insert("k".to_string(), "v".to_string()).await.expect("insert should succeed"); + + // Force a read past L1 so the encrypted post chain decrypts the value. + l1.invalidate(&"k".to_string()).await.expect("invalidate should succeed"); + let fetched = cache.get("k").await.expect("get should succeed").expect("value present"); + assert_eq!( + *fetched.value(), + "v", + "value must round-trip through the chained encrypted fallbacks" + ); + + // The first post tier stored ciphertext, not plaintext. + let stored = l2 + .operations() + .iter() + .find_map(|op| match op { + CacheOp::Insert { entry, .. } => Some(entry.value().to_vec()), + _ => None, + }) + .expect("first post tier should have received an insert"); + assert_ne!(stored, serialized("v"), "value must be encrypted in the chained fallback"); +}