diff --git a/.spelling b/.spelling index 19808e58f..10dbfb722 100644 --- a/.spelling +++ b/.spelling @@ -11,6 +11,8 @@ 5xx = >= +AAD +AEAD ABA ABI ACLs @@ -134,6 +136,7 @@ RAII RDME RMW RMWs +RNG RPC RSS Rc @@ -250,6 +253,7 @@ chainable chrono chunked chunkless +ciphertext clippt clippy clonable @@ -273,6 +277,10 @@ covariant coverage.json crates.io crypto +cryptographic +cryptographically +SymCrypt +undecryptable customizable cutover dSMS @@ -285,6 +293,8 @@ dec decrement decrementer decrementers +decrypt +decrypts deduplicating deduplication deps @@ -334,6 +344,7 @@ freelist freezable frontend fundle +GCM gRPC getter getters @@ -444,6 +455,8 @@ passthrough performant pessimizes pointee +plaintext +pluggable polyfill pre-approved pre-generate diff --git a/crates/cachet/Cargo.toml b/crates/cachet/Cargo.toml index 4dca1cd2d..ed47703fe 100644 --- a/crates/cachet/Cargo.toml +++ b/crates/cachet/Cargo.toml @@ -42,6 +42,7 @@ test-util = ["cachet_tier/test-util", "tick/test-util"] memory = ["dep:cachet_memory"] service = ["dep:cachet_service", "dep:layered"] serialize = ["dep:serde", "dep:postcard", "dep:bytesbuf"] +encrypt = ["dep:bytesbuf"] telemetry = [] [dependencies] diff --git a/crates/cachet/README.md b/crates/cachet/README.md index 180cd2cda..5901cb198 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 `.encrypt_with(cipher)` on serialized builders and the `AeadCipher` trait for authenticated value encryption with a caller-supplied cipher.| |`test-util`|❌|Enables `MockCache`, frozen-clock utilities, and other test helpers.| ## Examples @@ -226,6 +227,113 @@ let cache = Cache::builder::(clock) cache.insert("key".to_string(), "value".to_string()).await?; ``` +### Encryption Boundary + +With the `encrypt` feature, chain `.encrypt_with(cipher)` after `.serialize()` to +encrypt values with a caller-supplied `AeadCipher` before they reach the fallback +tier. The cachet crate ships only the encryption *mechanism* — it has **no +cryptographic dependency of its own**, so you plug in a cipher backed by whichever +approved cryptographic library your project mandates. The cipher receives each +value’s storage key as associated data and must authenticate it, which +cryptographically binds every value to its key. + +Only values are encrypted: keys are left serialized-but-unencrypted 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 decrypt (corrupt, truncated, wrong key, tampered, or +relocated to a different key) is treated as a cache miss and emits a +`cache.decrypt_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() + .encrypt_with(my_cipher) // any `AeadCipher` implementation + .fallback(remote) + .build(); + +cache.insert("key".to_string(), "value".to_string()).await?; +``` + +#### Example: a `SymCrypt`-backed AES-256-GCM cipher + +[SymCrypt][__link20] is a FIPS-certifiable, +SDL-approved cryptographic library. The following `AeadCipher` implementation wraps +it using the [`symcrypt`][__link21] crate; it stores each +value as `nonce || ciphertext || tag` with a fresh random 96-bit nonce and +authenticates 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::{AeadCipher, DecodeOutcome, Error}; +use symcrypt::cipher::BlockCipherType; +use symcrypt::gcm::GcmExpandedKey; + +const NONCE_SIZE: usize = 12; +const TAG_SIZE: usize = 16; + +pub struct Aes256GcmCipher { + key: GcmExpandedKey, +} + +impl Aes256GcmCipher { + 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 AeadCipher for Aes256GcmCipher { + fn encrypt(&self, aad: &[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, aad, &mut head[NONCE_SIZE..], tag); + Ok(result.into()) + } + + fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error> { + let bytes = ciphertext.to_vec(); + if bytes.len() < NONCE_SIZE + TAG_SIZE { + return Ok(DecodeOutcome::SoftFailure("ciphertext too short")); + } + let (nonce, rest) = bytes.split_at(NONCE_SIZE); + let (body, tag) = rest.split_at(rest.len() - TAG_SIZE); + let nonce: &[u8; NONCE_SIZE] = nonce.try_into().expect("exactly 12 bytes"); + + let mut buffer = body.to_vec(); + match self.key.decrypt_in_place(nonce, aad, &mut buffer, tag) { + // Any authentication failure is a soft failure: the entry reads as a miss. + Ok(()) => Ok(DecodeOutcome::Value(buffer.into())), + Err(_) => Ok(DecodeOutcome::SoftFailure("AES-GCM decryption failed")), + } + } +} +``` + +Because each encryption 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 +341,13 @@ Cachet provides two complementary telemetry channels: ### Tracing events Enable with the `logs` feature and `.enable_logs()` on the cache builder. -Each tier outcome and operation completion emits a structured [`tracing`][__link20] event. +Each tier outcome and operation completion emits a structured [`tracing`][__link22] event. **Tier events** carry `cache.name`, `cache.event`, and `cache.duration_ns`. **Operation-complete events** carry `cache.name`, `cache.operation`, `cache.duration_ns`, and `cache.coalesced`. -Use [`telemetry::attributes`][__link21] constants to filter and match events in a +Use [`telemetry::attributes`][__link23] constants to filter and match events in a custom `tracing_subscriber::Layer`: ```rust @@ -265,10 +373,10 @@ See the `telemetry_subscriber` example for a complete demonstration. ### Event handler callback API -Register a [`CacheEventHandler`][__link22] via +Register a [`CacheEventHandler`][__link24] via `.event_handler(handler)` on the cache builder to receive typed -[`CacheTierEvent`][__link23] and -[`CacheOperationEvent`][__link24] callbacks. +[`CacheTierEvent`][__link25] and +[`CacheOperationEvent`][__link26] callbacks. Events carry a `request_id` for correlating tier outcomes with their parent operation. Works independently of the `logs` feature. @@ -280,7 +388,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]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQb_xlIDv3a6WgboIYzdhk5tYwbm8NaNvZXwrcbhIXs0eaeycFhZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC44LjCCbWNhY2hldF9tZW1vcnllMC40LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbJYL19q8vzBMb_QI_68rGifAb8ItjnxSNMDYbUffk0aZ7s_1hZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC44LjCCbWNhY2hldF9tZW1vcnllMC40LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA [__link0]: https://docs.rs/cachet/0.8.0/cachet/?search=TimeToRefresh [__link1]: https://crates.io/crates/uniflight/0.3.0 [__link10]: https://docs.rs/cachet_tier/0.2.6/cachet_tier/?search=CacheTier @@ -294,11 +402,13 @@ This crate was developed as part of The Oxidizer Project. Br [__link18]: https://docs.rs/cachet/0.8.0/cachet/?search=telemetry::attributes [__link19]: https://docs.rs/bytesbuf/0.6.0/bytesbuf/?search=BytesView [__link2]: https://docs.rs/cachet/0.8.0/cachet/?search=CacheBuilder::stampede_protection - [__link20]: https://crates.io/crates/tracing/0.1.44 - [__link21]: https://docs.rs/cachet/0.8.0/cachet/?search=telemetry::attributes - [__link22]: https://docs.rs/cachet/0.8.0/cachet/?search=telemetry::handler::CacheEventHandler - [__link23]: https://docs.rs/cachet/0.8.0/cachet/?search=telemetry::handler::CacheTierEvent - [__link24]: https://docs.rs/cachet/0.8.0/cachet/?search=telemetry::handler::CacheOperationEvent + [__link20]: https://github.com/microsoft/SymCrypt + [__link21]: https://crates.io/crates/symcrypt + [__link22]: https://crates.io/crates/tracing/0.1.44 + [__link23]: https://docs.rs/cachet/0.8.0/cachet/?search=telemetry::attributes + [__link24]: https://docs.rs/cachet/0.8.0/cachet/?search=telemetry::handler::CacheEventHandler + [__link25]: https://docs.rs/cachet/0.8.0/cachet/?search=telemetry::handler::CacheTierEvent + [__link26]: https://docs.rs/cachet/0.8.0/cachet/?search=telemetry::handler::CacheOperationEvent [__link3]: https://docs.rs/cachet_tier/0.2.6/cachet_tier/?search=CacheTier [__link4]: https://docs.rs/cachet_tier/0.2.6/cachet_tier/?search=DynamicCache [__link5]: https://docs.rs/cachet/0.8.0/cachet/?search=InsertPolicy diff --git a/crates/cachet/src/builder/encrypt.rs b/crates/cachet/src/builder/encrypt.rs new file mode 100644 index 000000000..b87a5c5df --- /dev/null +++ b/crates/cachet/src/builder/encrypt.rs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Builder for applying an authenticated-encryption boundary in the cache pipeline. +//! +//! `.encrypt_with(cipher)` becomes available once a [`TransformBuilder`] has reduced +//! values to [`BytesView`](bytesbuf::BytesView) (typically via +//! [`serialize`](crate::CacheBuilder::serialize)). It produces an +//! [`EncryptedTransformBuilder`], whose post-transform tier chain is wrapped in an +//! internal `EncryptedTier` at build time. + +use std::fmt::Debug; +use std::hash::Hash; +use std::marker::PhantomData; + +use bytesbuf::BytesView; +use cachet_tier::DynamicCache; +use tick::Clock; + +use super::buildable::{Buildable, type_name}; +use super::fallback::FallbackBuilder; +use super::sealed::{CacheTierBuilder, Sealed}; +use super::transform::TransformBuilder; +use crate::telemetry::CacheTelemetry; +use crate::transform::{AeadCipher, EncryptedTier, TransformAdapter}; +use crate::{Codec, Encoder}; + +/// The builder produced by [`TransformBuilder::encrypt_with`]. +/// +/// It mirrors [`TransformBuilder`] but fixes the storage types to +/// [`BytesView`] and carries an authenticated cipher. At build time the +/// post-transform tier chain is wrapped in an internal `EncryptedTier`, which +/// encrypts values and authenticates each value against its storage key. Add post +/// tiers with [`fallback`](Self::fallback) and finish with [`build`](Self::build), +/// exactly as with `TransformBuilder`. +pub struct EncryptedTransformBuilder { + pre: Pre, + post: Post, + key_encoder: Box>, + value_codec: Box>, + cipher: Box, + clock: Clock, + telemetry: CacheTelemetry, + stampede_protection: bool, +} + +impl Debug for EncryptedTransformBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EncryptedTransformBuilder") + .field("pre", &self.pre) + .field("post", &self.post) + .field("K", &std::any::type_name::()) + .field("V", &std::any::type_name::()) + .finish_non_exhaustive() + } +} + +// ── .encrypt_with() on TransformBuilder ── + +impl TransformBuilder { + /// Encrypts values with the given [`AeadCipher`](crate::AeadCipher) before they + /// reach the post-transform tier. + /// + /// Available once values are [`BytesView`] (typically after + /// [`serialize`](crate::CacheBuilder::serialize)). Supply a cipher backed by your + /// approved cryptographic library; the cipher receives the storage key as + /// associated data and must authenticate it (see the + /// [`AeadCipher`](crate::AeadCipher) contract). Keys themselves are never + /// encrypted, and a value that fails authentication is treated as a cache miss. + /// + /// # Examples + /// + /// ```ignore + /// use cachet::{AeadCipher, Cache, DecodeOutcome, Error}; + /// use tick::Clock; + /// + /// struct MyCipher; + /// impl AeadCipher for MyCipher { + /// fn encrypt( + /// &self, + /// aad: &[u8], + /// plaintext: &bytesbuf::BytesView, + /// ) -> Result { + /// # unimplemented!() + /// // ... encrypt with your approved library, authenticating `aad` ... + /// } + /// fn decrypt( + /// &self, + /// aad: &[u8], + /// ciphertext: &bytesbuf::BytesView, + /// ) -> Result, Error> { + /// # unimplemented!() + /// // ... decrypt, returning SoftFailure on any authentication failure ... + /// } + /// } + /// + /// let clock = Clock::new_tokio(); + /// let remote = Cache::builder::(clock.clone()).memory(); + /// + /// let cache = Cache::builder::(clock) + /// .memory() + /// .serialize() + /// .encrypt_with(MyCipher) + /// .fallback(remote) + /// .build(); + /// ``` + #[must_use] + pub fn encrypt_with(self, cipher: impl AeadCipher + 'static) -> EncryptedTransformBuilder { + EncryptedTransformBuilder { + pre: self.pre, + post: self.post, + key_encoder: self.key_encoder, + value_codec: self.value_codec, + cipher: Box::new(cipher), + clock: self.clock, + telemetry: self.telemetry, + stampede_protection: self.stampede_protection, + } + } +} + +// ── .fallback() on EncryptedTransformBuilder ── + +impl EncryptedTransformBuilder { + /// Sets the first post-transform storage tier (speaks encrypted `BytesView`). + pub fn fallback(self, fallback: FB) -> EncryptedTransformBuilder + where + FB: CacheTierBuilder, + { + EncryptedTransformBuilder { + pre: self.pre, + post: fallback, + key_encoder: self.key_encoder, + value_codec: self.value_codec, + cipher: self.cipher, + clock: self.clock, + telemetry: self.telemetry, + stampede_protection: self.stampede_protection, + } + } +} + +impl EncryptedTransformBuilder +where + Post: CacheTierBuilder, +{ + /// Adds another post-transform fallback tier (speaks encrypted `BytesView`). + pub fn fallback(self, fallback: FB) -> EncryptedTransformBuilder> + where + FB: CacheTierBuilder, + { + let clock = self.clock.clone(); + let telemetry = self.telemetry.clone(); + let stampede_protection = self.stampede_protection; + + let post_chain = FallbackBuilder { + name: None, + primary_builder: self.post, + fallback_builder: fallback, + clock: clock.clone(), + refresh: None, + telemetry: telemetry.clone(), + stampede_protection, + _phantom: PhantomData, + }; + + EncryptedTransformBuilder { + pre: self.pre, + post: post_chain, + key_encoder: self.key_encoder, + value_codec: self.value_codec, + cipher: self.cipher, + clock, + telemetry, + stampede_protection, + } + } +} + +// ── Sealed + CacheTierBuilder (allow nesting an encrypted transform) ── + +impl Sealed for EncryptedTransformBuilder +where + K: Clone + Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, +{ +} + +impl CacheTierBuilder for EncryptedTransformBuilder +where + K: Clone + Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, +{ +} + +// ── .build() on EncryptedTransformBuilder ── + +#[expect(private_bounds, reason = "Buildable is an internal trait")] +impl EncryptedTransformBuilder +where + K: Clone + Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + Pre: Buildable, + Post: Buildable, +{ + /// Builds the full cache hierarchy with the encrypted transform boundary. + pub fn build(self) -> crate::Cache { + >::build(self) + } +} + +impl Buildable for EncryptedTransformBuilder +where + K: Clone + Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + Pre: Buildable, + Post: Buildable, +{ + type TierOutput = DynamicCache; + + fn build(self) -> crate::Cache { + let clock = self.clock.clone(); + let telemetry = self.telemetry.clone(); + let stampede_protection = self.stampede_protection; + let tier = self.build_tier(clock.clone(), telemetry.clone(), false); + + crate::Cache::new(type_name::(None), tier, clock, telemetry, stampede_protection) + } + + fn build_tier(self, clock: Clock, telemetry: CacheTelemetry, fallback: bool) -> Self::TierOutput { + let pre_tier = self.pre.build_tier(clock.clone(), telemetry.clone(), fallback); + + // Build the post-transform tier chain and wrap it so values are encrypted + // (and key-authenticated) before reaching it. + let post_tier = self.post.build_tier(clock.clone(), telemetry.clone(), true); + let encrypted = EncryptedTier::new( + post_tier, + self.cipher, + telemetry.clone(), + type_name::>(None), + ); + let adapted = TransformAdapter::from_boxed(encrypted, self.key_encoder, self.value_codec); + + let fallback = crate::fallback::FallbackCache::new(type_name::(None), pre_tier, adapted, clock, None, telemetry); + + DynamicCache::new(fallback) + } +} diff --git a/crates/cachet/src/builder/mod.rs b/crates/cachet/src/builder/mod.rs index 292f417cb..354594131 100644 --- a/crates/cachet/src/builder/mod.rs +++ b/crates/cachet/src/builder/mod.rs @@ -8,6 +8,8 @@ mod buildable; mod cache; +#[cfg(feature = "encrypt")] +mod encrypt; mod fallback; mod sealed; #[cfg(any(feature = "serialize", test))] @@ -15,6 +17,8 @@ mod serialize; mod transform; pub use cache::CacheBuilder; +#[cfg(feature = "encrypt")] +pub use encrypt::EncryptedTransformBuilder; pub use fallback::FallbackBuilder; pub use sealed::CacheTierBuilder; pub use transform::TransformBuilder; diff --git a/crates/cachet/src/builder/transform.rs b/crates/cachet/src/builder/transform.rs index 97a48e6f7..000d0a794 100644 --- a/crates/cachet/src/builder/transform.rs +++ b/crates/cachet/src/builder/transform.rs @@ -26,14 +26,14 @@ use crate::{CacheTier, Codec, Encoder}; /// At build time, both sides are built into tiers, the post-transform tier is wrapped /// in a `TransformAdapter`, and combined with the pre-transform tier via fallback. pub struct TransformBuilder { - pre: Pre, - post: Post, - key_encoder: Box>, - value_codec: Box>, - clock: Clock, - telemetry: CacheTelemetry, - stampede_protection: bool, - _phantom: PhantomData<(K, V, KT, VT)>, + pub(super) pre: Pre, + pub(super) post: Post, + pub(super) key_encoder: Box>, + pub(super) value_codec: Box>, + pub(super) clock: Clock, + pub(super) telemetry: CacheTelemetry, + pub(super) stampede_protection: bool, + pub(super) _phantom: PhantomData<(K, V, KT, VT)>, } impl Debug for TransformBuilder { diff --git a/crates/cachet/src/lib.rs b/crates/cachet/src/lib.rs index 88f672025..afabf05da 100644 --- a/crates/cachet/src/lib.rs +++ b/crates/cachet/src/lib.rs @@ -154,6 +154,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 `.encrypt_with(cipher)` on serialized builders and the `AeadCipher` trait for authenticated value encryption with a caller-supplied cipher. | //! | `test-util` | ❌ | Enables `MockCache`, frozen-clock utilities, and other test helpers. | //! //! # Examples @@ -223,6 +224,116 @@ //! # }; //! ``` //! +//! ## Encryption Boundary +//! +//! With the `encrypt` feature, chain `.encrypt_with(cipher)` after `.serialize()` to +//! encrypt values with a caller-supplied `AeadCipher` before they reach the fallback +//! tier. The cachet crate ships only the encryption *mechanism* — it has **no +//! cryptographic dependency of its own**, so you plug in a cipher backed by whichever +//! approved cryptographic library your project mandates. The cipher receives each +//! value's storage key as associated data and must authenticate it, which +//! cryptographically binds every value to its key. +//! +//! Only values are encrypted: keys are left serialized-but-unencrypted 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 decrypt (corrupt, truncated, wrong key, tampered, or +//! relocated to a different key) is treated as a cache miss and emits a +//! `cache.decrypt_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() +//! .encrypt_with(my_cipher) // any `AeadCipher` implementation +//! .fallback(remote) +//! .build(); +//! +//! cache.insert("key".to_string(), "value".to_string()).await?; +//! # Ok::<(), cachet::Error>(()) +//! # }; +//! ``` +//! +//! ### Example: a `SymCrypt`-backed AES-256-GCM cipher +//! +//! [SymCrypt](https://github.com/microsoft/SymCrypt) is a FIPS-certifiable, +//! SDL-approved cryptographic library. The following `AeadCipher` 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 +//! authenticates 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::{AeadCipher, DecodeOutcome, Error}; +//! use symcrypt::cipher::BlockCipherType; +//! use symcrypt::gcm::GcmExpandedKey; +//! +//! const NONCE_SIZE: usize = 12; +//! const TAG_SIZE: usize = 16; +//! +//! pub struct Aes256GcmCipher { +//! key: GcmExpandedKey, +//! } +//! +//! impl Aes256GcmCipher { +//! 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 AeadCipher for Aes256GcmCipher { +//! fn encrypt(&self, aad: &[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, aad, &mut head[NONCE_SIZE..], tag); +//! Ok(result.into()) +//! } +//! +//! fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error> { +//! let bytes = ciphertext.to_vec(); +//! if bytes.len() < NONCE_SIZE + TAG_SIZE { +//! return Ok(DecodeOutcome::SoftFailure("ciphertext too short")); +//! } +//! let (nonce, rest) = bytes.split_at(NONCE_SIZE); +//! let (body, tag) = rest.split_at(rest.len() - TAG_SIZE); +//! let nonce: &[u8; NONCE_SIZE] = nonce.try_into().expect("exactly 12 bytes"); +//! +//! let mut buffer = body.to_vec(); +//! match self.key.decrypt_in_place(nonce, aad, &mut buffer, tag) { +//! // Any authentication failure is a soft failure: the entry reads as a miss. +//! Ok(()) => Ok(DecodeOutcome::Value(buffer.into())), +//! Err(_) => Ok(DecodeOutcome::SoftFailure("AES-GCM decryption failed")), +//! } +//! } +//! } +//! ``` +//! +//! Because each encryption 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: @@ -284,6 +395,9 @@ pub mod telemetry; mod transform; mod wrapper; +#[cfg(feature = "encrypt")] +#[doc(inline)] +pub use builder::EncryptedTransformBuilder; #[doc(inline)] pub use builder::{CacheBuilder, CacheTierBuilder, FallbackBuilder, TransformBuilder}; #[doc(inline)] @@ -307,5 +421,8 @@ pub use policy::InsertPolicy; pub use refresh::TimeToRefresh; #[doc(inline)] pub use telemetry::handler::{CacheEventHandler, CacheOperationEvent, CacheTierEvent}; +#[cfg(feature = "encrypt")] +#[doc(inline)] +pub use transform::AeadCipher; #[doc(inline)] pub use transform::{Codec, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, infallible, infallible_owned}; diff --git a/crates/cachet/src/telemetry/attributes.rs b/crates/cachet/src/telemetry/attributes.rs index b867cf094..e6306ac64 100644 --- a/crates/cachet/src/telemetry/attributes.rs +++ b/crates/cachet/src/telemetry/attributes.rs @@ -99,6 +99,12 @@ 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 authenticated decryption and was treated as a miss. +/// +/// Only emitted when the `encrypt` feature is enabled. Signals a corrupt, +/// truncated, wrong-key, tampered, or relocated ciphertext. +pub const EVENT_DECRYPT_FAILED: &str = "cache.decrypt_failed"; + #[cfg(test)] mod tests { use super::*; @@ -130,6 +136,7 @@ mod tests { EVENT_REFRESH_HIT, EVENT_REFRESH_MISS, EVENT_EVICTION, + EVENT_DECRYPT_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 8fb7037eb..f99e9e8b5 100644 --- a/crates/cachet/src/telemetry/cache.rs +++ b/crates/cachet/src/telemetry/cache.rs @@ -365,6 +365,31 @@ impl CacheTelemetry { ); } + /// Records that a stored value failed authenticated decryption and was + /// treated as a cache miss. + /// + /// Fires from an `EncryptedTier` on the `get` path, so the thread-local + /// request ID is set and correlates the failure with the operation that + /// observed it. Signals a corrupt, truncated, wrong-key, tampered, or + /// relocated ciphertext. The encrypted 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. + #[cfg(feature = "encrypt")] + pub(crate) fn record_decrypt_failure(&self, cache_name: CacheName) { + #[cfg(any(feature = "logs", test))] + if self.logging_enabled { + tracing::warn!(cache.name = cache_name, cache.event = attributes::EVENT_DECRYPT_FAILED); + } + + self.emit_tier_event( + Self::current_request_id(), + cache_name, + attributes::EVENT_DECRYPT_FAILED, + Duration::ZERO, + true, + ); + } + pub(crate) fn complete_operation( &self, request_id: RequestId, diff --git a/crates/cachet/src/transform/encrypt.rs b/crates/cachet/src/transform/encrypt.rs new file mode 100644 index 000000000..ccd750e74 --- /dev/null +++ b/crates/cachet/src/transform/encrypt.rs @@ -0,0 +1,431 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Authenticated encryption of cache values stored in an untrusted tier. +//! +//! This provides only the encryption *mechanism* — it carries no cryptographic +//! dependency of its own. [`AeadCipher`] is the pluggable contract: you supply the +//! actual cipher, backed by your approved cryptographic library, and register it with +//! [`encrypt_with`](crate::TransformBuilder::encrypt_with). [`EncryptedTier`] installs +//! that cipher at the storage boundary, where both the key and value are available, and +//! authenticates each value against its storage key. +//! +//! See the crate-level "Encryption Boundary" docs for a reference `AeadCipher` +//! implementation backed by `SymCrypt` (FIPS-certifiable AES-256-GCM). + +use std::borrow::Cow; + +use bytesbuf::BytesView; + +use crate::cache::CacheName; +use crate::telemetry::CacheTelemetry; +use crate::transform::DecodeOutcome; +use crate::{CacheEntry, CacheTier, Error, SizeError}; + +/// Returns a contiguous byte slice from a [`BytesView`]. Borrows for single-span +/// views (the common case) and gathers into a `Vec` only for multi-span views. +pub(crate) fn to_contiguous(view: &BytesView) -> Cow<'_, [u8]> { + let first = view.first_slice(); + if first.len() == view.len() { + Cow::Borrowed(first) + } else { + let mut buf = Vec::with_capacity(view.len()); + for (slice, _) in view.slices() { + buf.extend_from_slice(slice); + } + Cow::Owned(buf) + } +} + +/// Authenticated encryption with associated data (AEAD) for cache values. +/// +/// Implementations turn a value's plaintext bytes into stored bytes and back, +/// authenticating a caller-supplied *associated data* (AAD) value. `EncryptedTier` +/// passes the entry's storage key as AAD, so a value is cryptographically bound to +/// the key it was stored under. +/// +/// This trait supplies no cipher of its own: implement it with your organization's +/// approved cryptographic library and register it via +/// [`encrypt_with`](crate::TransformBuilder::encrypt_with). See the crate-level +/// "Encryption Boundary" docs for a reference `SymCrypt`-backed implementation. +/// +/// # Security contract +/// +/// Implementors **must** authenticate `aad`: [`decrypt`](Self::decrypt) must return +/// [`DecodeOutcome::SoftFailure`] when the `aad` does not match the value supplied to +/// [`encrypt`](Self::encrypt). 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 [`encrypt`](Self::encrypt), or a nonce-misuse-resistant scheme. +/// +/// [`decrypt`](Self::decrypt) distinguishes two failure modes: +/// - `Ok(DecodeOutcome::SoftFailure(_))` — the ciphertext is undecodable (corrupt, +/// truncated, tampered, wrong key, or AAD mismatch); the cache treats it as a miss. +/// - `Err(_)` — the operation could not be attempted (e.g. an unavailable backend); +/// the error propagates to the caller. +pub trait AeadCipher: Send + Sync { + /// Encrypts `plaintext`, authenticating `aad`, and returns the stored representation. + /// + /// # Errors + /// + /// Returns an error if encryption cannot be performed. + fn encrypt(&self, aad: &[u8], plaintext: &BytesView) -> Result; + + /// Decrypts `ciphertext`, verifying `aad`. + /// + /// # Errors + /// + /// Returns `Err` only if decryption could not be attempted. An authentication or + /// format failure is reported as `Ok(DecodeOutcome::SoftFailure(_))`. + fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error>; +} + +/// A cache tier that transparently encrypts values with an [`AeadCipher`]. +/// +/// It wraps an inner `CacheTier` (typically a remote tier +/// holding serialized bytes). On insert it encrypts the value, authenticating the +/// storage key as AAD; on get it decrypts, and an authentication failure — corrupt +/// bytes, a tampered entry, or a value relocated from a different key — surfaces as +/// a cache miss (`Ok(None)`) rather than an error. Each such failure emits a +/// `cache.decrypt_failed` telemetry event so that tampering with the backing store +/// is observable rather than silent. +pub(crate) struct EncryptedTier { + inner: S, + cipher: Box, + telemetry: CacheTelemetry, + name: CacheName, +} + +impl EncryptedTier { + pub(crate) fn new(inner: S, cipher: Box, telemetry: CacheTelemetry, name: CacheName) -> Self { + Self { + inner, + cipher, + telemetry, + name, + } + } +} + +impl std::fmt::Debug for EncryptedTier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EncryptedTier").field("inner", &self.inner).finish_non_exhaustive() + } +} + +impl CacheTier for EncryptedTier +where + S: CacheTier + Send + Sync, +{ + async fn get(&self, key: &BytesView) -> Result>, Error> { + let Some(entry) = self.inner.get(key).await? else { + return Ok(None); + }; + let ttl = entry.ttl(); + let cached_at = entry.cached_at(); + let value = entry.into_value(); + // The storage key is authenticated as AAD, so a value planted under the + // wrong key fails decryption and is treated as a miss. + let aad = to_contiguous(key); + match self.cipher.decrypt(aad.as_ref(), &value)? { + DecodeOutcome::Value(value) => { + let mut decrypted = CacheEntry::new(value); + if let Some(ttl) = ttl { + decrypted.set_ttl(ttl); + } + if let Some(cached_at) = cached_at { + decrypted.ensure_cached_at(cached_at); + } + Ok(Some(decrypted)) + } + DecodeOutcome::SoftFailure(_) => { + self.telemetry.record_decrypt_failure(self.name); + Ok(None) + } + } + } + + async fn insert(&self, key: BytesView, entry: CacheEntry) -> Result<(), Error> { + let aad = to_contiguous(&key); + let encrypted = entry.try_map_value(|value| self.cipher.encrypt(aad.as_ref(), &value))?; + self.inner.insert(key, encrypted).await + } + + async fn invalidate(&self, key: &BytesView) -> Result<(), Error> { + self.inner.invalidate(key).await + } + + async fn clear(&self) -> Result<(), Error> { + self.inner.clear().await + } + + async fn len(&self) -> Result { + self.inner.len().await + } +} + +#[cfg(test)] +mod tests { + use cachet_tier::MockCache; + + use super::*; + + fn view(data: &[u8]) -> BytesView { + BytesView::from(data.to_vec()) + } + + /// A crypto-free [`AeadCipher`] for exercising the tier mechanism. It "seals" + /// a value as `aad_len || aad || plaintext` and, on decrypt, treats an AAD + /// mismatch or malformed input as a soft failure — mirroring how a real AEAD + /// binds the value to its key without performing any real cryptography. + struct MockAeadCipher; + + impl AeadCipher for MockAeadCipher { + fn encrypt(&self, aad: &[u8], plaintext: &BytesView) -> Result { + let plaintext = plaintext.to_vec(); + let mut out = Vec::with_capacity(4 + aad.len() + plaintext.len()); + out.extend_from_slice(&(u32::try_from(aad.len()).expect("aad fits in u32")).to_le_bytes()); + out.extend_from_slice(aad); + out.extend_from_slice(&plaintext); + Ok(out.into()) + } + + fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error> { + let bytes = ciphertext.to_vec(); + let Some(len_bytes) = bytes.get(0..4) else { + return Ok(DecodeOutcome::SoftFailure("mock: missing length prefix")); + }; + let aad_len = u32::from_le_bytes(len_bytes.try_into().expect("4 bytes")) as usize; + let Some(stored_aad) = bytes.get(4..4 + aad_len) else { + return Ok(DecodeOutcome::SoftFailure("mock: truncated aad")); + }; + if stored_aad != aad { + return Ok(DecodeOutcome::SoftFailure("mock: aad mismatch")); + } + Ok(DecodeOutcome::Value(bytes[4 + aad_len..].to_vec().into())) + } + } + + /// A cipher whose operations always hard-error, for exercising error propagation. + struct FailingCipher; + + impl AeadCipher for FailingCipher { + fn encrypt(&self, _aad: &[u8], _plaintext: &BytesView) -> Result { + Err(Error::from_message("encrypt failed")) + } + + fn decrypt(&self, _aad: &[u8], _ciphertext: &BytesView) -> Result, Error> { + Err(Error::from_message("decrypt failed")) + } + } + + fn tier(inner: S) -> EncryptedTier { + EncryptedTier::new(inner, Box::new(MockAeadCipher), CacheTelemetry::new(), "encrypted-test") + } + + fn failing_tier(inner: S) -> EncryptedTier { + EncryptedTier::new(inner, Box::new(FailingCipher), CacheTelemetry::new(), "failing-test") + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn round_trips_through_inner() { + let inner = MockCache::::new(); + let tier = tier(inner.clone()); + + let key = view(b"user:1"); + tier.insert(key.clone(), CacheEntry::new(view(b"profile"))) + .await + .expect("insert should succeed"); + + // The inner tier stores the sealed representation, not the plaintext value. + let stored = inner.get(&key).await.expect("inner get ok").expect("entry present"); + assert_ne!(stored.value().to_vec(), b"profile", "inner tier must not hold plaintext"); + + let fetched = tier.get(&key).await.expect("get ok").expect("entry present"); + assert_eq!(fetched.value().to_vec(), b"profile", "decrypted value must match original"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn relocated_value_is_a_miss() { + let inner = MockCache::::new(); + let tier = tier(inner.clone()); + + let key_a = view(b"key-A"); + tier.insert(key_a.clone(), CacheEntry::new(view(b"value-A"))) + .await + .expect("insert should succeed"); + let blob_a = inner.get(&key_a).await.expect("inner get ok").expect("entry present").into_value(); + + // Attacker relocates A's sealed blob under key B in the untrusted inner tier. + let key_b = view(b"key-B"); + inner + .insert(key_b.clone(), CacheEntry::new(blob_a)) + .await + .expect("insert should succeed"); + + // Reading B must NOT yield A's value: AAD (key) mismatch => miss. + assert!( + tier.get(&key_b).await.expect("get ok").is_none(), + "relocated value must fail the AAD check and read as a miss" + ); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn get_miss_returns_none() { + let tier = tier(MockCache::::new()); + assert!( + tier.get(&view(b"absent")).await.expect("get ok").is_none(), + "empty inner tier must miss" + ); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn preserves_ttl_and_cached_at() { + use std::time::{Duration, SystemTime}; + + let tier = tier(MockCache::::new()); + let ttl = Duration::from_mins(5); + let cached_at = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000); + let key = view(b"k"); + + tier.insert(key.clone(), CacheEntry::expires_at(view(b"v"), ttl, cached_at)) + .await + .expect("insert should succeed"); + + let fetched = tier.get(&key).await.expect("get ok").expect("entry present"); + assert_eq!(fetched.value().to_vec(), b"v", "decrypted value must match"); + assert_eq!(fetched.ttl(), Some(ttl), "ttl must survive the round trip"); + assert_eq!(fetched.cached_at(), Some(cached_at), "cached_at must survive the round trip"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn invalidate_clear_len_delegate() { + let tier = tier(MockCache::::new()); + tier.insert(view(b"a"), CacheEntry::new(view(b"1"))) + .await + .expect("insert should succeed"); + tier.insert(view(b"b"), CacheEntry::new(view(b"2"))) + .await + .expect("insert should succeed"); + assert_eq!(tier.len().await.expect("len ok"), 2); + + tier.invalidate(&view(b"a")).await.expect("invalidate should succeed"); + assert_eq!(tier.len().await.expect("len ok"), 1); + + tier.clear().await.expect("clear should succeed"); + assert_eq!(tier.len().await.expect("len ok"), 0); + } + + #[test] + fn debug_omits_inner_secrets() { + let tier = tier(MockCache::::new()); + assert!(format!("{tier:?}").contains("EncryptedTier")); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn decrypt_failure_emits_telemetry() { + use testing_aids::LogCapture; + + let capture = LogCapture::new(); + let _guard = tracing::subscriber::set_default(capture.subscriber()); + + let inner = MockCache::::new(); + let tier = EncryptedTier::new( + inner.clone(), + Box::new(MockAeadCipher), + CacheTelemetry::with_logging(), + "encrypted-test", + ); + + // Plant a malformed blob that cannot be decoded. + inner + .insert(view(b"k"), CacheEntry::new(view(&[0u8; 2]))) + .await + .expect("insert should succeed"); + + assert!( + tier.get(&view(b"k")).await.expect("get ok").is_none(), + "undecodable value must read as a miss" + ); + capture.assert_contains(crate::telemetry::attributes::EVENT_DECRYPT_FAILED); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn encrypt_error_propagates_on_insert() { + let tier = failing_tier(MockCache::::new()); + assert!( + tier.insert(view(b"k"), CacheEntry::new(view(b"v"))).await.is_err(), + "a cipher encryption error must propagate from insert" + ); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn decrypt_error_propagates_on_get() { + let inner = MockCache::::new(); + inner + .insert(view(b"k"), CacheEntry::new(view(b"stored"))) + .await + .expect("insert should succeed"); + + let tier = failing_tier(inner); + assert!( + tier.get(&view(b"k")).await.is_err(), + "a cipher decryption hard error must propagate from get" + ); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn inner_tier_errors_propagate() { + use cachet_tier::CacheOp; + + let inner = MockCache::::new(); + let tier = tier(inner.clone()); + + inner.fail_when(|_op: &CacheOp| true); + + assert!(tier.get(&view(b"k")).await.is_err(), "inner get error must propagate"); + assert!( + tier.insert(view(b"k"), CacheEntry::new(view(b"v"))).await.is_err(), + "inner insert error must propagate" + ); + assert!(tier.invalidate(&view(b"k")).await.is_err(), "inner invalidate error must propagate"); + assert!(tier.clear().await.is_err(), "inner clear error must propagate"); + } + + #[test] + fn to_contiguous_gathers_every_span() { + // Single-span: returns the full contents (borrowed). + let single = view(b"contiguous"); + assert_eq!(to_contiguous(&single).as_ref(), b"contiguous"); + + // Multi-span: must concatenate ALL spans, not just the first one. + let mut multi = BytesView::from(b"first-".to_vec()); + multi.append(BytesView::from(b"second".to_vec())); + assert_ne!(multi.first_slice().len(), multi.len(), "fixture must be multi-span"); + assert_eq!( + to_contiguous(&multi).as_ref(), + b"first-second", + "to_contiguous must gather every span, not return only the first" + ); + } + + #[test] + fn mock_cipher_treats_truncated_aad_as_soft_failure() { + // A valid 4-byte length prefix declaring a 4-byte AAD, but with no bytes + // following it, must decode as a soft failure rather than panic. + let blob = 4u32.to_le_bytes().to_vec(); + let outcome = MockAeadCipher + .decrypt(b"aad", &BytesView::from(blob)) + .expect("malformed input is a soft failure, not a hard error"); + assert!(matches!(outcome, DecodeOutcome::SoftFailure(_))); + } +} diff --git a/crates/cachet/src/transform/mod.rs b/crates/cachet/src/transform/mod.rs index 16e292d5d..90ce25ecd 100644 --- a/crates/cachet/src/transform/mod.rs +++ b/crates/cachet/src/transform/mod.rs @@ -36,9 +36,15 @@ //! they can be used where a fallible closure is expected. mod codec; +#[cfg(feature = "encrypt")] +mod encrypt; #[cfg(test)] pub(crate) mod testing; mod tier; pub use codec::{Codec, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, infallible, infallible_owned}; +#[cfg(feature = "encrypt")] +pub use encrypt::AeadCipher; +#[cfg(feature = "encrypt")] +pub(crate) use encrypt::EncryptedTier; pub(crate) use tier::TransformAdapter; diff --git a/crates/cachet/tests/encrypt.rs b/crates/cachet/tests/encrypt.rs new file mode 100644 index 000000000..116d11935 --- /dev/null +++ b/crates/cachet/tests/encrypt.rs @@ -0,0 +1,304 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the value-encryption transform via `CacheBuilder`. +//! +//! These exercise the encryption *pipeline* (builder wiring, key-as-AAD binding, +//! relocation defense, fallback chaining) using a crypto-free mock [`AeadCipher`], +//! so they run under the base `encrypt` feature with no cryptographic dependency. + +#![cfg(all(feature = "encrypt", feature = "serialize", feature = "test-util"))] + +use std::sync::atomic::{AtomicU32, Ordering}; + +use bytesbuf::BytesView; +use cachet::{AeadCipher, Cache, CacheEntry, CacheOp, CacheTier, DecodeOutcome, Error, MockCache}; +use tick::Clock; + +const NONCE_SIZE: usize = 12; + +/// A crypto-free [`AeadCipher`] for exercising the pipeline. The stored form is +/// `nonce(12) || aad_len(4, LE) || aad || body`, where `body` is the plaintext +/// combined with a nonce-derived keystream (via XOR) so the stored bytes never +/// contain the plaintext verbatim. A monotonic counter stands in for a fresh nonce per encryption, and +/// `decrypt` authenticates the AAD by comparing it to the embedded copy — mirroring +/// the security contract without real crypto. +#[derive(Default)] +struct MockCipher { + counter: AtomicU32, +} + +impl MockCipher { + /// Derives a 12-byte nonce from the monotonic counter (counter in the first 4 + /// bytes, little-endian; remaining bytes are a fixed filler). + fn nonce_bytes(counter: u32) -> [u8; NONCE_SIZE] { + let mut nonce = [0xA5u8; NONCE_SIZE]; + nonce[..4].copy_from_slice(&counter.to_le_bytes()); + nonce + } + + /// Reversible keystream transform: `body[i] ^= 0x5A ^ nonce[i % 12]`. + fn xor_keystream(nonce: &[u8; NONCE_SIZE], body: &mut [u8]) { + for (i, byte) in body.iter_mut().enumerate() { + *byte ^= 0x5A ^ nonce[i % NONCE_SIZE]; + } + } +} + +impl AeadCipher for MockCipher { + fn encrypt(&self, aad: &[u8], plaintext: &BytesView) -> Result { + let nonce = Self::nonce_bytes(self.counter.fetch_add(1, Ordering::Relaxed)); + let mut out = Vec::with_capacity(NONCE_SIZE + 4 + aad.len() + plaintext.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&u32::try_from(aad.len()).expect("aad fits in u32").to_le_bytes()); + out.extend_from_slice(aad); + let body_start = out.len(); + for (slice, _) in plaintext.slices() { + out.extend_from_slice(slice); + } + Self::xor_keystream(&nonce, &mut out[body_start..]); + Ok(BytesView::from(out)) + } + + fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error> { + let bytes = ciphertext.to_vec(); + let Some(nonce) = bytes.get(..NONCE_SIZE) else { + return Ok(DecodeOutcome::SoftFailure("truncated")); + }; + let nonce: [u8; NONCE_SIZE] = nonce.try_into().expect("NONCE_SIZE bytes"); + let rest = &bytes[NONCE_SIZE..]; + let Some(len_bytes) = rest.get(..4) else { + return Ok(DecodeOutcome::SoftFailure("truncated")); + }; + let aad_len = u32::from_le_bytes(len_bytes.try_into().expect("4 bytes")) as usize; + let Some(stored_aad) = rest.get(4..4 + aad_len) else { + return Ok(DecodeOutcome::SoftFailure("truncated")); + }; + if stored_aad != aad { + return Ok(DecodeOutcome::SoftFailure("aad mismatch")); + } + let mut body = rest[4 + aad_len..].to_vec(); + Self::xor_keystream(&nonce, &mut body); + Ok(DecodeOutcome::Value(BytesView::from(body))) + } +} + +/// Returns the serialized (version byte + postcard) form of a value, matching +/// what the `serialize()` boundary produces before encryption. +fn serialized(value: &str) -> Vec { + let mut out = vec![1u8]; // FORMAT_VERSION + out.extend_from_slice(&postcard::to_allocvec(&value.to_string()).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() + .encrypt_with(MockCipher::default()) + .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() + .encrypt_with(MockCipher::default()) + .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() { + // `.encrypt_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() + .encrypt_with(MockCipher::default()) + .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() + .encrypt_with(MockCipher::default()) + .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".to_string()).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() + .encrypt_with(MockCipher::default()); + assert!(format!("{builder:?}").contains("EncryptedTransformBuilder")); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn encrypt_chained_post_transform_fallbacks() { + // Chain two post-transform fallback tiers after `.encrypt_with()`, exercising the + // second `.fallback()` that folds the existing post tier into a FallbackBuilder. + let l1 = MockCache::::new(); + let l2 = MockCache::::new(); + let l3 = MockCache::::new(); + let cache = Cache::builder::(Clock::new_frozen()) + .storage(l1.clone()) + .serialize() + .encrypt_with(MockCipher::default()) + .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) + .fallback(Cache::builder::(Clock::new_frozen()).storage(l3.clone())) + .build(); + + cache.insert("k".to_string(), "v".to_string()).await.expect("insert should succeed"); + + // Force a read past L1 so the encrypted post chain decrypts the value. + l1.invalidate(&"k".to_string()).await.expect("invalidate should succeed"); + let fetched = cache + .get(&"k".to_string()) + .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"); +}