diff --git a/.spelling b/.spelling index 34e46a74b..5eacf103f 100644 --- a/.spelling +++ b/.spelling @@ -11,6 +11,8 @@ 5xx = >= +AAD +AEAD ABA ABI ACLs @@ -137,6 +139,7 @@ RAII RDME RMW RMWs +RNG RPC RSS Rc @@ -254,6 +257,7 @@ chainable chrono chunked chunkless +ciphertext clippt clippy clonable @@ -277,6 +281,14 @@ covariant coverage.json crates.io crypto +cryptographic +cryptographically +CryptProtectData +DPAPI +keystream +SymCrypt +undecryptable +unprotect customizable cutover dSMS @@ -290,6 +302,8 @@ decorrelate decrement decrementer decrementers +decrypt +decrypts dedup deduplicated deduplicating @@ -345,6 +359,7 @@ freelist freezable frontend fundle +GCM gRPC getter getters @@ -460,6 +475,8 @@ passthrough performant pessimizes pointee +plaintext +pluggable pointees polyfill pre-approved @@ -786,6 +803,7 @@ valgrind vendored webhook whitespace +keyless Quiesce transactional snapshotted diff --git a/Cargo.lock b/Cargo.lock index 39e9f4c42..014922a92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -706,6 +706,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 30e1fcb96..6c961e1a4 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 = ["serialize", "dep:bytesbuf"] telemetry = [] [dependencies] @@ -75,6 +76,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 5b1a8fb3d..0c4fc4646 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,116 @@ 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::{Error, Rejection, Unprotected, 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 { + let bytes = protected.to_vec(); + if bytes.len() < NONCE_SIZE + TAG_SIZE { + // Not even a well-formed envelope — a benign structural reject. + return Ok(Unprotected::Rejected(Rejection::Malformed)); + } + 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) { + // Authenticated: recovered. A tag failure (tampering, wrong key, + // relocation) reads as an authentication failure. + Ok(()) => Ok(Unprotected::Recovered(buffer.into())), + Err(_) => Ok(Unprotected::Rejected(Rejection::AuthenticationFailed)), + } + } +} +``` + +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 +344,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 @@ -260,15 +371,16 @@ See the `telemetry_subscriber` example for a complete demonstration. |Level|Events| |-----|------| |ERROR|`cache.get_error`, `cache.insert_error`, `cache.invalidate_error`, `cache.clear_error`| +|WARN|`cache.unprotect_failed` (with the `encrypt` feature)| |INFO|`cache.expired`, `cache.refresh_miss`, `cache.inserted`, `cache.insert_rejected`, `cache.invalidated`, `cache.eviction`| |DEBUG|`cache.hit`, `cache.miss`, `cache.refresh_hit`, `cache.cleared`| ### 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 +392,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_xlIDv3a6WgboIYzdhk5tYwbm8NaNvZXwrcbhIXs0eaeycFhZIiCaGJ5dGVzYnVmZTAuNy4wgmZjYWNoZXRmMC4xMC4wgm1jYWNoZXRfbWVtb3J5ZTAuNS4xgm5jYWNoZXRfc2VydmljZWUwLjIuOYJrY2FjaGV0X3RpZXJlMC4yLjeCZHRpY2tlMC40LjCCZ3RyYWNpbmdmMC4xLjQ0gml1bmlmbGlnaHRlMC4zLjA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQb11VxC_uAPOQbtUn4Wx2-BfAbid3Nt1Y27Pobprn8Z6FjFy9hYvRhcoQbEu82gxmuwisbbRVw3TEnX4YbwFMVyLfb6D0btdKyrvo5pT5hZIiCaGJ5dGVzYnVmZTAuNy4wgmZjYWNoZXRmMC4xMC4wgm1jYWNoZXRfbWVtb3J5ZTAuNS4xgm5jYWNoZXRfc2VydmljZWUwLjIuOYJrY2FjaGV0X3RpZXJlMC4yLjeCZHRpY2tlMC40LjCCZ3RyYWNpbmdmMC4xLjQ0gml1bmlmbGlnaHRlMC4zLjA [__link0]: https://docs.rs/cachet/0.10.0/cachet/?search=TimeToRefresh [__link1]: https://crates.io/crates/uniflight/0.3.0 [__link10]: https://docs.rs/cachet_tier/0.2.7/cachet_tier/?search=CacheTier @@ -294,11 +406,13 @@ This crate was developed as part of { + pub(super) pre: Pre, + pub(super) pool: GlobalPool, + pub(super) protect: Option>>, + pub(super) clock: Clock, + pub(super) telemetry: CacheTelemetry, + pub(super) stampede_protection: bool, + pub(super) _phantom: PhantomData<(K, V)>, +} + +impl Debug for SerializeBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SerializeBuilder") + .field("pre", &self.pre) + .field("protected", &PROTECTED) + .field("K", &std::any::type_name::()) + .field("V", &std::any::type_name::()) + .finish_non_exhaustive() + } +} + +fn new_serialize_builder( + pre: Pre, + clock: Clock, + telemetry: CacheTelemetry, + stampede_protection: bool, +) -> SerializeBuilder { + SerializeBuilder { + pre, + pool: GlobalPool::new(), + protect: None, + clock, + telemetry, + stampede_protection, + _phantom: PhantomData, + } +} + +/// Builds the value codec for the serialize boundary: postcard serialization, optionally +/// wrapped in the protector (which binds the storage key as associated data). +#[cfg(feature = "encrypt")] +fn compose_value_codec(pool: GlobalPool, protect: Option>>) -> Box> +where + V: Serialize + DeserializeOwned + Send + Sync + 'static, +{ + match protect { + Some(protect) => Box::new(ChainedCodec::new(Box::new(PostcardCodec::new(pool)), protect)), + None => Box::new(PostcardCodec::new(pool)), + } +} + +/// Without the `encrypt` feature there is no protector, so the value codec is plain +/// postcard serialization. +#[cfg(not(feature = "encrypt"))] +fn compose_value_codec(pool: GlobalPool, _protect: Option>>) -> Box> +where + V: Serialize + DeserializeOwned + Send + Sync + 'static, +{ + Box::new(PostcardCodec::new(pool)) +} + +// ── .serialize() entry points ── impl CacheBuilder where @@ -20,9 +117,12 @@ where V: Clone + Send + Sync + 'static, CT: CacheTier + Send + Sync + 'static, { - /// Applies a serialization boundary that converts keys and values to [`BytesView`](bytesbuf::BytesView). + /// Begins a serialization boundary that converts keys and values to [`BytesView`] + /// for the next fallback tier. /// - /// Subsequent `.fallback()` tiers must work with `BytesView` keys and values. + /// Add a byte-speaking storage tier with + /// [`fallback`](SerializeBuilder::fallback); the value is serialized before it + /// reaches that tier and deserialized on the way back. /// /// # Examples /// @@ -40,13 +140,15 @@ where /// .build(); /// ``` #[must_use] - pub fn serialize(self) -> TransformBuilder + pub fn serialize(self) -> SerializeBuilder where K: Serialize, V: Serialize + DeserializeOwned, { - let pool = GlobalPool::new(); - self.transform(PostcardEncoder::new(pool.clone()), PostcardCodec::new(pool)) + let clock = self.clock.clone(); + let telemetry = self.telemetry.clone(); + let stampede = self.stampede_protection; + new_serialize_builder(self, clock, telemetry, stampede) } } @@ -57,14 +159,83 @@ where PB: CacheTierBuilder, FB: CacheTierBuilder, { - /// Applies a serialization boundary on a fallback builder. + /// Begins a serialization boundary applying to the next fallback tier. + /// + /// See [`CacheBuilder::serialize`] for the semantics; here the pre-transform tier is + /// the fallback hierarchy built so far. #[must_use] - pub fn serialize(self) -> TransformBuilder + pub fn serialize(self) -> SerializeBuilder where K: Serialize, V: Serialize + DeserializeOwned, { - let pool = GlobalPool::new(); - self.transform(PostcardEncoder::new(pool.clone()), PostcardCodec::new(pool)) + let clock = self.clock.clone(); + let telemetry = self.telemetry.clone(); + let stampede = self.stampede_protection; + new_serialize_builder(self, clock, telemetry, stampede) + } +} + +// ── .fallback() — materializes the boundary as one wrapped tier ── + +impl SerializeBuilder { + /// Adds the byte-speaking storage tier this boundary transforms, returning an + /// ordinary [`FallbackBuilder`] with the pre-transform tier as primary and the + /// serialized (and optionally protected) tier as fallback. + /// + /// The transform applies to this one tier only. To transform another tier, call + /// `.serialize()` again on the returned builder. + #[must_use] + pub fn fallback(self, fallback: FB) -> FallbackBuilder> + where + K: Serialize + Send + Sync + 'static, + V: Serialize + DeserializeOwned + Send + Sync + 'static, + { + let key_encoder: Box> = Box::new(PostcardEncoder::new(self.pool.clone())); + let value_codec = compose_value_codec::(self.pool, self.protect); + // The storage key is bytes here, so bind it as the codec context: this is what + // lets a protector authenticate each value against its key. + let make_context: MakeContext = Box::new(|key: &BytesView| CodecContext::from_key(to_contiguous(key))); + + let wrapped = TransformTierBuilder { + inner: fallback, + key_encoder, + value_codec, + make_context, + clock: self.clock.clone(), + telemetry: self.telemetry.clone(), + stampede_protection: self.stampede_protection, + _phantom: PhantomData, + }; + FallbackBuilder { + name: None, + primary_builder: self.pre, + fallback_builder: wrapped, + clock: self.clock, + refresh: None, + telemetry: self.telemetry, + stampede_protection: self.stampede_protection, + _phantom: PhantomData, + } + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use cachet_tier::MockCache; + + use super::*; + use crate::Cache; + + #[test] + fn serialize_fallback_builds_a_transform_tier() { + let builder = Cache::builder::(Clock::new_frozen()) + .storage(MockCache::::new()) + .serialize() + .fallback(Cache::builder::(Clock::new_frozen()).storage(MockCache::::new())); + + let debug = format!("{:?}", builder.fallback_builder); + assert!(debug.contains("TransformTierBuilder"), "debug output was: {debug}"); } } diff --git a/crates/cachet/src/builder/transform.rs b/crates/cachet/src/builder/transform.rs index 97a48e6f7..22602c976 100644 --- a/crates/cachet/src/builder/transform.rs +++ b/crates/cachet/src/builder/transform.rs @@ -1,7 +1,21 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Transform builder for applying type-conversion boundaries in the cache pipeline. +//! Builder for a type-conversion boundary that applies to the *next* fallback tier. +//! +//! `.transform()` returns a [`TransformBuilder`] — a *pending* boundary holding the +//! pre-transform tier and the codecs. Like [`serialize`](super::serialize), it is +//! deliberately **not** buildable on its own: the transform materializes only when you +//! add a storage tier with [`fallback`](TransformBuilder::fallback), which wraps exactly +//! that tier in a [`TransformAdapter`] and hands back an ordinary [`FallbackBuilder`]. To +//! transform another tier, call `.transform()` again — each `.transform()` is its own +//! boundary applying to the single `.fallback()` that follows. +//! +//! Wrapping each tier independently keeps decoding *below* every fallback junction: an +//! undecodable value in one tier decodes to a miss there, so the fallback chain falls +//! through to the next tier rather than shadowing a good copy. (A single adapter over a +//! chain of tiers would decode *above* the junctions, turning a present-but-undecodable +//! blob into a hard miss and hiding a valid copy in a later tier.) use std::fmt::Debug; use std::hash::Hash; @@ -15,32 +29,29 @@ use super::cache::CacheBuilder; use super::fallback::FallbackBuilder; use super::sealed::{CacheTierBuilder, Sealed}; use crate::telemetry::CacheTelemetry; -use crate::transform::TransformAdapter; -use crate::{CacheTier, Codec, Encoder}; +use crate::transform::{MakeContext, TransformAdapter, keyless_context}; +use crate::{Cache, CacheTier, Codec, Encoder}; -/// Builder that introduces a type-conversion boundary in the cache pipeline. +/// A pending type-conversion boundary, produced by [`transform`](CacheBuilder::transform). /// -/// - `Pre`: the pre-transform builder (`CacheTierBuilder`) -/// - `Post`: the post-transform builder (`CacheTierBuilder`), starts as `()` -/// -/// 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)>, +/// Holds the pre-transform tier plus the codecs that convert FROM the user types +/// (`K, V`) TO the storage types (`KT, VT`). Add a storage tier speaking `KT, VT` with +/// [`fallback`](Self::fallback) to materialize the boundary; a `TransformBuilder` on its +/// own is not buildable. +pub struct TransformBuilder { + pub(super) pre: Pre, + 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 { +impl Debug for TransformBuilder { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TransformBuilder") .field("pre", &self.pre) - .field("post", &self.post) .field("K", &std::any::type_name::()) .field("KT", &std::any::type_name::()) .field("V", &std::any::type_name::()) @@ -49,7 +60,26 @@ impl Debug for TransformBuilder( + pre: Pre, + key_encoder: impl Encoder + 'static, + value_codec: impl Codec + 'static, + clock: Clock, + telemetry: CacheTelemetry, + stampede_protection: bool, +) -> TransformBuilder { + TransformBuilder { + pre, + key_encoder: Box::new(key_encoder), + value_codec: Box::new(value_codec), + clock, + telemetry, + stampede_protection, + _phantom: PhantomData, + } +} + +// ── .transform() entry points ── impl CacheBuilder where @@ -57,13 +87,15 @@ where V: Clone + Send + Sync + 'static, CT: CacheTier + Send + Sync + 'static, { - /// Applies a generic type transform boundary. + /// Begins a type-conversion boundary for the next fallback tier. /// /// The codecs convert FROM user types TO storage types: /// - `key_encoder`: `K -> KT` (one-directional) /// - `value_codec`: `V <-> VT` (bidirectional) /// - /// Subsequent `.fallback()` tiers must work with `KT, VT`. + /// Add a storage tier speaking `KT, VT` with + /// [`fallback`](TransformBuilder::fallback). The transform applies to that one tier; + /// to transform another, call `.transform()` again. #[must_use] pub fn transform( self, @@ -76,22 +108,11 @@ where { let clock = self.clock.clone(); let telemetry = self.telemetry.clone(); - let stampede_protection = self.stampede_protection; - TransformBuilder { - pre: self, - post: (), - key_encoder: Box::new(key_encoder), - value_codec: Box::new(value_codec), - clock, - telemetry, - stampede_protection, - _phantom: PhantomData, - } + let stampede = self.stampede_protection; + new_transform_builder(self, key_encoder, value_codec, clock, telemetry, stampede) } } -// ── .transform() on FallbackBuilder ── - impl FallbackBuilder where K: Clone + Hash + Eq + Send + Sync + 'static, @@ -99,7 +120,10 @@ where PB: CacheTierBuilder, FB: CacheTierBuilder, { - /// Applies a generic type transform boundary on a fallback builder. + /// Begins a type-conversion boundary applying to the next fallback tier. + /// + /// See [`CacheBuilder::transform`] for the semantics; here the pre-transform tier is + /// the fallback hierarchy built so far. #[must_use] pub fn transform( self, @@ -112,155 +136,138 @@ where { let clock = self.clock.clone(); let telemetry = self.telemetry.clone(); - let stampede_protection = self.stampede_protection; - TransformBuilder { - pre: self, - post: (), - key_encoder: Box::new(key_encoder), - value_codec: Box::new(value_codec), - clock, - telemetry, - stampede_protection, - _phantom: PhantomData, - } + let stampede = self.stampede_protection; + new_transform_builder(self, key_encoder, value_codec, clock, telemetry, stampede) } } -// ── .fallback() on TransformBuilder ── +// ── .fallback() — materializes the boundary as one wrapped tier ── -impl TransformBuilder -where - KT: Clone + Hash + Eq + Send + Sync + 'static, - VT: Clone + Send + Sync + 'static, -{ - /// Sets the first post-transform storage tier (speaks `KT, VT`). - pub fn fallback(self, fallback: FB) -> TransformBuilder - where - FB: CacheTierBuilder, - { - TransformBuilder { - pre: self.pre, - post: fallback, +impl TransformBuilder { + /// Adds the storage tier this boundary transforms (speaks `KT, VT`), returning an + /// ordinary [`FallbackBuilder`] with the pre-transform tier as primary and the + /// adapted tier as fallback. + /// + /// The transform applies to this one tier only. To transform another tier, call + /// `.transform()` again on the returned builder. + #[must_use] + pub fn fallback(self, fallback: FB) -> FallbackBuilder> { + let wrapped = TransformTierBuilder { + inner: fallback, key_encoder: self.key_encoder, value_codec: self.value_codec, - clock: self.clock, - telemetry: self.telemetry, + make_context: keyless_context(), + clock: self.clock.clone(), + telemetry: self.telemetry.clone(), stampede_protection: self.stampede_protection, _phantom: PhantomData, - } - } -} - -impl TransformBuilder -where - KT: Clone + Hash + Eq + Send + Sync + 'static, - VT: Clone + Send + Sync + 'static, - Post: CacheTierBuilder, -{ - /// Adds another post-transform fallback tier (speaks `KT, VT`). - pub fn fallback(self, fallback: FB) -> TransformBuilder> - where - FB: CacheTierBuilder, - { - let clock = self.clock.clone(); - let telemetry = self.telemetry.clone(); - let stampede_protection = self.stampede_protection; - - let post_chain = FallbackBuilder { + }; + FallbackBuilder { name: None, - primary_builder: self.post, - fallback_builder: fallback, - clock: clock.clone(), + primary_builder: self.pre, + fallback_builder: wrapped, + clock: self.clock, refresh: None, - telemetry: telemetry.clone(), - stampede_protection, - _phantom: PhantomData, - }; - - TransformBuilder { - pre: self.pre, - post: post_chain, - key_encoder: self.key_encoder, - value_codec: self.value_codec, - clock, - telemetry, - stampede_protection, + telemetry: self.telemetry, + stampede_protection: self.stampede_protection, _phantom: PhantomData, } } } -// ── Sealed + CacheTierBuilder ── - -impl Sealed for TransformBuilder -where - K: Clone + Hash + Eq + Send + Sync + 'static, - V: Clone + Send + Sync + 'static, - KT: Clone + Hash + Eq + Send + Sync + 'static, - VT: Clone + Send + Sync + 'static, -{ +/// A per-leaf builder that wraps one storage tier in a [`TransformAdapter`]. +/// +/// Produced by [`TransformBuilder::fallback`] (and, with `BytesView` storage types, by +/// [`SerializeBuilder::fallback`](super::serialize::SerializeBuilder::fallback)); it +/// carries the codecs plus the key-context function and, at build time, decorates its +/// inner tier so each backing store is converted independently — keeping decoding below +/// every fallback junction. +pub struct TransformTierBuilder { + pub(super) inner: Inner, + pub(super) key_encoder: Box>, + pub(super) value_codec: Box>, + pub(super) make_context: MakeContext, + pub(super) clock: Clock, + pub(super) telemetry: CacheTelemetry, + pub(super) stampede_protection: bool, + pub(super) _phantom: PhantomData<(K, V, KT, VT)>, } -impl CacheTierBuilder for TransformBuilder -where - K: Clone + Hash + Eq + Send + Sync + 'static, - V: Clone + Send + Sync + 'static, - KT: Clone + Hash + Eq + Send + Sync + 'static, - VT: Clone + Send + Sync + 'static, -{ +impl Debug for TransformTierBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TransformTierBuilder") + .field("inner", &self.inner) + .field("K", &std::any::type_name::()) + .field("KT", &std::any::type_name::()) + .field("V", &std::any::type_name::()) + .field("VT", &std::any::type_name::()) + .finish_non_exhaustive() + } } -// ── .build() ── +impl Sealed for TransformTierBuilder {} -#[expect(private_bounds, reason = "Buildable is an internal trait")] -impl TransformBuilder +impl CacheTierBuilder for TransformTierBuilder where K: Clone + Hash + Eq + Send + Sync + 'static, V: Clone + Send + Sync + 'static, KT: Clone + Hash + Eq + Send + Sync + 'static, VT: Clone + Send + Sync + 'static, - Pre: Buildable, - Post: Buildable, + Inner: CacheTierBuilder, { - /// Builds the full cache hierarchy with the transform boundary. - pub fn build(self) -> crate::Cache { - >::build(self) - } } -// ── Buildable ── - -impl Buildable for TransformBuilder +impl Buildable for TransformTierBuilder where K: Clone + Hash + Eq + Send + Sync + 'static, V: Clone + Send + Sync + 'static, KT: Clone + Hash + Eq + Send + Sync + 'static, VT: Clone + Send + Sync + 'static, - Pre: Buildable, - Post: Buildable, + Inner: Buildable, { - type TierOutput = DynamicCache; + type TierOutput = TransformAdapter; - fn build(self) -> crate::Cache { + // A `TransformTierBuilder` is only ever composed as the fallback tier of a + // `FallbackBuilder`, which drives it through `build_tier`; `build` is required by the + // trait but never reached, so it is excluded from coverage. + #[cfg_attr(coverage_nightly, coverage(off))] + fn build(self) -> 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); + let tier = DynamicCache::new(self.build_tier(clock.clone(), telemetry.clone(), false)); - crate::Cache::new(type_name::(None), tier, clock, telemetry, stampede_protection) + Cache::new(type_name::(None), tier, clock, telemetry, stampede_protection) } fn build_tier(self, clock: Clock, telemetry: CacheTelemetry, fallback: bool) -> Self::TierOutput { - // Build pre-transform tier - let pre_tier = self.pre.build_tier(clock.clone(), telemetry.clone(), fallback); + let inner = self.inner.build_tier(clock, telemetry, fallback); + TransformAdapter::from_boxed(inner, self.key_encoder, self.value_codec, self.make_context) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use cachet_tier::MockCache; - // Build post-transform tier, wrap in TransformAdapter - let post_tier = self.post.build_tier(clock.clone(), telemetry.clone(), true); - let adapted = TransformAdapter::from_boxed(post_tier, self.key_encoder, self.value_codec); + use super::*; + use crate::transform::{TransformCodec, TransformEncoder, infallible, infallible_owned}; - // Combine: pre is primary, adapted is fallback - let fallback = crate::fallback::FallbackCache::new(type_name::(None), pre_tier, adapted, clock, None, telemetry); + #[test] + fn transform_tier_builder_debug() { + let builder = Cache::builder::(Clock::new_frozen()) + .storage(MockCache::::new()) + .transform( + TransformEncoder::infallible(|k: &i32| k.to_string()), + TransformCodec::new( + infallible(|v: &i32| v.to_string()), + infallible_owned(|v: String| v.parse::().unwrap_or_default()), + ), + ) + .fallback(Cache::builder::(Clock::new_frozen()).storage(MockCache::::new())); - DynamicCache::new(fallback) + let debug = format!("{:?}", builder.fallback_builder); + assert!(debug.contains("TransformTierBuilder"), "debug output was: {debug}"); } } diff --git a/crates/cachet/src/cache.rs b/crates/cachet/src/cache.rs index 86a9b71c0..805305cf8 100644 --- a/crates/cachet/src/cache.rs +++ b/crates/cachet/src/cache.rs @@ -869,13 +869,10 @@ where #[cfg(test)] mod tests { - use std::sync::{Arc, Mutex}; - use cachet_tier::MockCache; use super::*; - use crate::telemetry::handler::RequestId; - use crate::{CacheEventHandler, CacheOperationEvent, CacheTierEvent}; + use crate::RecordingEventHandler; fn block_on(f: F) -> F::Output { futures::executor::block_on(f) @@ -925,52 +922,15 @@ mod tests { #[test] fn cache_event_handler_receives_fallback_tier_events() { - type EventRecord = Vec<(RequestId, String, String, bool)>; - - #[derive(Clone)] - struct RecordingHandler { - tier_events: Arc>, - operation_events: Arc>, - } - - impl CacheEventHandler for RecordingHandler { - fn on_tier_event(&self, event: &CacheTierEvent<'_>) { - self.tier_events.lock().expect("test handler mutex should not be poisoned").push(( - event.request_id, - event.tier_name.to_string(), - event.outcome.to_string(), - event.fallback, - )); - } - - fn on_operation_complete(&self, event: &CacheOperationEvent<'_>) { - self.operation_events - .lock() - .expect("test handler mutex should not be poisoned") - .push(( - event.request_id, - event.cache_name.to_string(), - event.operation.to_string(), - event.coalesced, - )); - } - } - - let tier_events = Arc::new(Mutex::new(Vec::new())); - let operation_events = Arc::new(Mutex::new(Vec::new())); + let handler = RecordingEventHandler::new(); block_on(async { let clock = Clock::new_frozen(); - let handler = RecordingHandler { - tier_events: Arc::clone(&tier_events), - operation_events: Arc::clone(&operation_events), - }; - let l2 = Cache::builder::(clock.clone()).storage(MockCache::new()).name("l2"); let cache = Cache::builder::(clock) .storage(MockCache::new()) .name("l1") - .event_handler(handler) + .event_handler(handler.clone()) .fallback(l2) .build(); @@ -978,30 +938,24 @@ mod tests { assert!(result.is_none()); }); - let tier_events = tier_events.lock().expect("test handler mutex should not be poisoned").clone(); - let operation_events = operation_events.lock().expect("test handler mutex should not be poisoned").clone(); - let request_id = operation_events[0].0; + let tier_events = handler.tier_events(); + let operation_events = handler.operation_events(); + let request_id = operation_events[0].request_id; + let miss = crate::telemetry::attributes::EVENT_MISS; assert_eq!( - tier_events, - vec![ - ( - request_id, - "l1".to_string(), - crate::telemetry::attributes::EVENT_MISS.to_string(), - false - ), - ( - request_id, - "l2".to_string(), - crate::telemetry::attributes::EVENT_MISS.to_string(), - true - ), - ] + tier_events + .iter() + .map(|e| (e.request_id, e.tier_name.as_str(), e.outcome.as_str(), e.fallback)) + .collect::>(), + vec![(request_id, "l1", miss, false), (request_id, "l2", miss, true)] ); assert_eq!( - operation_events, - vec![(request_id, "l1".to_string(), "cache.get".to_string(), false)] + operation_events + .iter() + .map(|e| (e.request_id, e.cache_name.as_str(), e.operation.as_str(), e.coalesced)) + .collect::>(), + vec![(request_id, "l1", "cache.get", false)] ); } diff --git a/crates/cachet/src/lib.rs b/crates/cachet/src/lib.rs index 8a11de185..f498d4562 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,119 @@ //! # }; //! ``` //! +//! ## 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::{Error, Rejection, Unprotected, 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 { +//! let bytes = protected.to_vec(); +//! if bytes.len() < NONCE_SIZE + TAG_SIZE { +//! // Not even a well-formed envelope — a benign structural reject. +//! return Ok(Unprotected::Rejected(Rejection::Malformed)); +//! } +//! 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) { +//! // Authenticated: recovered. A tag failure (tampering, wrong key, +//! // relocation) reads as an authentication failure. +//! Ok(()) => Ok(Unprotected::Recovered(buffer.into())), +//! Err(_) => Ok(Unprotected::Rejected(Rejection::AuthenticationFailed)), +//! } +//! } +//! } +//! ``` +//! +//! 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: @@ -258,6 +372,7 @@ //! | Level | Events | //! |-------|--------| //! | ERROR | `cache.get_error`, `cache.insert_error`, `cache.invalidate_error`, `cache.clear_error` | +//! | WARN | `cache.unprotect_failed` (with the `encrypt` feature) | //! | INFO | `cache.expired`, `cache.refresh_miss`, `cache.inserted`, `cache.insert_rejected`, `cache.invalidated`, `cache.eviction` | //! | DEBUG | `cache.hit`, `cache.miss`, `cache.refresh_hit`, `cache.cleared` | //! @@ -285,6 +400,9 @@ pub mod telemetry; mod transform; mod wrapper; +#[cfg(any(feature = "serialize", test))] +#[doc(inline)] +pub use builder::SerializeBuilder; #[doc(inline)] pub use builder::{CacheBuilder, CacheTierBuilder, FallbackBuilder, TransformBuilder}; #[doc(inline)] @@ -308,8 +426,17 @@ pub use policy::InsertPolicy; pub use refresh::TimeToRefresh; #[doc(inline)] pub use telemetry::handler::{CacheEventHandler, CacheOperationEvent, CacheTierEvent}; +#[cfg(any(feature = "test-util", test))] +#[doc(inline)] +pub use telemetry::handler::{RecordedOperationEvent, RecordedTierEvent, RecordingEventHandler}; +#[cfg(all(feature = "encrypt", any(feature = "test-util", test)))] +#[doc(inline)] +pub use transform::MockValueProtector; +#[doc(inline)] +pub use transform::{Codec, CodecContext, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, infallible, infallible_owned}; +#[cfg(feature = "encrypt")] #[doc(inline)] -pub use transform::{Codec, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, infallible, infallible_owned}; +pub use transform::{Rejection, Unprotected, ValueProtector}; // Installs a silent, always-interested global `tracing` subscriber before any // unit test in this crate runs. This keeps `tracing` emission paths executing diff --git a/crates/cachet/src/serialize/codec.rs b/crates/cachet/src/serialize/codec.rs index bdf3de5f8..0585aa19c 100644 --- a/crates/cachet/src/serialize/codec.rs +++ b/crates/cachet/src/serialize/codec.rs @@ -16,7 +16,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use crate::transform::DecodeOutcome; -use crate::{Codec, Encoder, Error}; +use crate::{Codec, CodecContext, Encoder, Error}; const FORMAT_VERSION: u8 = 1; @@ -63,31 +63,30 @@ impl PostcardCodec { } } -impl Encoder for PostcardCodec { - fn encode(&self, value: &T) -> Result { +impl Codec for PostcardCodec { + /// Encodes a value to its stored byte representation. The [`CodecContext`] is unused — + /// serialization does not bind a key. + fn encode(&self, _ctx: &CodecContext<'_>, value: &T) -> Result { encode(value, &self.pool) } -} -impl Codec for PostcardCodec { /// Decodes a stored value back to the original type. /// - /// Returns `DecodeOutcome::Value(v)` on success, or - /// `DecodeOutcome::SoftFailure(reason)` if the stored data is undecodable - /// and should be treated as a cache miss. - fn decode(&self, value: BytesView) -> Result, Error> { + /// Returns `DecodeOutcome::Value(v)` on success, or `DecodeOutcome::SoftFailure` if + /// the stored data is undecodable and should be treated as a cache miss. + fn decode(&self, _ctx: &CodecContext<'_>, value: BytesView) -> Result, Error> { let bytes = to_contiguous(&value); let Some((version, payload)) = bytes.split_first() else { - return Ok(DecodeOutcome::SoftFailure("empty payload")); + return Ok(DecodeOutcome::SoftFailure); // empty payload }; if *version != FORMAT_VERSION { - return Ok(DecodeOutcome::SoftFailure("format version mismatch")); + return Ok(DecodeOutcome::SoftFailure); // format version mismatch } match postcard::from_bytes(payload) { Ok(value) => Ok(DecodeOutcome::Value(value)), - Err(_) => Ok(DecodeOutcome::SoftFailure("deserialization failed")), + Err(_) => Ok(DecodeOutcome::SoftFailure), // deserialization failed } } } @@ -101,7 +100,7 @@ fn encode(value: &T, pool: &GlobalPool) -> Result Cow<'_, [u8]> { +pub(crate) fn to_contiguous(view: &BytesView) -> Cow<'_, [u8]> { let first = view.first_slice(); if first.len() == view.len() { Cow::Borrowed(first) @@ -137,8 +136,8 @@ mod tests { fn decode_empty_payload_returns_soft_failure() { let codec = PostcardCodec::new(GlobalPool::new()); let empty = BytesView::from(Vec::::new()); - let result: Result, Error> = codec.decode(empty); - assert!(matches!(result.unwrap(), DecodeOutcome::SoftFailure("empty payload"))); + let result: Result, Error> = codec.decode(&CodecContext::keyless(), empty); + assert!(matches!(result.unwrap(), DecodeOutcome::SoftFailure)); } #[test] @@ -147,8 +146,8 @@ mod tests { let mut data = vec![0xFF]; data.extend_from_slice(&postcard::to_allocvec(&"hello".to_string()).unwrap()); let view = BytesView::from(data); - let result: Result, Error> = codec.decode(view); - assert!(matches!(result.unwrap(), DecodeOutcome::SoftFailure("format version mismatch"))); + let result: Result, Error> = codec.decode(&CodecContext::keyless(), view); + assert!(matches!(result.unwrap(), DecodeOutcome::SoftFailure)); } #[test] @@ -156,16 +155,16 @@ mod tests { let codec = PostcardCodec::new(GlobalPool::new()); let data = vec![FORMAT_VERSION, 0xFF, 0xFE, 0xFD]; let view = BytesView::from(data); - let result: Result, Error> = codec.decode(view); - assert!(matches!(result.unwrap(), DecodeOutcome::SoftFailure("deserialization failed"))); + let result: Result, Error> = codec.decode(&CodecContext::keyless(), view); + assert!(matches!(result.unwrap(), DecodeOutcome::SoftFailure)); } #[test] fn encode_decode_roundtrip() { let codec = PostcardCodec::new(GlobalPool::new()); let original = "hello, world!".to_string(); - let encoded = codec.encode(&original).expect("encode should succeed"); - let outcome: DecodeOutcome = codec.decode(encoded).expect("decode should succeed"); + let encoded = codec.encode(&CodecContext::keyless(), &original).expect("encode should succeed"); + let outcome: DecodeOutcome = codec.decode(&CodecContext::keyless(), encoded).expect("decode should succeed"); assert!(matches!(outcome, DecodeOutcome::Value(ref v) if v == &original)); } @@ -185,7 +184,7 @@ mod tests { fn decode_multi_span_view() { let codec = PostcardCodec::new(GlobalPool::new()); let original = "multi-span test".to_string(); - let encoded = codec.encode(&original).expect("encode should succeed"); + let encoded = codec.encode(&CodecContext::keyless(), &original).expect("encode should succeed"); let bytes = to_contiguous(&encoded); let mid = bytes.len() / 2; @@ -195,7 +194,7 @@ mod tests { assert_ne!(first_half.first_slice().len(), first_half.len(), "should be multi-span"); - let outcome: DecodeOutcome = codec.decode(first_half).expect("decode should succeed"); + let outcome: DecodeOutcome = codec.decode(&CodecContext::keyless(), first_half).expect("decode should succeed"); assert!(matches!(outcome, DecodeOutcome::Value(ref v) if v == &original)); } } 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..2829c09d4 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 the `ProtectorCodec` on the `get` path when a value fails its + /// authentication check (`Rejection::AuthenticationFailed`), 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. A 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(any(feature = "encrypt", test))] + 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, @@ -419,11 +446,10 @@ impl CacheTelemetry { #[cfg(test)] mod tests { - use std::sync::Mutex; - use testing_aids::tracing_logs::Capture; use super::*; + use crate::RecordingEventHandler; fn subscriber(capture: &Capture) -> impl tracing::Subscriber { capture.subscriber() @@ -490,6 +516,19 @@ mod tests { .await; }); capture.assert_contains("DEBUG"); + + #[cfg(feature = "encrypt")] + { + let capture = Capture::new(); + let _guard = tracing::subscriber::set_default(subscriber(&capture)); + let request_id = next_request_id(); + futures::executor::block_on(async { + async { telemetry.record_unprotect_failure("cache") } + .with_request_id(request_id) + .await; + }); + capture.assert_contains("WARN"); + } } #[test] @@ -615,45 +654,8 @@ mod tests { #[test] fn handler_receives_tier_and_operation_events_without_logging() { - type EventRecord = Vec<(RequestId, String, String, u128, bool)>; - - #[derive(Clone)] - struct RecordingHandler { - tier_events: Arc>, - operation_events: Arc>, - } - - impl CacheEventHandler for RecordingHandler { - fn on_tier_event(&self, event: &CacheTierEvent<'_>) { - self.tier_events.lock().expect("test handler mutex should not be poisoned").push(( - event.request_id, - event.tier_name.to_string(), - event.outcome.to_string(), - event.duration.as_nanos(), - event.fallback, - )); - } - - fn on_operation_complete(&self, event: &CacheOperationEvent<'_>) { - self.operation_events - .lock() - .expect("test handler mutex should not be poisoned") - .push(( - event.request_id, - event.cache_name.to_string(), - event.operation.to_string(), - event.duration.as_nanos(), - event.coalesced, - )); - } - } - - let tier_events = Arc::new(Mutex::new(Vec::new())); - let operation_events = Arc::new(Mutex::new(Vec::new())); - let telemetry = CacheTelemetry::new().with_handler(Arc::new(RecordingHandler { - tier_events: Arc::clone(&tier_events), - operation_events: Arc::clone(&operation_events), - })); + let handler = RecordingEventHandler::new(); + let telemetry = CacheTelemetry::new().with_handler(Arc::new(handler.clone())); let request_id = next_request_id(); futures::executor::block_on( @@ -664,14 +666,21 @@ mod tests { .with_request_id(request_id), ); - assert_eq!( - *tier_events.lock().expect("test handler mutex should not be poisoned"), - vec![(request_id, "l2".to_string(), attributes::EVENT_HIT.to_string(), 7, true)] - ); - assert_eq!( - *operation_events.lock().expect("test handler mutex should not be poisoned"), - vec![(request_id, "cache".to_string(), "cache.get".to_string(), 11, true)] - ); + let tier = handler.tier_events(); + assert_eq!(tier.len(), 1); + assert_eq!(tier[0].request_id, request_id); + assert_eq!(tier[0].tier_name, "l2"); + assert_eq!(tier[0].outcome, attributes::EVENT_HIT); + assert_eq!(tier[0].duration, Duration::from_nanos(7)); + assert!(tier[0].fallback); + + let ops = handler.operation_events(); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0].request_id, request_id); + assert_eq!(ops[0].cache_name, "cache"); + assert_eq!(ops[0].operation, "cache.get"); + assert_eq!(ops[0].duration, Duration::from_nanos(11)); + assert!(ops[0].coalesced); } #[test] @@ -745,36 +754,8 @@ mod tests { #[test] fn eviction_handler_receives_request_id_from_calling_thread() { - type TierRecord = (RequestId, String, String); - type OpRecord = (RequestId, String, String); - - struct EvictionRecorder { - tier_events: Arc>>, - operation_events: Arc>>, - } - impl CacheEventHandler for EvictionRecorder { - fn on_tier_event(&self, event: &CacheTierEvent<'_>) { - self.tier_events.lock().expect("test mutex should not be poisoned").push(( - event.request_id, - event.tier_name.to_string(), - event.outcome.to_string(), - )); - } - fn on_operation_complete(&self, event: &CacheOperationEvent<'_>) { - self.operation_events.lock().expect("test mutex should not be poisoned").push(( - event.request_id, - event.cache_name.to_string(), - event.operation.to_string(), - )); - } - } - - let tier_events = Arc::new(Mutex::new(Vec::new())); - let operation_events = Arc::new(Mutex::new(Vec::new())); - let telemetry = CacheTelemetry::new().with_handler(Arc::new(EvictionRecorder { - tier_events: Arc::clone(&tier_events), - operation_events: Arc::clone(&operation_events), - })); + let handler = RecordingEventHandler::new(); + let telemetry = CacheTelemetry::new().with_handler(Arc::new(handler.clone())); let request_id = next_request_id(); futures::executor::block_on( @@ -785,58 +766,35 @@ mod tests { .with_request_id(request_id), ); - let tiers = tier_events.lock().expect("test mutex should not be poisoned"); + let tiers = handler.tier_events(); assert_eq!(tiers.len(), 1, "expected exactly one eviction tier event"); - assert_eq!(tiers[0].0, request_id, "eviction should carry the inserting thread's request_id"); - assert_eq!(tiers[0].2, attributes::EVENT_EVICTION); + assert_eq!( + tiers[0].request_id, request_id, + "eviction should carry the inserting thread's request_id" + ); + assert_eq!(tiers[0].outcome, attributes::EVENT_EVICTION); - let ops = operation_events.lock().expect("test mutex should not be poisoned"); + let ops = handler.operation_events(); assert_eq!(ops.len(), 1, "expected one operation complete event"); - assert_eq!(ops[0].0, request_id); - assert_eq!(ops[0].2, "cache.insert"); + assert_eq!(ops[0].request_id, request_id); + assert_eq!(ops[0].operation, "cache.insert"); } #[test] fn eviction_without_request_context_has_zero_id() { - type TierRecord = (RequestId, String); - type OpRecord = (RequestId, String); - - struct IdRecorder { - tier_events: Arc>>, - operation_events: Arc>>, - } - impl CacheEventHandler for IdRecorder { - fn on_tier_event(&self, event: &CacheTierEvent<'_>) { - self.tier_events - .lock() - .expect("test mutex should not be poisoned") - .push((event.request_id, event.outcome.to_string())); - } - fn on_operation_complete(&self, event: &CacheOperationEvent<'_>) { - self.operation_events - .lock() - .expect("test mutex should not be poisoned") - .push((event.request_id, event.operation.to_string())); - } - } - - let tier_events = Arc::new(Mutex::new(Vec::new())); - let operation_events = Arc::new(Mutex::new(Vec::new())); - let telemetry = CacheTelemetry::new().with_handler(Arc::new(IdRecorder { - tier_events: Arc::clone(&tier_events), - operation_events: Arc::clone(&operation_events), - })); + let handler = RecordingEventHandler::new(); + let telemetry = CacheTelemetry::new().with_handler(Arc::new(handler.clone())); // No WithRequestId wrapper — simulates background maintenance thread telemetry.record_eviction("bg_cache"); telemetry.complete_operation(0, "bg_cache", "background", Duration::ZERO, false); - let tiers = tier_events.lock().expect("test mutex should not be poisoned"); + let tiers = handler.tier_events(); assert_eq!(tiers.len(), 1); - assert_eq!(tiers[0].0, 0, "background eviction should have request_id 0"); + assert_eq!(tiers[0].request_id, 0, "background eviction should have request_id 0"); - let ops = operation_events.lock().expect("test mutex should not be poisoned"); + let ops = handler.operation_events(); assert_eq!(ops.len(), 1); - assert_eq!(ops[0].0, 0); + assert_eq!(ops[0].request_id, 0); } } diff --git a/crates/cachet/src/telemetry/handler.rs b/crates/cachet/src/telemetry/handler.rs index 6cedb1774..b4d2a60fb 100644 --- a/crates/cachet/src/telemetry/handler.rs +++ b/crates/cachet/src/telemetry/handler.rs @@ -70,3 +70,137 @@ pub trait CacheEventHandler: Send + Sync { /// Called once when the top-level cache operation completes. fn on_operation_complete(&self, event: &CacheOperationEvent<'_>); } + +/// An owned snapshot of a [`CacheTierEvent`], captured by [`RecordingEventHandler`]. +#[cfg(any(feature = "test-util", test))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordedTierEvent { + /// See [`CacheTierEvent::request_id`]. + pub request_id: RequestId, + /// See [`CacheTierEvent::tier_name`]. + pub tier_name: String, + /// See [`CacheTierEvent::outcome`]. + pub outcome: String, + /// See [`CacheTierEvent::duration`]. + pub duration: Duration, + /// See [`CacheTierEvent::fallback`]. + pub fallback: bool, +} + +/// An owned snapshot of a [`CacheOperationEvent`], captured by [`RecordingEventHandler`]. +#[cfg(any(feature = "test-util", test))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordedOperationEvent { + /// See [`CacheOperationEvent::request_id`]. + pub request_id: RequestId, + /// See [`CacheOperationEvent::cache_name`]. + pub cache_name: String, + /// See [`CacheOperationEvent::operation`]. + pub operation: String, + /// See [`CacheOperationEvent::duration`]. + pub duration: Duration, + /// See [`CacheOperationEvent::coalesced`]. + pub coalesced: bool, +} + +/// A [`CacheEventHandler`] that records events into shared buffers for inspection. +/// +/// Available with the `test-util` feature. Handlers are taken by value when +/// registered, so register a *clone* via +/// [`event_handler`](crate::CacheBuilder::event_handler), keep the original, drive the +/// cache, then read the captured events with [`tier_events`](Self::tier_events) and +/// [`operation_events`](Self::operation_events). All clones share the same buffers. +/// +/// # Examples +/// +/// ``` +/// # #[cfg(feature = "memory")] { +/// use cachet::{Cache, RecordingEventHandler}; +/// use tick::Clock; +/// # futures::executor::block_on(async { +/// let handler = RecordingEventHandler::new(); +/// let cache = Cache::builder::(Clock::new_frozen()) +/// .memory() +/// .event_handler(handler.clone()) +/// .build(); +/// +/// let _ = cache.get("absent").await; +/// assert!( +/// handler +/// .operation_events() +/// .iter() +/// .any(|event| event.operation == "cache.get") +/// ); +/// # }); +/// # } +/// ``` +#[cfg(any(feature = "test-util", test))] +#[derive(Debug, Clone, Default)] +pub struct RecordingEventHandler { + tier_events: std::sync::Arc>>, + operation_events: std::sync::Arc>>, +} + +#[cfg(any(feature = "test-util", test))] +impl RecordingEventHandler { + /// Creates a recording handler with empty buffers. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Returns a snapshot of the tier events captured so far, in emission order. + /// + /// # Panics + /// + /// Panics if the internal lock was poisoned by a thread panicking while holding it. + #[must_use] + pub fn tier_events(&self) -> Vec { + self.tier_events + .lock() + .expect("recording handler mutex should not be poisoned") + .clone() + } + + /// Returns a snapshot of the operation-complete events captured so far, in emission order. + /// + /// # Panics + /// + /// Panics if the internal lock was poisoned by a thread panicking while holding it. + #[must_use] + pub fn operation_events(&self) -> Vec { + self.operation_events + .lock() + .expect("recording handler mutex should not be poisoned") + .clone() + } +} + +#[cfg(any(feature = "test-util", test))] +impl CacheEventHandler for RecordingEventHandler { + fn on_tier_event(&self, event: &CacheTierEvent<'_>) { + self.tier_events + .lock() + .expect("recording handler mutex should not be poisoned") + .push(RecordedTierEvent { + request_id: event.request_id, + tier_name: event.tier_name.to_string(), + outcome: event.outcome.to_string(), + duration: event.duration, + fallback: event.fallback, + }); + } + + fn on_operation_complete(&self, event: &CacheOperationEvent<'_>) { + self.operation_events + .lock() + .expect("recording handler mutex should not be poisoned") + .push(RecordedOperationEvent { + request_id: event.request_id, + cache_name: event.cache_name.to_string(), + operation: event.operation.to_string(), + duration: event.duration, + coalesced: event.coalesced, + }); + } +} diff --git a/crates/cachet/src/transform/codec.rs b/crates/cachet/src/transform/codec.rs index 90fe406b3..e55a9d51e 100644 --- a/crates/cachet/src/transform/codec.rs +++ b/crates/cachet/src/transform/codec.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use std::borrow::Cow; use std::fmt::Debug; use crate::Error; @@ -46,8 +47,10 @@ where /// A one-directional encoder that converts values from type `From` to type `To`. /// -/// Used for key encoding in the transform builder pipeline, where -/// only the forward direction is needed. +/// This is the **key** side of the pipeline: keys are only ever encoded — never decoded +/// back, since every operation re-encodes the key it already has — and must encode +/// deterministically so lookups stay stable. A key takes no [`CodecContext`] because it +/// *is* the context. Contrast [`Codec`], the bidirectional, context-bound **value** side. pub trait Encoder: Send + Sync { /// Encodes a value from type `From` to type `To`. /// @@ -57,6 +60,49 @@ pub trait Encoder: Send + Sync { fn encode(&self, value: &From) -> Result; } +/// Per-operation metadata threaded through [`Codec`] calls. +/// +/// Currently just the storage key, which an authenticated codec binds as associated data +/// so a value can't be relocated to another key. It is a struct precisely so more +/// per-operation fields can be added later without changing how codecs receive it — a +/// codec always takes `&CodecContext<'_>`, and a context *producer* sets only the fields +/// it cares about. The key is a [`Cow`] so a producer can hand over either a borrowed +/// slice (the common single-span case) or a gathered buffer (a multi-span key) while +/// still yielding one self-contained context value. +#[derive(Debug, Clone)] +pub struct CodecContext<'a> { + key: Cow<'a, [u8]>, +} + +impl<'a> CodecContext<'a> { + /// Creates a context bound to `key`. + #[must_use] + pub fn new(key: &'a [u8]) -> Self { + Self { key: Cow::Borrowed(key) } + } + + /// Creates a context with no key, for codecs that do not bind one (e.g. serialization + /// or a plain type mapping). + #[must_use] + pub fn keyless() -> CodecContext<'static> { + CodecContext { key: Cow::Borrowed(&[]) } + } + + /// Creates a context bound to a possibly-owned key, for callers that must gather a + /// multi-span key into a contiguous buffer before binding it. + #[cfg(any(feature = "serialize", test))] + #[must_use] + pub(crate) fn from_key(key: Cow<'a, [u8]>) -> Self { + Self { key } + } + + /// The storage key this value is bound to, or an empty slice when none was supplied. + #[must_use] + pub fn key(&self) -> &[u8] { + &self.key + } +} + /// The result of a decode operation. /// /// Used by [`Codec::decode`] to distinguish between a successful decode, @@ -65,29 +111,42 @@ pub trait Encoder: Send + Sync { pub enum DecodeOutcome { /// The value was successfully decoded. Value(T), - /// The stored data is undecodable and should be treated as a cache miss. - /// - /// The string describes the reason (e.g., "version mismatch", "empty payload"). - SoftFailure(&'static str), + /// The stored data is undecodable and should be treated as a cache miss (as opposed + /// to a hard [`Error`], which propagates). Why it was undecodable is not part of the + /// general codec vocabulary — a codec that needs to react to a specific cause (e.g. + /// an authentication failure) categorizes it internally before returning this. + SoftFailure, } /// A bidirectional codec that converts between types `A` and `B`. /// -/// Extends [`Encoder`] with a `decode` method for the reverse direction. -/// Used for value encoding and decoding in the transform builder pipeline. -pub trait Codec: Encoder { - /// Decodes a value from type `B` back to type `A`. +/// A codec is a stage in the value pipeline: it converts a value to its stored form +/// ([`encode`](Self::encode)) and back ([`decode`](Self::decode)), given a +/// [`CodecContext`]. Serialization ignores the context; an authenticated (protection) +/// codec binds the context's key so a value cannot be relocated to a different key. +/// +/// Unlike [`Encoder`], a codec is *not* used for keys — it always receives the context, +/// which already carries the key. +pub trait Codec: Send + Sync { + /// Encodes `value` into its stored representation, given `ctx`. + /// + /// # Errors + /// + /// Returns an error if the encoding fails. + fn encode(&self, ctx: &CodecContext<'_>, value: &A) -> Result; + + /// Decodes a value from type `B` back to type `A`, given `ctx`. /// /// # Returns /// /// - `Ok(DecodeOutcome::Value(v))` on success - /// - `Ok(DecodeOutcome::SoftFailure(reason))` if the stored data is undecodable + /// - `Ok(DecodeOutcome::SoftFailure)` if the stored data is undecodable /// and should be treated as a cache miss /// /// # Errors /// /// Returns `Err` for hard failures that should propagate to the caller. - fn decode(&self, value: B) -> Result, Error>; + fn decode(&self, ctx: &CodecContext<'_>, value: B) -> Result, Error>; } type EncodeFn = Box Result + Send + Sync>; @@ -155,14 +214,12 @@ impl TransformCodec { } } -impl Encoder for TransformCodec { - fn encode(&self, value: &A) -> Result { +impl Codec for TransformCodec { + fn encode(&self, _ctx: &CodecContext<'_>, value: &A) -> Result { (self.encode_fn)(value) } -} -impl Codec for TransformCodec { - fn decode(&self, value: B) -> Result, Error> { + fn decode(&self, _ctx: &CodecContext<'_>, value: B) -> Result, Error> { (self.decode_fn)(value) } } @@ -175,3 +232,55 @@ impl Debug for TransformCodec { .finish() } } + +/// A codec formed by running one codec through another: `A <-> M <-> B`. +/// +/// On encode, `first` runs then `second`; on decode the order reverses, and a +/// [`SoftFailure`](DecodeOutcome::SoftFailure) from either stage propagates as a miss. +/// Used to layer an authenticated-protection stage over a serialization stage while +/// presenting a single `Codec` to the tier. +#[cfg(feature = "encrypt")] +pub(crate) struct ChainedCodec { + first: Box>, + second: Box>, +} + +#[cfg(feature = "encrypt")] +impl ChainedCodec { + pub(crate) fn new(first: Box>, second: Box>) -> Self { + Self { first, second } + } +} + +#[cfg(feature = "encrypt")] +impl Codec for ChainedCodec +where + A: Send + Sync, + M: Send + Sync, + B: Send + Sync, +{ + fn encode(&self, ctx: &CodecContext<'_>, value: &A) -> Result { + let middle = self.first.encode(ctx, value)?; + self.second.encode(ctx, &middle) + } + + fn decode(&self, ctx: &CodecContext<'_>, value: B) -> Result, Error> { + match self.second.decode(ctx, value)? { + DecodeOutcome::Value(middle) => self.first.decode(ctx, middle), + DecodeOutcome::SoftFailure => Ok(DecodeOutcome::SoftFailure), + } + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn codec_context_exposes_bound_and_keyless_keys() { + let bound = CodecContext::new(b"storage-key"); + assert_eq!(bound.key(), b"storage-key"); + assert!(CodecContext::keyless().key().is_empty()); + } +} diff --git a/crates/cachet/src/transform/encrypt/codec.rs b/crates/cachet/src/transform/encrypt/codec.rs new file mode 100644 index 000000000..2c63aa7f0 --- /dev/null +++ b/crates/cachet/src/transform/encrypt/codec.rs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Adapts a [`ValueProtector`] into a [`Codec`] pipeline stage. + +use std::sync::Arc; + +use bytesbuf::BytesView; + +use super::{Rejection, Unprotected, ValueProtector}; +use crate::Error; +use crate::telemetry::CacheTelemetry; +use crate::transform::{Codec, CodecContext, DecodeOutcome}; + +/// A byte-to-byte [`Codec`] stage that authenticates values with a [`ValueProtector`], +/// binding the storage key (carried by the [`CodecContext`]) as associated data. +/// +/// Chained after serialization so it protects the serialized bytes; each backing tier is +/// authenticated independently on read. Being the stage that performs the authentication, +/// it *owns* the protection telemetry: an +/// [`AuthenticationFailed`](Rejection::AuthenticationFailed) rejection records +/// `cache.unprotect_failed`, while a [`Malformed`](Rejection::Malformed) one is a silent +/// miss. Both surface to the general codec pipeline as a plain +/// [`DecodeOutcome::SoftFailure`], so no crypto category leaks into the shared decode +/// vocabulary. +pub(crate) struct ProtectorCodec { + protector: Arc, + telemetry: CacheTelemetry, +} + +impl ProtectorCodec { + pub(crate) fn new(protector: Arc, telemetry: CacheTelemetry) -> Self { + Self { protector, telemetry } + } +} + +impl Codec for ProtectorCodec { + fn encode(&self, ctx: &CodecContext<'_>, value: &BytesView) -> Result { + self.protector.protect(ctx.key(), value) + } + + fn decode(&self, ctx: &CodecContext<'_>, value: BytesView) -> Result, Error> { + match self.protector.unprotect(ctx.key(), &value)? { + Unprotected::Recovered(recovered) => Ok(DecodeOutcome::Value(recovered)), + Unprotected::Rejected(Rejection::AuthenticationFailed) => { + self.telemetry.record_unprotect_failure(std::any::type_name::()); + Ok(DecodeOutcome::SoftFailure) + } + Unprotected::Rejected(Rejection::Malformed) => Ok(DecodeOutcome::SoftFailure), + } + } +} diff --git a/crates/cachet/src/transform/encrypt/mock.rs b/crates/cachet/src/transform/encrypt/mock.rs new file mode 100644 index 000000000..eac86999e --- /dev/null +++ b/crates/cachet/src/transform/encrypt/mock.rs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! A deterministic, crypto-free [`ValueProtector`] test double. + +use bytesbuf::BytesView; + +use super::{Rejection, Unprotected, ValueProtector}; +use crate::Error; + +/// Length of the mock protector's nonce prefix, in bytes. +const MOCK_NONCE_SIZE: usize = 12; + +/// Width of the little-endian context-length field stored after the nonce. +const CONTEXT_LEN_SIZE: usize = size_of::(); + +/// A deterministic, crypto-free [`ValueProtector`] for tests. +/// +/// Available with the `test-util` feature. Use it to exercise an +/// [`protect_with`](crate::SerializeBuilder::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 +/// [`Rejected(AuthenticationFailed)`](Unprotected::Rejected) 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. Structurally invalid (truncated) input returns +/// [`Rejected(Malformed)`](Unprotected::Rejected), 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() + } + + /// Returns the next monotonic nonce, so repeated `protect` calls of identical input + /// still differ yet stay reproducible. + fn next_nonce(&self) -> [u8; MOCK_NONCE_SIZE] { + use std::sync::atomic::Ordering; + Self::nonce_bytes(self.counter.fetch_add(1, Ordering::Relaxed)) + } + + /// 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 { + // Stored layout: nonce || context_len (u64 LE) || context || masked_body. + let nonce = self.next_nonce(); + let mut out = Vec::with_capacity(MOCK_NONCE_SIZE + CONTEXT_LEN_SIZE + 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 { + // Peel the stored layout apart segment by segment: + // nonce || context_len (u64 LE) || context || masked_body. + // Structural problems (a blob that isn't a valid envelope) are `Malformed`; + // a well-formed envelope whose bound context doesn't match is `AuthenticationFailed`. + let bytes = protected.to_vec(); + + let Some((nonce, rest)) = bytes.split_at_checked(MOCK_NONCE_SIZE) else { + return Ok(Unprotected::Rejected(Rejection::Malformed)); // truncated nonce + }; + let Some((len_bytes, rest)) = rest.split_at_checked(CONTEXT_LEN_SIZE) else { + return Ok(Unprotected::Rejected(Rejection::Malformed)); // truncated length + }; + + // 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 rather than partially + // matched. Compared in u64 space to avoid any usize truncation. + let stored_len = u64::from_le_bytes( + len_bytes + .try_into() + .expect("CONTEXT_LEN_SIZE bytes, guarded by split_at_checked above"), + ); + if stored_len != context.len() as u64 { + return Ok(Unprotected::Rejected(Rejection::AuthenticationFailed)); // context length mismatch + } + + let Some((stored_context, masked_body)) = rest.split_at_checked(context.len()) else { + return Ok(Unprotected::Rejected(Rejection::Malformed)); // truncated context + }; + if stored_context != context { + return Ok(Unprotected::Rejected(Rejection::AuthenticationFailed)); // context mismatch + } + + let nonce: [u8; MOCK_NONCE_SIZE] = nonce.try_into().expect("MOCK_NONCE_SIZE bytes, guarded by split_at_checked above"); + let mut body = masked_body.to_vec(); + Self::mask(&nonce, &mut body); + Ok(Unprotected::Recovered(BytesView::from(body))) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + fn view(data: &[u8]) -> BytesView { + BytesView::from(data.to_vec()) + } + + #[test] + fn mock_protector_categorizes_malformed_mismatched_and_valid_input() { + let p = MockValueProtector::new(); + let kind = |bytes: Vec| match p.unprotect(b"context", &BytesView::from(bytes)) { + Ok(Unprotected::Recovered(_)) => None, + Ok(Unprotected::Rejected(k)) => Some(k), + Err(e) => panic!("unexpected error: {e}"), + }; + + // Structurally invalid blobs are Malformed (benign): too short to hold the nonce + // prefix, or the 8-byte length field, or the declared context. + assert_eq!(kind(vec![0u8; 4]), Some(Rejection::Malformed), "truncated nonce"); + assert_eq!(kind(vec![0u8; MOCK_NONCE_SIZE]), Some(Rejection::Malformed), "truncated length"); + 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_eq!(kind(truncated_ctx), Some(Rejection::Malformed), "truncated context"); + + // Well-formed envelopes whose bound context doesn't match are AuthenticationFailed: + // a strict-prefix (length-mismatched) context, or a same-length different context. + let extended = p.protect(b"context-long", &view(b"value")).expect("protect should succeed"); + assert_eq!( + kind(extended.to_vec()), + Some(Rejection::AuthenticationFailed), + "prefix/length-mismatched context" + ); + let other = p.protect(b"kontext", &view(b"value")).expect("protect should succeed"); + assert_eq!(kind(other.to_vec()), Some(Rejection::AuthenticationFailed), "context mismatch"); + + // A well-formed round-trip under the expected context recovers. + let valid = p.protect(b"context", &view(b"value")).expect("protect should succeed"); + assert_eq!(kind(valid.to_vec()), None, "a valid round-trip must recover"); + } +} diff --git a/crates/cachet/src/transform/encrypt/mod.rs b/crates/cachet/src/transform/encrypt/mod.rs new file mode 100644 index 000000000..ab278fc28 --- /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::SerializeBuilder::protect_with). It is installed as a +//! [`Codec`](crate::Codec) stage in the value pipeline, after serialization, where the +//! storage key is available and bound to each value. +//! +//! See the crate-level "Encryption Boundary" docs for a reference [`ValueProtector`] +//! implementation backed by `SymCrypt` (FIPS-certifiable AES-256-GCM). + +mod codec; +#[cfg(any(feature = "test-util", test))] +mod mock; +mod protector; + +pub(crate) use codec::ProtectorCodec; +#[cfg(any(feature = "test-util", test))] +pub use mock::MockValueProtector; +pub use protector::{Rejection, Unprotected, ValueProtector}; diff --git a/crates/cachet/src/transform/encrypt/protector.rs b/crates/cachet/src/transform/encrypt/protector.rs new file mode 100644 index 000000000..497eebd1e --- /dev/null +++ b/crates/cachet/src/transform/encrypt/protector.rs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The [`ValueProtector`] authenticated-protection contract. + +use bytesbuf::BytesView; + +use crate::Error; + +/// Why [`unprotect`](ValueProtector::unprotect) could not recover a value. +/// +/// This is a *protection-domain* category — it deliberately lives here rather than on the +/// general [`DecodeOutcome`](crate::DecodeOutcome), which every codec returns and which +/// has no business carrying crypto concepts. Both variants read as a cache miss; they +/// differ only in whether the miss is worth a security signal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Rejection { + /// The stored bytes are not a valid protected envelope — too short, wrong framing, or + /// an unknown version. Usually benign (corruption or a rolling format migration), so a + /// fronting tier treats it as a silent miss. + Malformed, + /// A well-formed value failed its authentication check — tampered, wrong key, or + /// relocated to a different key. Security-relevant: a fronting tier records it. + AuthenticationFailed, +} + +/// The outcome of [`ValueProtector::unprotect`]. +/// +/// Distinguishes a recovered value from the two ways recovery can fail, so a fronting +/// tier can treat them differently even though both read as a cache miss. +#[derive(Debug)] +#[expect( + clippy::large_enum_variant, + reason = "Recovered is the common, hot-path variant, so sizing the enum to BytesView is intended; boxing it would add an allocation on every successful unprotect, while Rejected is rare" +)] +pub enum Unprotected { + /// The value authenticated and was recovered. + Recovered(BytesView), + /// The value could not be recovered; see [`Rejection`] for why. + Rejected(Rejection), +} + +/// 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. The fronting tier 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::SerializeBuilder::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 +/// [`Unprotected::Rejected`] 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) reports three outcomes: +/// - [`Ok(Unprotected::Recovered(v))`](Unprotected::Recovered) — the value authenticated. +/// - [`Ok(Unprotected::Rejected(reason))`](Unprotected::Rejected) — unrecoverable; the +/// cache treats it as a miss. Use [`Rejection::AuthenticationFailed`] when an +/// authentication check was attempted and failed (tampering, wrong key, relocation), +/// and [`Rejection::Malformed`] when the stored bytes are not a valid envelope +/// (too short, wrong framing, unknown version). +/// - `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`, or reports why it could not + /// be authenticated. + /// + /// # Errors + /// + /// Returns `Err` only if the operation could not be attempted. An authentication or + /// format failure is reported as [`Ok(Unprotected::Rejected(_))`](Unprotected::Rejected). + fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result; +} diff --git a/crates/cachet/src/transform/mod.rs b/crates/cachet/src/transform/mod.rs index 16e292d5d..8127350aa 100644 --- a/crates/cachet/src/transform/mod.rs +++ b/crates/cachet/src/transform/mod.rs @@ -36,9 +36,19 @@ //! 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}; -pub(crate) use tier::TransformAdapter; +#[cfg(feature = "encrypt")] +pub(crate) use codec::ChainedCodec; +pub use codec::{Codec, CodecContext, 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::ProtectorCodec; +#[cfg(feature = "encrypt")] +pub use encrypt::{Rejection, Unprotected, ValueProtector}; +pub(crate) use tier::{MakeContext, TransformAdapter, keyless_context}; diff --git a/crates/cachet/src/transform/testing.rs b/crates/cachet/src/transform/testing.rs index 5a4c7c87f..9824b946d 100644 --- a/crates/cachet/src/transform/testing.rs +++ b/crates/cachet/src/transform/testing.rs @@ -7,7 +7,7 @@ use std::fmt::Debug; use std::marker::PhantomData; use super::codec::DecodeOutcome; -use crate::{Codec, Encoder, Error}; +use crate::{Codec, CodecContext, Error}; /// A mock codec for testing that uses identity encoding and a configurable decode outcome. /// @@ -17,16 +17,15 @@ use crate::{Codec, Encoder, Error}; /// /// ```ignore /// use cachet::transform::testing::MockCodec; -/// use cachet::DecodeOutcome; /// /// // A codec that always succeeds /// let codec = MockCodec::::value(); /// /// // A codec that always returns a soft failure -/// let codec = MockCodec::::soft_failure("version mismatch"); +/// let codec = MockCodec::::soft_failure(); /// ``` pub(crate) struct MockCodec { - soft_failure: Option<&'static str>, + soft_failure: bool, _phantom: PhantomData, } @@ -35,32 +34,31 @@ impl MockCodec { #[must_use] pub(crate) fn value() -> Self { Self { - soft_failure: None, + soft_failure: false, _phantom: PhantomData, } } - /// Creates a mock codec that always returns [`DecodeOutcome::SoftFailure`] with the given reason. + /// Creates a mock codec that always returns [`DecodeOutcome::SoftFailure`]. #[must_use] - pub(crate) fn soft_failure(reason: &'static str) -> Self { + pub(crate) fn soft_failure() -> Self { Self { - soft_failure: Some(reason), + soft_failure: true, _phantom: PhantomData, } } } -impl Encoder for MockCodec { - fn encode(&self, value: &T) -> Result { +impl Codec for MockCodec { + fn encode(&self, _ctx: &CodecContext<'_>, value: &T) -> Result { Ok(value.clone()) } -} -impl Codec for MockCodec { - fn decode(&self, value: T) -> Result, Error> { - match self.soft_failure { - Some(reason) => Ok(DecodeOutcome::SoftFailure(reason)), - None => Ok(DecodeOutcome::Value(value)), + fn decode(&self, _ctx: &CodecContext<'_>, value: T) -> Result, Error> { + if self.soft_failure { + Ok(DecodeOutcome::SoftFailure) + } else { + Ok(DecodeOutcome::Value(value)) } } } @@ -78,20 +76,26 @@ mod tests { #[test] fn value_codec_roundtrips() { let codec = MockCodec::::value(); - assert_eq!(codec.encode(&42).unwrap(), 42); - assert!(matches!(codec.decode(42).unwrap(), DecodeOutcome::Value(42))); + assert_eq!(codec.encode(&CodecContext::keyless(), &42).unwrap(), 42); + assert!(matches!( + codec.decode(&CodecContext::keyless(), 42).unwrap(), + DecodeOutcome::Value(42) + )); } #[test] fn soft_failure_codec_decodes_to_soft_failure() { - let codec = MockCodec::::soft_failure("bad data"); - assert!(matches!(codec.decode(42).unwrap(), DecodeOutcome::SoftFailure("bad data"))); + let codec = MockCodec::::soft_failure(); + assert!(matches!( + codec.decode(&CodecContext::keyless(), 42).unwrap(), + DecodeOutcome::SoftFailure + )); } #[test] fn soft_failure_codec_encodes_normally() { - let codec = MockCodec::::soft_failure("bad data"); - assert_eq!(codec.encode(&42).unwrap(), 42); + let codec = MockCodec::::soft_failure(); + assert_eq!(codec.encode(&CodecContext::keyless(), &42).unwrap(), 42); } #[test] @@ -100,8 +104,8 @@ mod tests { let debug = format!("{codec:?}"); assert!(debug.contains("MockCodec")); - let codec = MockCodec::::soft_failure("reason"); + let codec = MockCodec::::soft_failure(); let debug = format!("{codec:?}"); - assert!(debug.contains("reason")); + assert!(debug.contains("soft_failure: true")); } } diff --git a/crates/cachet/src/transform/tier.rs b/crates/cachet/src/transform/tier.rs index 70d79c1f9..2a8ba64c2 100644 --- a/crates/cachet/src/transform/tier.rs +++ b/crates/cachet/src/transform/tier.rs @@ -3,9 +3,23 @@ use std::fmt::Debug; -use crate::transform::codec::DecodeOutcome; +use crate::transform::codec::{CodecContext, DecodeOutcome}; use crate::{CacheEntry, CacheTier, Codec, Encoder, Error, SizeError}; +/// Builds the per-operation [`CodecContext`] from a mapped storage key. +/// +/// Returns a keyless context for a plain type transform, or a key-bound context for the +/// serialize/protect boundary (where the storage key is bytes and doubles as associated +/// data). It yields the whole [`CodecContext`] — not just the key — so new context fields +/// can be added on the struct without changing this signature; boxed so both forms share +/// one adapter type. +pub(crate) type MakeContext = Box Fn(&'a KT) -> CodecContext<'a> + Send + Sync>; + +/// A [`MakeContext`] that binds no key — the codec receives a keyless context. +pub(crate) fn keyless_context() -> MakeContext { + Box::new(|_| CodecContext::keyless()) +} + /// Adapter that transforms keys and values between user types and storage types. /// /// `TransformAdapter`: @@ -13,7 +27,11 @@ use crate::{CacheEntry, CacheTier, Codec, Encoder, Error, SizeError}; /// - `KT, VT` are the storage types (the types used by the inner `S: CacheTier`) /// - `key_encoder: K->KT` (one-directional), `value_codec: V<->VT` (bidirectional) /// -/// Implements `CacheTier` by encoding keys/values to `KT, VT` for the inner tier. +/// `make_context` builds the [`CodecContext`] passed to the value codec: keyless for a +/// plain transform, or key-bound for an authenticated (serialize/protect) boundary. The +/// adapter is a pure transform: on read a [`DecodeOutcome::SoftFailure`] simply becomes a +/// cache miss. Any observability of *why* (e.g. an authentication failure) is owned by +/// the value codec that detected it, not by this general adapter. pub(crate) struct TransformAdapter where S: CacheTier, @@ -21,18 +39,25 @@ where inner: S, key_encoder: Box>, value_codec: Box>, + make_context: MakeContext, } impl TransformAdapter where S: CacheTier, { - /// Creates a new `TransformAdapter` from pre-boxed codecs. - pub(crate) fn from_boxed(inner: S, key_encoder: Box>, value_codec: Box>) -> Self { + /// Creates a new `TransformAdapter` from pre-boxed codecs and a context builder. + pub(crate) fn from_boxed( + inner: S, + key_encoder: Box>, + value_codec: Box>, + make_context: MakeContext, + ) -> Self { Self { inner, key_encoder, value_codec, + make_context, } } } @@ -47,32 +72,35 @@ where { async fn get(&self, key: &K) -> Result>, Error> { let mapped_key = self.key_encoder.encode(key)?; - let entry_option = self.inner.get(&mapped_key).await?; - if let Some(entry) = entry_option { - let ttl = entry.ttl(); - let cached_at = entry.cached_at(); - let decoded = self.value_codec.decode(entry.into_value())?; - match decoded { - DecodeOutcome::Value(v) => { - let mut e = CacheEntry::new(v); - if let Some(ttl) = ttl { - e.set_ttl(ttl); - } - if let Some(t) = cached_at { - e.ensure_cached_at(t); - } - Ok(Some(e)) + let Some(entry) = self.inner.get(&mapped_key).await? else { + return Ok(None); + }; + let ttl = entry.ttl(); + let cached_at = entry.cached_at(); + let stored = entry.into_value(); + + let ctx = (self.make_context)(&mapped_key); + match self.value_codec.decode(&ctx, stored)? { + DecodeOutcome::Value(v) => { + let mut e = CacheEntry::new(v); + if let Some(ttl) = ttl { + e.set_ttl(ttl); } - DecodeOutcome::SoftFailure(_) => Ok(None), + if let Some(t) = cached_at { + e.ensure_cached_at(t); + } + Ok(Some(e)) } - } else { - Ok(None) + DecodeOutcome::SoftFailure => Ok(None), } } async fn insert(&self, key: K, entry: CacheEntry) -> Result<(), Error> { let mapped_key = self.key_encoder.encode(&key)?; - let mapped_entry = entry.try_map_value(|v| self.value_codec.encode(&v))?; + let mapped_entry = { + let ctx = (self.make_context)(&mapped_key); + entry.try_map_value(|v| self.value_codec.encode(&ctx, &v))? + }; self.inner.insert(mapped_key, mapped_entry).await } @@ -106,7 +134,9 @@ where } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { + use bytesbuf::BytesView; use cachet_tier::MockCache; use super::*; @@ -120,15 +150,15 @@ mod tests { |v: i32| Ok::<_, std::convert::Infallible>(v.to_string()), ); // Exercise both directions so closure bodies are covered. - assert_eq!(codec.encode(&"42".to_string()).unwrap(), 42); - assert!(matches!(codec.decode(42).unwrap(), DecodeOutcome::Value(s) if s == "42")); + assert_eq!(codec.encode(&CodecContext::keyless(), &"42".to_string()).unwrap(), 42); + assert!(matches!(codec.decode(&CodecContext::keyless(), 42).unwrap(), DecodeOutcome::Value(s) if s == "42")); let key_encoder = TransformEncoder::new(|k: &String| k.parse::()); // Exercise the encoder so the wrapping closure is covered. assert_eq!(key_encoder.encode(&"7".to_string()).unwrap(), 7); let inner = MockCache::::new(); - let adapter = TransformAdapter::from_boxed(inner, Box::new(key_encoder), Box::new(codec)); + let adapter = TransformAdapter::from_boxed(inner, Box::new(key_encoder), Box::new(codec), keyless_context()); let debug = format!("{adapter:?}"); assert!(debug.contains("TransformAdapter")); } @@ -150,6 +180,7 @@ mod tests { inner, Box::new(TransformEncoder::new(|k: &String| k.parse::())), Box::new(TransformCodec::new(infallible(|v: &i32| *v), infallible_owned(|v: i32| v))), + keyless_context(), ); assert_eq!(adapter.len().await.expect("MockCache::len returns Ok"), 2); } @@ -172,6 +203,7 @@ mod tests { inner, Box::new(TransformEncoder::new(|k: &i32| Ok::<_, std::convert::Infallible>(*k))), Box::new(TransformCodec::new(infallible(|v: &i32| *v), infallible_owned(|v: i32| v))), + keyless_context(), ); let result = adapter.get(&1).await.unwrap().expect("should be Some"); @@ -182,15 +214,56 @@ mod tests { #[cfg_attr(miri, ignore)] #[tokio::test] - async fn get_returns_none_on_soft_failure() { + async fn soft_failure_reads_as_a_miss() { + // The adapter is a pure transform: a decode soft-failure is just a miss, with no + // telemetry of its own (that belongs to whichever codec detected the failure). let inner = MockCache::with_data(std::iter::once((1, CacheEntry::new(42))).collect()); let adapter = TransformAdapter::from_boxed( inner, Box::new(TransformEncoder::new(|k: &i32| Ok::<_, std::convert::Infallible>(*k))), - Box::new(MockCodec::::soft_failure("test failure")), + Box::new(MockCodec::::soft_failure()), + keyless_context(), + ); + + assert!(adapter.get(&1).await.unwrap().is_none(), "a soft failure reads as a miss"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn key_context_binds_the_mapped_key() { + // A codec whose decode returns the context key proves the adapter threads the + // mapped key bytes into the codec context (a keyless context would yield empty). + let inner = MockCache::::new(); + let adapter = TransformAdapter::from_boxed( + inner, + Box::new(TransformEncoder::new(|k: &BytesView| Ok::<_, std::convert::Infallible>(k.clone()))), + Box::new(KeyReportingCodec), + Box::new(|key: &BytesView| CodecContext::from_key(std::borrow::Cow::Owned(key.to_vec()))), ); - let result = adapter.get(&1).await.unwrap(); - assert!(result.is_none(), "soft failure should return None"); + adapter + .insert(BytesView::from(b"k".to_vec()), CacheEntry::new(BytesView::from(b"v".to_vec()))) + .await + .expect("insert should succeed"); + let got = adapter + .get(&BytesView::from(b"k".to_vec())) + .await + .expect("get should succeed") + .expect("present"); + assert_eq!(got.value().to_vec(), b"k", "decode must observe the bound key via the context"); + } + + /// A codec that stores the value as-is but decodes to the context key, so a test can + /// observe which key the adapter bound into the context. + struct KeyReportingCodec; + + impl Codec for KeyReportingCodec { + fn encode(&self, _ctx: &CodecContext<'_>, value: &BytesView) -> Result { + Ok(value.clone()) + } + + fn decode(&self, ctx: &CodecContext<'_>, _value: BytesView) -> Result, Error> { + Ok(DecodeOutcome::Value(BytesView::from(ctx.key().to_vec()))) + } } } diff --git a/crates/cachet/tests/encrypt.rs b/crates/cachet/tests/encrypt.rs new file mode 100644 index 000000000..8ab661956 --- /dev/null +++ b/crates/cachet/tests/encrypt.rs @@ -0,0 +1,367 @@ +// 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, ValueProtector}; +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("SerializeBuilder")); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn encrypt_chained_post_transform_fallbacks() { + // Chain two byte-speaking fallback tiers, each with its own `.serialize()` + // boundary; the value must round-trip through the composed hierarchy and each tier + // stores ciphertext. + 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())) + .serialize() + .protect_with(MockValueProtector::new()) + .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"); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn tampered_first_post_tier_falls_through_to_valid_second() { + // Both post tiers receive a copy on insert. If the first tier's copy is tampered + // but the second's is intact, a read must fall through to the second and return the + // value — a tampered tier must not shadow a good copy in a later tier. + 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())) + .serialize() + .protect_with(MockValueProtector::new()) + .fallback(Cache::builder::(Clock::new_frozen()).storage(l3.clone())) + .build(); + + cache.insert("k".to_string(), "v".to_string()).await.expect("insert should succeed"); + + // Corrupt the first post tier's stored ciphertext, and evict L1 so the read must + // consult the encrypted post chain. + l2.insert(BytesView::from(serialized("k")), CacheEntry::new(BytesView::from(vec![0u8; 2]))) + .await + .expect("tampering should succeed"); + l1.invalidate(&"k".to_string()).await.expect("invalidate should succeed"); + + let fetched = cache.get("k").await.expect("get should succeed"); + assert_eq!( + fetched.map(|entry| entry.value().clone()), + Some("v".to_string()), + "a tampered first post tier must not shadow the valid copy in the next tier" + ); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn unprotect_failure_emits_structured_event_correlated_with_get() { + use cachet::RecordingEventHandler; + use cachet::telemetry::attributes::EVENT_UNPROTECT_FAILED; + + // Assert the public structured event schema and request-id correlation, not just + // the tracing text. Handlers are taken by value, so register a clone and keep the + // original to read back the captured events. + let handler = RecordingEventHandler::new(); + let l1 = MockCache::::new(); + let l2 = MockCache::::new(); + let cache = Cache::builder::(Clock::new_frozen()) + .storage(l1.clone()) + .event_handler(handler.clone()) + .serialize() + .protect_with(MockValueProtector::new()) + .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) + .build(); + + // Plant a well-formed blob protected under a DIFFERENT key directly in the untrusted + // post tier. A read falls past L1, finds it, and fails authentication (the bound key + // doesn't match) — the security-relevant case that must emit the event. + let planted = MockValueProtector::new() + .protect(&serialized("wrong-key"), &BytesView::from(serialized("v"))) + .expect("crafting the blob should succeed"); + l2.insert(BytesView::from(serialized("k")), CacheEntry::new(planted)) + .await + .expect("planting the blob should succeed"); + + assert!( + cache.get("k").await.expect("get should succeed").is_none(), + "an unprotectable value must read as a miss" + ); + + // The structured tier callback must carry the unprotect-failure outcome, be tagged + // as a fallback-side event, and carry a real (nonzero) request id. + let unprotect = handler + .tier_events() + .into_iter() + .find(|event| event.outcome == EVENT_UNPROTECT_FAILED) + .expect("a cache.unprotect_failed tier event must be emitted"); + assert!(unprotect.fallback, "unprotect_failed must be tagged fallback = true"); + assert_ne!(unprotect.request_id, 0, "unprotect_failed must carry a real request id"); + + // That request id must match the completed `cache.get` operation event. + let get_op = handler + .operation_events() + .into_iter() + .find(|event| event.operation == "cache.get") + .expect("a completed cache.get operation event must be emitted"); + assert_eq!( + get_op.request_id, unprotect.request_id, + "unprotect_failed must correlate with the get that observed it" + ); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn malformed_protected_blob_is_a_silent_miss() { + use cachet::RecordingEventHandler; + use cachet::telemetry::attributes::EVENT_UNPROTECT_FAILED; + + // A structurally invalid blob (too short to be an envelope) is a benign Malformed + // reject, NOT an authentication failure: it reads as a miss but must not raise the + // security-relevant cache.unprotect_failed event (no crying wolf on format drift). + let handler = RecordingEventHandler::new(); + let l1 = MockCache::::new(); + let l2 = MockCache::::new(); + let cache = Cache::builder::(Clock::new_frozen()) + .storage(l1.clone()) + .event_handler(handler.clone()) + .serialize() + .protect_with(MockValueProtector::new()) + .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) + .build(); + + l2.insert(BytesView::from(serialized("k")), CacheEntry::new(BytesView::from(vec![0u8; 2]))) + .await + .expect("planting the blob should succeed"); + + assert!( + cache.get("k").await.expect("get should succeed").is_none(), + "a malformed value must read as a miss" + ); + assert!( + handler + .tier_events() + .into_iter() + .all(|event| event.outcome != EVENT_UNPROTECT_FAILED), + "a malformed (non-authentication) failure must not emit cache.unprotect_failed" + ); +} diff --git a/crates/cachet/tests/transform.rs b/crates/cachet/tests/transform.rs index d2c72044c..b81b7f66e 100644 --- a/crates/cachet/tests/transform.rs +++ b/crates/cachet/tests/transform.rs @@ -272,13 +272,15 @@ async fn transform_on_fallback_builder() { assert_eq!(*result.value(), 10); } -// -- Chained post-transform fallback -- +// -- Per-tier transform fallbacks -- #[cfg_attr(miri, ignore)] #[tokio::test] -async fn chained_post_transform_fallback() { +async fn per_tier_transform_fallbacks() { let clock = Clock::new_frozen(); + // Each byte-speaking tier gets its own `.transform()` boundary, so decoding stays + // below every fallback junction. let cache = Cache::builder::(clock.clone()) .memory() .ttl(Duration::from_mins(1)) @@ -287,6 +289,10 @@ async fn chained_post_transform_fallback() { TransformCodec::new(infallible(|v: &i32| v.to_string()), |v: String| v.parse::()), ) .fallback(Cache::builder::(clock.clone()).storage(MockCache::new())) + .transform( + TransformEncoder::infallible(|k: &i32| k.to_string()), + TransformCodec::new(infallible(|v: &i32| v.to_string()), |v: String| v.parse::()), + ) .fallback(Cache::builder::(clock).storage(MockCache::new())) .build();