From e2cfa1be213bac15b75ce0f7197c89f4cc1d1f89 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Thu, 9 Jul 2026 14:21:29 -0400 Subject: [PATCH 01/20] Add bytes encryption layer for cachet --- .spelling | 9 + Cargo.lock | 140 ++++++++- Cargo.toml | 2 + crates/cachet/Cargo.toml | 3 + crates/cachet/README.md | 30 +- crates/cachet/src/builder/encrypt.rs | 230 ++++++++++++++ crates/cachet/src/builder/mod.rs | 4 + crates/cachet/src/builder/transform.rs | 16 +- crates/cachet/src/lib.rs | 34 +++ crates/cachet/src/transform/encrypt.rs | 407 +++++++++++++++++++++++++ crates/cachet/src/transform/mod.rs | 4 + crates/cachet/tests/encrypt.rs | 181 +++++++++++ 12 files changed, 1050 insertions(+), 10 deletions(-) create mode 100644 crates/cachet/src/builder/encrypt.rs create mode 100644 crates/cachet/src/transform/encrypt.rs create mode 100644 crates/cachet/tests/encrypt.rs diff --git a/.spelling b/.spelling index 6c7170936..b0be2a2a8 100644 --- a/.spelling +++ b/.spelling @@ -11,6 +11,8 @@ 5xx = >= +AAD +AEAD ABA ABI ACLs @@ -250,6 +252,7 @@ chainable chrono chunked chunkless +ciphertext clippt clippy clonable @@ -273,6 +276,7 @@ covariant coverage.json crates.io crypto +cryptographically customizable cutover dSMS @@ -285,6 +289,8 @@ dec decrement decrementer decrementers +decrypt +decrypts deduplicating deduplication deps @@ -334,6 +340,7 @@ freelist freezable frontend fundle +GCM gRPC getter getters @@ -443,6 +450,8 @@ passthrough performant pessimizes pointee +plaintext +pluggable polyfill pre-approved pre-generate diff --git a/Cargo.lock b/Cargo.lock index 2b02ef815..41f3ea28b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -628,6 +663,7 @@ dependencies = [ name = "cachet" version = "0.8.0" dependencies = [ + "aes-gcm", "alloc_tracker", "anyspawn", "bytesbuf", @@ -638,6 +674,7 @@ dependencies = [ "dashmap", "dynosaur", "futures", + "getrandom 0.3.4", "layered", "ohno 0.3.8", "opentelemetry", @@ -722,7 +759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -787,6 +824,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -874,6 +921,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -967,6 +1023,25 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.23.0" @@ -1636,6 +1711,16 @@ dependencies = [ "windows-result", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1671,6 +1756,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.3" @@ -2099,6 +2194,15 @@ dependencies = [ "pastey", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.48.0" @@ -2611,6 +2715,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.81" @@ -2876,6 +2986,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -4154,6 +4276,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "typespec" version = "1.0.0" @@ -4236,6 +4364,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 4782905b5..f3ee519c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ tick = { path = "crates/tick", default-features = false, version = "0.4.0" } uniflight = { path = "crates/uniflight", default-features = false, version = "0.3.0" } # external dependencies +aes-gcm = { version = "0.10.3", default-features = false, features = ["aes", "alloc"] } ahash = { version = "0.8.4", default-features = false } alloc_tracker = { version = "0.6.0", default-features = false } allocator-api2 = { version = "0.4.0", default-features = false } @@ -122,6 +123,7 @@ futures = { version = "0.3.31", default-features = false } futures-channel = { version = "0.3.31", default-features = false } futures-core = { version = "0.3.31", default-features = false } futures-util = { version = "0.3.31", default-features = false } +getrandom = { version = "0.3.4", default-features = false, features = ["std"] } gungraun = { version = "0.19.2", default-features = false } hashbrown = { version = "0.17.0", default-features = false } http = { version = "1.4.1", default-features = false, features = ["std"] } diff --git a/crates/cachet/Cargo.toml b/crates/cachet/Cargo.toml index 4a8ffddbe..15d58558c 100644 --- a/crates/cachet/Cargo.toml +++ b/crates/cachet/Cargo.toml @@ -42,15 +42,18 @@ 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:aes-gcm", "dep:getrandom", "dep:bytesbuf"] telemetry = [] [dependencies] +aes-gcm = { workspace = true, optional = true } anyspawn = { workspace = true, features = ["tokio"] } bytesbuf = { workspace = true, optional = true } cachet_memory = { workspace = true, optional = true } cachet_service = { workspace = true, optional = true } cachet_tier = { workspace = true } futures = { workspace = true, features = ["async-await", "executor"] } +getrandom = { workspace = true, optional = true } layered = { workspace = true, optional = true } ohno = { workspace = true } parking_lot = { workspace = true } diff --git a/crates/cachet/README.md b/crates/cachet/README.md index 180cd2cda..2b766c5aa 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(&key)` on serialized builders for authenticated (AES-256-GCM) value encryption.| |`test-util`|❌|Enables `MockCache`, frozen-clock utilities, and other test helpers.| ## Examples @@ -226,6 +227,33 @@ let cache = Cache::builder::(clock) cache.insert("key".to_string(), "value".to_string()).await?; ``` +### Encryption Boundary + +With the `encrypt` feature, chain `.encrypt(&key)` after `.serialize()` to encrypt +values with AES-256-GCM before they reach the fallback tier. Each value is +encrypted with a fresh random nonce and cryptographically bound to its storage key; +keys are left serialized-but-unencrypted so they remain deterministic and can be +looked up. A stored value that fails to decrypt — corrupt, truncated, wrong key, or +relocated to a different key — is treated as a cache miss. + +```rust +use cachet::Cache; +use tick::Clock; + +let clock = Clock::new_tokio(); +let remote = Cache::builder::(clock.clone()).memory(); +let key = [0u8; 32]; // in production, load from a secret store + +let cache = Cache::builder::(clock) + .memory() + .serialize() + .encrypt(&key) + .fallback(remote) + .build(); + +cache.insert("key".to_string(), "value".to_string()).await?; +``` + ## Telemetry Cachet provides two complementary telemetry channels: @@ -280,7 +308,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]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbpeHGvMvQaAUbsC2g92vE_lobpJAjv-3H748bHP2lxkL3o0xhZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC44LjCCbWNhY2hldF9tZW1vcnllMC40LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA [__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 diff --git a/crates/cachet/src/builder/encrypt.rs b/crates/cachet/src/builder/encrypt.rs new file mode 100644 index 000000000..cf262bb92 --- /dev/null +++ b/crates/cachet/src/builder/encrypt.rs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Builder for applying an AES-256-GCM encryption boundary in the cache pipeline. +//! +//! `.encrypt(&key)` 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, Aes256GcmCipher, EncryptedTier, TransformAdapter}; +use crate::{Codec, Encoder}; + +/// The builder produced by [`TransformBuilder::encrypt`]. +/// +/// 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() on TransformBuilder ── + +impl TransformBuilder { + /// Encrypts values with AES-256-GCM before they reach the post-transform tier. + /// + /// Available only once values are [`BytesView`] (typically after + /// [`serialize`](crate::CacheBuilder::serialize)). Keys are left untouched — AES-GCM + /// output is non-deterministic, so an encrypted key could never be looked up — but + /// each value is cryptographically bound to its storage key, so a value cannot be + /// relocated to a different key in the backing store. + /// + /// # Examples + /// + /// ```no_run + /// use cachet::Cache; + /// use tick::Clock; + /// + /// let clock = Clock::new_tokio(); + /// let remote = Cache::builder::(clock.clone()).memory(); + /// let key = [0u8; 32]; + /// + /// let cache = Cache::builder::(clock) + /// .memory() + /// .serialize() + /// .encrypt(&key) + /// .fallback(remote) + /// .build(); + /// ``` + #[must_use] + pub fn encrypt(self, key: &[u8; 32]) -> EncryptedTransformBuilder { + self.encrypt_with(Aes256GcmCipher::new(key)) + } + + /// Encrypts values with the given authenticated cipher before they reach the + /// post-transform tier. Internal seam for [`encrypt`](Self::encrypt); the cipher + /// abstraction is not part of the public API yet. + #[must_use] + 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); + 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..b053fffc1 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(crate) pre: Pre, + pub(crate) post: Post, + pub(crate) key_encoder: Box>, + pub(crate) value_codec: Box>, + pub(crate) clock: Clock, + pub(crate) telemetry: CacheTelemetry, + pub(crate) stampede_protection: bool, + pub(crate) _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..78f8b1890 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(&key)` on serialized builders for authenticated (AES-256-GCM) value encryption. | //! | `test-util` | ❌ | Enables `MockCache`, frozen-clock utilities, and other test helpers. | //! //! # Examples @@ -223,6 +224,36 @@ //! # }; //! ``` //! +//! ## Encryption Boundary +//! +//! With the `encrypt` feature, chain `.encrypt(&key)` after `.serialize()` to encrypt +//! values with AES-256-GCM before they reach the fallback tier. Each value is +//! encrypted with a fresh random nonce and cryptographically bound to its storage key; +//! keys are left serialized-but-unencrypted so they remain deterministic and can be +//! looked up. A stored value that fails to decrypt — corrupt, truncated, wrong key, or +//! relocated to a different key — is treated as a cache miss. +//! +//! ```ignore +//! use cachet::Cache; +//! use tick::Clock; +//! # async { +//! +//! let clock = Clock::new_tokio(); +//! let remote = Cache::builder::(clock.clone()).memory(); +//! let key = [0u8; 32]; // in production, load from a secret store +//! +//! let cache = Cache::builder::(clock) +//! .memory() +//! .serialize() +//! .encrypt(&key) +//! .fallback(remote) +//! .build(); +//! +//! cache.insert("key".to_string(), "value".to_string()).await?; +//! # Ok::<(), cachet::Error>(()) +//! # }; +//! ``` +//! //! # Telemetry //! //! Cachet provides two complementary telemetry channels: @@ -284,6 +315,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)] diff --git a/crates/cachet/src/transform/encrypt.rs b/crates/cachet/src/transform/encrypt.rs new file mode 100644 index 000000000..ecd20c985 --- /dev/null +++ b/crates/cachet/src/transform/encrypt.rs @@ -0,0 +1,407 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Authenticated encryption of cache values stored in an untrusted tier. +//! +//! [`AeadCipher`] is the pluggable encryption contract; [`Aes256GcmCipher`] is +//! the built-in AES-256-GCM implementation. [`EncryptedTier`] installs a cipher +//! at the storage boundary, where both the key and value are available, and +//! authenticates each value against its storage key. + +use std::borrow::Cow; + +use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload}; +use aes_gcm::{Aes256Gcm, Nonce}; +use bytesbuf::BytesView; + +use crate::transform::DecodeOutcome; +use crate::{CacheEntry, CacheTier, Error, SizeError}; + +/// Length of the AES-GCM nonce, in bytes. Stored in front of every ciphertext. +const NONCE_SIZE: usize = 12; + +/// Length of the AES-GCM authentication tag, in bytes. Stored after the ciphertext. +const TAG_SIZE: usize = 16; + +/// Returns a contiguous byte slice from a [`BytesView`]. Borrows for single-span +/// views (the common case) and gathers into a `Vec` only for multi-span views. +fn to_contiguous(view: &BytesView) -> Cow<'_, [u8]> { + let first = view.first_slice(); + if first.len() == view.len() { + Cow::Borrowed(first) + } else { + let mut buf = Vec::with_capacity(view.len()); + for (slice, _) in view.slices() { + buf.extend_from_slice(slice); + } + Cow::Owned(buf) + } +} + +/// 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. +/// +/// # 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 are also responsible for nonce discipline — use a +/// fresh nonce per [`encrypt`](Self::encrypt), or a nonce-misuse-resistant scheme. +/// +/// `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(crate) 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>; +} + +/// An AES-256-GCM implementation of [`AeadCipher`]. +/// +/// Encryption writes a fresh random 12-byte nonce in front of the ciphertext +/// (`nonce || ciphertext || tag`) and authenticates the AAD supplied by +/// [`EncryptedTier`] (the storage key). Decryption failures — truncation, +/// corruption, tag mismatch, AAD mismatch, or the wrong key — are reported as +/// [`DecodeOutcome::SoftFailure`], so an undecodable entry is treated as a cache +/// miss rather than a hard error. +/// +/// Because each encryption uses a fresh random nonce, output is non-deterministic; +/// this cipher is applied to cache *values* only, never to keys. +#[derive(Clone)] +pub(crate) struct Aes256GcmCipher { + cipher: Aes256Gcm, +} + +impl Aes256GcmCipher { + /// Creates a new AES-256-GCM cipher from a 32-byte key. + #[must_use] + pub(crate) fn new(key: &[u8; 32]) -> Self { + Self { + cipher: Aes256Gcm::new(key.into()), + } + } +} + +impl std::fmt::Debug for Aes256GcmCipher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never render the key material. + f.debug_struct("Aes256GcmCipher").finish_non_exhaustive() + } +} + +impl AeadCipher for Aes256GcmCipher { + fn encrypt(&self, aad: &[u8], plaintext: &BytesView) -> Result { + let mut nonce_bytes = [0u8; NONCE_SIZE]; + getrandom::fill(&mut nonce_bytes).map_err(|e| Error::from_message(format!("failed to generate nonce: {e}")))?; + let nonce = Nonce::from_slice(&nonce_bytes); + + // Assemble `nonce || plaintext` in one buffer, encrypt the plaintext region + // in place, then append the detached tag. The plaintext spans are copied + // exactly once, straight into their final location. + // + // This copy cannot be eliminated: AES-GCM encrypts in place and so needs a + // single mutable, contiguous buffer, but `plaintext` is a shared, immutable + // `BytesView` (its memory may back other views) that can also be split across + // multiple spans. We therefore must gather it into our own writable buffer — + // which we do directly into `result` so it is the only copy. + let mut result = Vec::with_capacity(NONCE_SIZE + plaintext.len() + TAG_SIZE); + result.extend_from_slice(&nonce_bytes); + for (slice, _) in plaintext.slices() { + result.extend_from_slice(slice); + } + + let tag = self + .cipher + .encrypt_in_place_detached(nonce, aad, &mut result[NONCE_SIZE..]) + .map_err(|e| Error::from_message(format!("AES-GCM encryption failed: {e}")))?; + result.extend_from_slice(tag.as_slice()); + Ok(result.into()) + } + + fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error> { + // Borrow the ciphertext contiguously (zero-copy for the common single-span + // case; gather only when it is split across spans). The plaintext is then + // produced by the allocating `decrypt`, which is the single unavoidable copy: + // decryption must write plaintext into fresh memory since the input view is + // shared and immutable and cannot be decrypted in place. + let bytes = to_contiguous(ciphertext); + if bytes.len() < NONCE_SIZE { + return Ok(DecodeOutcome::SoftFailure("AES-GCM ciphertext too short: missing nonce")); + } + + let (nonce_bytes, body) = bytes.split_at(NONCE_SIZE); + let nonce = Nonce::from_slice(nonce_bytes); + + match self.cipher.decrypt(nonce, Payload { msg: body, aad }) { + Ok(plaintext) => Ok(DecodeOutcome::Value(plaintext.into())), + Err(_) => Ok(DecodeOutcome::SoftFailure("AES-GCM decryption failed")), + } + } +} + +/// 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. +pub(crate) struct EncryptedTier { + inner: S, + cipher: Box, +} + +impl EncryptedTier { + pub(crate) fn new(inner: S, cipher: Box) -> Self { + Self { inner, cipher } + } +} + +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. + match self.cipher.decrypt(&key.to_vec(), &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(_) => Ok(None), + } + } + + async fn insert(&self, key: BytesView, entry: CacheEntry) -> Result<(), Error> { + let aad = key.to_vec(); + let encrypted = entry.try_map_value(|value| self.cipher.encrypt(&aad, &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 super::*; + + const KEY: [u8; 32] = [42u8; 32]; + const AAD: &[u8] = b"cache-key"; + + fn view(data: &[u8]) -> BytesView { + BytesView::from(data.to_vec()) + } + + #[test] + fn encrypt_decrypt_round_trip() { + let cipher = Aes256GcmCipher::new(&KEY); + let plaintext = view(b"the quick brown fox"); + + let encrypted = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed"); + assert_ne!(encrypted.to_vec(), plaintext.to_vec(), "ciphertext must differ from plaintext"); + + match cipher.decrypt(AAD, &encrypted).expect("decrypt should not hard-error") { + DecodeOutcome::Value(v) => assert_eq!(v.to_vec(), plaintext.to_vec()), + DecodeOutcome::SoftFailure(reason) => panic!("expected a decoded value, got soft failure: {reason}"), + } + } + + #[test] + fn decrypt_with_wrong_aad_is_soft_failure() { + // The core relocation defense: a ciphertext authenticated under one key's + // AAD must not decrypt under a different key's AAD. + let cipher = Aes256GcmCipher::new(&KEY); + let encrypted = cipher.encrypt(b"key-A", &view(b"secret")).expect("encrypt should succeed"); + + let outcome = cipher.decrypt(b"key-B", &encrypted).expect("decrypt should not hard-error"); + assert!( + matches!(outcome, DecodeOutcome::SoftFailure(_)), + "AAD mismatch must be a soft failure" + ); + } + + #[test] + fn each_encrypt_uses_a_fresh_nonce() { + let cipher = Aes256GcmCipher::new(&KEY); + let plaintext = view(b"same input"); + + let a = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed").to_vec(); + let b = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed").to_vec(); + assert_ne!(a, b, "distinct nonces should yield distinct ciphertexts for identical input"); + } + + #[test] + fn decrypt_too_short_is_soft_failure() { + let cipher = Aes256GcmCipher::new(&KEY); + let outcome = cipher + .decrypt(AAD, &view(&[0u8; NONCE_SIZE - 1])) + .expect("decrypt should not hard-error"); + assert!( + matches!(outcome, DecodeOutcome::SoftFailure(_)), + "truncated input should be a soft failure" + ); + } + + #[test] + fn decrypt_tampered_ciphertext_is_soft_failure() { + let cipher = Aes256GcmCipher::new(&KEY); + let mut encrypted = cipher.encrypt(AAD, &view(b"secret")).expect("encrypt should succeed").to_vec(); + *encrypted.last_mut().expect("ciphertext is non-empty") ^= 0x01; + + let outcome = cipher + .decrypt(AAD, &BytesView::from(encrypted)) + .expect("decrypt should not hard-error"); + assert!( + matches!(outcome, DecodeOutcome::SoftFailure(_)), + "tampered ciphertext should be a soft failure" + ); + } + + #[test] + fn decrypt_with_wrong_key_is_soft_failure() { + let encrypted = Aes256GcmCipher::new(&KEY) + .encrypt(AAD, &view(b"secret")) + .expect("encrypt should succeed"); + let other = Aes256GcmCipher::new(&[7u8; 32]); + + let outcome = other.decrypt(AAD, &encrypted).expect("decrypt should not hard-error"); + assert!( + matches!(outcome, DecodeOutcome::SoftFailure(_)), + "wrong key should be a soft failure" + ); + } + + #[test] + fn round_trip_over_multi_span_view() { + let cipher = Aes256GcmCipher::new(&KEY); + let encrypted = cipher.encrypt(AAD, &view(b"multi span payload")).expect("encrypt should succeed"); + + // Split the ciphertext into two spans so decrypt must handle a multi-span view. + let bytes = encrypted.to_vec(); + let mid = bytes.len() / 2; + let mut multi = BytesView::from(bytes[..mid].to_vec()); + multi.append(BytesView::from(bytes[mid..].to_vec())); + assert_ne!(multi.first_slice().len(), multi.len(), "test fixture should be multi-span"); + + let outcome = cipher.decrypt(AAD, &multi).expect("decrypt should succeed"); + assert!(matches!(outcome, DecodeOutcome::Value(v) if v.to_vec() == b"multi span payload")); + } + + #[test] + fn round_trip_over_multi_span_plaintext() { + // Exercise the multi-span gather path in `encrypt`: the plaintext view is + // split across two spans, so `encrypt` must collect them into one buffer. + let cipher = Aes256GcmCipher::new(&KEY); + let mut plaintext = BytesView::from(b"multi span ".to_vec()); + plaintext.append(BytesView::from(b"plaintext value".to_vec())); + assert_ne!(plaintext.first_slice().len(), plaintext.len(), "test fixture should be multi-span"); + + let encrypted = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed"); + let outcome = cipher.decrypt(AAD, &encrypted).expect("decrypt should succeed"); + assert!(matches!(outcome, DecodeOutcome::Value(v) if v.to_vec() == b"multi span plaintext value")); + } + + #[test] + fn debug_does_not_leak_key() { + let rendered = format!("{:?}", Aes256GcmCipher::new(&KEY)); + assert!(rendered.contains("Aes256GcmCipher")); + assert!(!rendered.contains("42"), "Debug must not render key material"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn encrypted_tier_round_trips_through_inner() { + use cachet_tier::MockCache; + + let inner = MockCache::::new(); + let tier = EncryptedTier::new(inner.clone(), Box::new(Aes256GcmCipher::new(&KEY))); + + let key = view(b"user:1"); + tier.insert(key.clone(), CacheEntry::new(view(b"profile"))) + .await + .expect("insert should succeed"); + + // The inner tier stores ciphertext, 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 hold ciphertext"); + + 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 encrypted_tier_relocated_value_is_a_miss() { + use cachet_tier::MockCache; + + let inner = MockCache::::new(); + let tier = EncryptedTier::new(inner.clone(), Box::new(Aes256GcmCipher::new(&KEY))); + + // Legitimately store a value under key A. + 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 ciphertext 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 => decryption fails => miss. + let fetched = tier.get(&key_b).await.expect("get ok"); + assert!(fetched.is_none(), "relocated ciphertext must fail AAD check and read as a miss"); + } +} diff --git a/crates/cachet/src/transform/mod.rs b/crates/cachet/src/transform/mod.rs index 16e292d5d..6fbb857f8 100644 --- a/crates/cachet/src/transform/mod.rs +++ b/crates/cachet/src/transform/mod.rs @@ -36,9 +36,13 @@ //! 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(crate) use encrypt::{AeadCipher, Aes256GcmCipher, 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..717d267ba --- /dev/null +++ b/crates/cachet/tests/encrypt.rs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the AES-256-GCM encryption transform via `CacheBuilder`. + +#![cfg(all(feature = "encrypt", feature = "serialize", feature = "test-util"))] + +use bytesbuf::BytesView; +use cachet::{Cache, CacheEntry, CacheOp, CacheTier, MockCache}; +use tick::Clock; + +const KEY: [u8; 32] = [42u8; 32]; +const NONCE_SIZE: usize = 12; +const GCM_TAG_SIZE: usize = 16; + +/// 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(&KEY) + .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 carry the nonce + GCM tag overhead. + let plaintext = serialized(&value); + assert_ne!(stored_value.to_vec(), plaintext, "stored value must be ciphertext, not plaintext"); + assert_eq!( + stored_value.len(), + NONCE_SIZE + plaintext.len() + GCM_TAG_SIZE, + "ciphertext must be nonce + plaintext + GCM tag" + ); + + // 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(&KEY) + .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()` 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(&KEY) + .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(&KEY) + .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"); +} From cc909f4caec122f0866a2b5b5e1c9f0e6a569594 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Thu, 9 Jul 2026 14:56:41 -0400 Subject: [PATCH 02/20] add decrypt failed event and some more documentation --- crates/cachet/README.md | 9 +++- crates/cachet/src/builder/encrypt.rs | 15 +++++- crates/cachet/src/lib.rs | 7 ++- crates/cachet/src/telemetry/attributes.rs | 7 +++ crates/cachet/src/telemetry/cache.rs | 23 ++++++++ crates/cachet/src/transform/encrypt.rs | 66 ++++++++++++++++++++--- 6 files changed, 117 insertions(+), 10 deletions(-) diff --git a/crates/cachet/README.md b/crates/cachet/README.md index 2b766c5aa..e74118df4 100644 --- a/crates/cachet/README.md +++ b/crates/cachet/README.md @@ -234,7 +234,12 @@ values with AES-256-GCM before they reach the fallback tier. Each value is encrypted with a fresh random nonce and cryptographically bound to its storage key; keys are left serialized-but-unencrypted so they remain deterministic and can be looked up. A stored value that fails to decrypt — corrupt, truncated, wrong key, or -relocated to a different key — is treated as a cache miss. +relocated to a different key — is treated as a cache miss and emits a +`cache.decrypt_failed` telemetry event. + +Only values are encrypted: keys are stored in plaintext in the backing tier, so do +not place secrets or PII in cache keys. For extreme write volumes, rotate the key +periodically to stay well within the random-nonce birthday bound. ```rust use cachet::Cache; @@ -308,7 +313,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]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbpeHGvMvQaAUbsC2g92vE_lobpJAjv-3H748bHP2lxkL3o0xhZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC44LjCCbWNhY2hldF9tZW1vcnllMC40LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbyuiO0QVEuMQbJSVJ4gMa8ksbNYDmq69e5OMbGHKmAj90pudhZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC44LjCCbWNhY2hldF9tZW1vcnllMC40LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA [__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 diff --git a/crates/cachet/src/builder/encrypt.rs b/crates/cachet/src/builder/encrypt.rs index cf262bb92..84e4847b2 100644 --- a/crates/cachet/src/builder/encrypt.rs +++ b/crates/cachet/src/builder/encrypt.rs @@ -66,6 +66,14 @@ impl TransformBuilder { /// each value is cryptographically bound to its storage key, so a value cannot be /// relocated to a different key in the backing store. /// + /// # Security + /// + /// Only values are encrypted. Keys are serialized and stored **in plaintext** in + /// the backing tier (they must stay deterministic to remain usable as lookup keys), + /// so do not place secrets or PII in cache keys. A stored value that fails + /// authentication (corrupt, truncated, wrong key, tampered, or relocated) is treated + /// as a cache miss and emits a `cache.decrypt_failed` telemetry event. + /// /// # Examples /// /// ```no_run @@ -220,7 +228,12 @@ where // 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); + 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); diff --git a/crates/cachet/src/lib.rs b/crates/cachet/src/lib.rs index 78f8b1890..13d4fdf52 100644 --- a/crates/cachet/src/lib.rs +++ b/crates/cachet/src/lib.rs @@ -231,7 +231,12 @@ //! encrypted with a fresh random nonce and cryptographically bound to its storage key; //! keys are left serialized-but-unencrypted so they remain deterministic and can be //! looked up. A stored value that fails to decrypt — corrupt, truncated, wrong key, or -//! relocated to a different key — is treated as a cache miss. +//! relocated to a different key — is treated as a cache miss and emits a +//! `cache.decrypt_failed` telemetry event. +//! +//! Only values are encrypted: keys are stored in plaintext in the backing tier, so do +//! not place secrets or PII in cache keys. For extreme write volumes, rotate the key +//! periodically to stay well within the random-nonce birthday bound. //! //! ```ignore //! use cachet::Cache; 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..5cddf66f4 100644 --- a/crates/cachet/src/telemetry/cache.rs +++ b/crates/cachet/src/telemetry/cache.rs @@ -365,6 +365,29 @@ 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. + #[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, + false, + ); + } + 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 index ecd20c985..ed36819ae 100644 --- a/crates/cachet/src/transform/encrypt.rs +++ b/crates/cachet/src/transform/encrypt.rs @@ -14,6 +14,8 @@ use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload}; use aes_gcm::{Aes256Gcm, Nonce}; use bytesbuf::BytesView; +use crate::cache::CacheName; +use crate::telemetry::CacheTelemetry; use crate::transform::DecodeOutcome; use crate::{CacheEntry, CacheTier, Error, SizeError}; @@ -87,6 +89,13 @@ pub(crate) trait AeadCipher: Send + Sync { /// /// Because each encryption uses a fresh random nonce, output is non-deterministic; /// this cipher is applied to cache *values* only, never to keys. +/// +/// # Nonce reuse +/// +/// A 96-bit random nonce is safe for a very large volume of writes under one key, +/// but not unbounded: the reuse probability follows the birthday bound and becomes +/// non-negligible only after an extremely large number of writes under the same key. +/// For extreme write volumes, rotate the key periodically. #[derive(Clone)] pub(crate) struct Aes256GcmCipher { cipher: Aes256Gcm, @@ -165,15 +174,24 @@ impl AeadCipher for Aes256GcmCipher { /// 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. +/// 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) -> Self { - Self { inner, cipher } + pub(crate) fn new(inner: S, cipher: Box, telemetry: CacheTelemetry, name: CacheName) -> Self { + Self { + inner, + cipher, + telemetry, + name, + } } } @@ -207,7 +225,10 @@ where } Ok(Some(decrypted)) } - DecodeOutcome::SoftFailure(_) => Ok(None), + DecodeOutcome::SoftFailure(_) => { + self.telemetry.record_decrypt_failure(self.name); + Ok(None) + } } } @@ -241,6 +262,10 @@ mod tests { BytesView::from(data.to_vec()) } + fn test_tier(inner: S) -> EncryptedTier { + EncryptedTier::new(inner, Box::new(Aes256GcmCipher::new(&KEY)), CacheTelemetry::new(), "encrypted-test") + } + #[test] fn encrypt_decrypt_round_trip() { let cipher = Aes256GcmCipher::new(&KEY); @@ -363,7 +388,7 @@ mod tests { use cachet_tier::MockCache; let inner = MockCache::::new(); - let tier = EncryptedTier::new(inner.clone(), Box::new(Aes256GcmCipher::new(&KEY))); + let tier = test_tier(inner.clone()); let key = view(b"user:1"); tier.insert(key.clone(), CacheEntry::new(view(b"profile"))) @@ -384,7 +409,7 @@ mod tests { use cachet_tier::MockCache; let inner = MockCache::::new(); - let tier = EncryptedTier::new(inner.clone(), Box::new(Aes256GcmCipher::new(&KEY))); + let tier = test_tier(inner.clone()); // Legitimately store a value under key A. let key_a = view(b"key-A"); @@ -404,4 +429,33 @@ mod tests { let fetched = tier.get(&key_b).await.expect("get ok"); assert!(fetched.is_none(), "relocated ciphertext must fail AAD check and read as a miss"); } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn decrypt_failure_emits_telemetry() { + use cachet_tier::MockCache; + 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(Aes256GcmCipher::new(&KEY)), + CacheTelemetry::with_logging(), + "encrypted-test", + ); + + // Plant a garbage "ciphertext" that cannot authenticate. + let key = view(b"key"); + inner + .insert(key.clone(), CacheEntry::new(view(&[0u8; 64]))) + .await + .expect("insert should succeed"); + + let fetched = tier.get(&key).await.expect("get ok"); + assert!(fetched.is_none(), "undecryptable value must read as a miss"); + capture.assert_contains(crate::telemetry::attributes::EVENT_DECRYPT_FAILED); + } } From 3c91aa60e8c4c2fc39fcc7db9e6ccf536f6952ef Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Thu, 9 Jul 2026 15:01:10 -0400 Subject: [PATCH 03/20] Fix PR comments --- crates/cachet/src/builder/transform.rs | 16 ++++++++-------- crates/cachet/src/transform/encrypt.rs | 7 ++++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/cachet/src/builder/transform.rs b/crates/cachet/src/builder/transform.rs index b053fffc1..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 { - pub(crate) pre: Pre, - pub(crate) post: Post, - pub(crate) key_encoder: Box>, - pub(crate) value_codec: Box>, - pub(crate) clock: Clock, - pub(crate) telemetry: CacheTelemetry, - pub(crate) stampede_protection: bool, - pub(crate) _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/transform/encrypt.rs b/crates/cachet/src/transform/encrypt.rs index ed36819ae..528e33387 100644 --- a/crates/cachet/src/transform/encrypt.rs +++ b/crates/cachet/src/transform/encrypt.rs @@ -214,7 +214,8 @@ where 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. - match self.cipher.decrypt(&key.to_vec(), &value)? { + 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 { @@ -233,8 +234,8 @@ where } async fn insert(&self, key: BytesView, entry: CacheEntry) -> Result<(), Error> { - let aad = key.to_vec(); - let encrypted = entry.try_map_value(|value| self.cipher.encrypt(&aad, &value))?; + 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 } From 3b0e843d07a64be57a661f7aff1f116976c81918 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Tue, 14 Jul 2026 11:52:58 -0400 Subject: [PATCH 04/20] Make encryption a plugin. Add symcrypt option --- .spelling | 3 + Cargo.lock | 160 +------ Cargo.toml | 2 +- crates/cachet/Cargo.toml | 5 +- crates/cachet/README.md | 27 +- crates/cachet/src/builder/encrypt.rs | 76 ++- crates/cachet/src/lib.rs | 32 +- crates/cachet/src/transform/encrypt.rs | 444 ++++++++---------- crates/cachet/src/transform/mod.rs | 8 +- .../cachet/src/transform/symcrypt_cipher.rs | 209 +++++++++ crates/cachet/tests/encrypt.rs | 185 +++++++- 11 files changed, 707 insertions(+), 444 deletions(-) create mode 100644 crates/cachet/src/transform/symcrypt_cipher.rs diff --git a/.spelling b/.spelling index b0be2a2a8..8f5aad092 100644 --- a/.spelling +++ b/.spelling @@ -136,6 +136,7 @@ RAII RDME RMW RMWs +RNG RPC RSS Rc @@ -276,7 +277,9 @@ covariant coverage.json crates.io crypto +cryptographic cryptographically +undecryptable customizable cutover dSMS diff --git a/Cargo.lock b/Cargo.lock index 41f3ea28b..c8935f94c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,41 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "ahash" version = "0.8.12" @@ -663,7 +628,6 @@ dependencies = [ name = "cachet" version = "0.8.0" dependencies = [ - "aes-gcm", "alloc_tracker", "anyspawn", "bytesbuf", @@ -685,6 +649,7 @@ dependencies = [ "recoverable", "seatbelt", "serde", + "symcrypt", "testing_aids", "tick", "tokio", @@ -759,7 +724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures", "rand_core 0.10.1", ] @@ -824,16 +789,6 @@ dependencies = [ "half", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clap" version = "4.6.1" @@ -921,15 +876,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -1023,25 +969,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "darling" version = "0.23.0" @@ -1711,16 +1638,6 @@ dependencies = [ "windows-result", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1756,16 +1673,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "glob" version = "0.3.3" @@ -2194,15 +2101,6 @@ dependencies = [ "pastey", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "insta" version = "1.48.0" @@ -2715,12 +2613,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openssl" version = "0.10.81" @@ -2986,18 +2878,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.13.1" @@ -3738,6 +3618,26 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symcrypt" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45f6e93e13900bae533f1f347c82d2fcf9d7ad7fffd7e58bc9fbd1b68575ca1c" +dependencies = [ + "lazy_static", + "libc", + "symcrypt-sys", +] + +[[package]] +name = "symcrypt-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0878df9feb068709a68ccdb5cc3201649201e79e823566863f6ca4db4b9201aa" +dependencies = [ + "libc", +] + [[package]] name = "symlink" version = "0.1.0" @@ -4276,12 +4176,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "typespec" version = "1.0.0" @@ -4364,16 +4258,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index f3ee519c1..55c11972a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,6 @@ tick = { path = "crates/tick", default-features = false, version = "0.4.0" } uniflight = { path = "crates/uniflight", default-features = false, version = "0.3.0" } # external dependencies -aes-gcm = { version = "0.10.3", default-features = false, features = ["aes", "alloc"] } ahash = { version = "0.8.4", default-features = false } alloc_tracker = { version = "0.6.0", default-features = false } allocator-api2 = { version = "0.4.0", default-features = false } @@ -181,6 +180,7 @@ smallvec = { version = "1.15.1", default-features = false } smol = { version = "2.0.0", default-features = false } static_assertions = { version = "1.1.0", default-features = false } syn = { version = "2.0.111", default-features = false } +symcrypt = { version = "0.5.1", default-features = false } thiserror = { version = "2.0.17", default-features = false } time = { version = "0.3.47", default-features = false } tokio = { version = "1.48.0", default-features = false } diff --git a/crates/cachet/Cargo.toml b/crates/cachet/Cargo.toml index 15d58558c..da2d118a5 100644 --- a/crates/cachet/Cargo.toml +++ b/crates/cachet/Cargo.toml @@ -42,11 +42,11 @@ 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:aes-gcm", "dep:getrandom", "dep:bytesbuf"] +encrypt = ["dep:bytesbuf"] +symcrypt = ["encrypt", "dep:symcrypt", "dep:getrandom"] telemetry = [] [dependencies] -aes-gcm = { workspace = true, optional = true } anyspawn = { workspace = true, features = ["tokio"] } bytesbuf = { workspace = true, optional = true } cachet_memory = { workspace = true, optional = true } @@ -60,6 +60,7 @@ parking_lot = { workspace = true } pin-project-lite = { workspace = true } postcard = { workspace = true, optional = true } serde = { workspace = true, optional = true, features = ["derive"] } +symcrypt = { workspace = true, optional = true } tick = { workspace = true, features = [] } tracing = { workspace = true, optional = true } uniflight = { workspace = true } diff --git a/crates/cachet/README.md b/crates/cachet/README.md index e74118df4..fda5e64e3 100644 --- a/crates/cachet/README.md +++ b/crates/cachet/README.md @@ -165,7 +165,8 @@ 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(&key)` on serialized builders for authenticated (AES-256-GCM) value encryption.| +|`encrypt`|❌|Enables `.encrypt_with(cipher)` on serialized builders and the `AeadCipher` trait for authenticated value encryption with a caller-supplied cipher.| +|`symcrypt`|❌|Enables the built-in `Aes256GcmCipher` (SymCrypt-backed AES-256-GCM) and the `.encrypt(&key)` convenience method. Implies `encrypt`.| |`test-util`|❌|Enables `MockCache`, frozen-clock utilities, and other test helpers.| ## Examples @@ -229,12 +230,20 @@ cache.insert("key".to_string(), "value".to_string()).await?; ### Encryption Boundary -With the `encrypt` feature, chain `.encrypt(&key)` after `.serialize()` to encrypt -values with AES-256-GCM before they reach the fallback tier. Each value is -encrypted with a fresh random nonce and cryptographically bound to its storage key; -keys are left serialized-but-unencrypted so they remain deterministic and can be -looked up. A stored value that fails to decrypt — corrupt, truncated, wrong key, or -relocated to a different key — is treated as a cache miss and emits a +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 can 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. + +For convenience, the `symcrypt` feature provides a built-in `Aes256GcmCipher` +(SymCrypt-backed AES-256-GCM, FIPS-certifiable) and a `.encrypt(&key)` shortcut for +`.encrypt_with(Aes256GcmCipher::new(&key))`. Each value is encrypted with a fresh +random nonce; keys are left serialized-but-unencrypted so they remain deterministic +and can be looked up. A stored value that fails to decrypt — corrupt, truncated, +wrong key, or relocated to a different key — is treated as a cache miss and emits a `cache.decrypt_failed` telemetry event. Only values are encrypted: keys are stored in plaintext in the backing tier, so do @@ -252,7 +261,7 @@ let key = [0u8; 32]; // in production, load from a secret store let cache = Cache::builder::(clock) .memory() .serialize() - .encrypt(&key) + .encrypt(&key) // requires the `symcrypt` feature .fallback(remote) .build(); @@ -313,7 +322,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]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbyuiO0QVEuMQbJSVJ4gMa8ksbNYDmq69e5OMbGHKmAj90pudhZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC44LjCCbWNhY2hldF9tZW1vcnllMC40LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbYTLaPDmxhiUbCENNbnE-_ecbUgmxqbnD8oYbvcUbjsva5KFhZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC44LjCCbWNhY2hldF9tZW1vcnllMC40LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA [__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 diff --git a/crates/cachet/src/builder/encrypt.rs b/crates/cachet/src/builder/encrypt.rs index 84e4847b2..108d003c6 100644 --- a/crates/cachet/src/builder/encrypt.rs +++ b/crates/cachet/src/builder/encrypt.rs @@ -1,13 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Builder for applying an AES-256-GCM encryption boundary in the cache pipeline. +//! Builder for applying an authenticated-encryption boundary in the cache pipeline. //! -//! `.encrypt(&key)` becomes available once a [`TransformBuilder`] has reduced values -//! to [`BytesView`](bytesbuf::BytesView) (typically via +//! `.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. +//! internal `EncryptedTier` at build time. With the `symcrypt` feature, `.encrypt(&key)` +//! is available as a convenience over the built-in `SymCrypt` cipher. use std::fmt::Debug; use std::hash::Hash; @@ -22,10 +23,11 @@ use super::fallback::FallbackBuilder; use super::sealed::{CacheTierBuilder, Sealed}; use super::transform::TransformBuilder; use crate::telemetry::CacheTelemetry; -use crate::transform::{AeadCipher, Aes256GcmCipher, EncryptedTier, TransformAdapter}; +use crate::transform::{AeadCipher, EncryptedTier, TransformAdapter}; use crate::{Codec, Encoder}; -/// The builder produced by [`TransformBuilder::encrypt`]. +/// The builder produced by [`TransformBuilder::encrypt_with`] (and, with the +/// `symcrypt` feature, the `encrypt` convenience method). /// /// It mirrors [`TransformBuilder`] but fixes the storage types to /// [`BytesView`] and carries an authenticated cipher. At build time the @@ -55,16 +57,18 @@ impl Debug for EncryptedTransformBuilder TransformBuilder { - /// Encrypts values with AES-256-GCM before they reach the post-transform tier. + /// Encrypts values with the built-in `SymCrypt` AES-256-GCM cipher before they + /// reach the post-transform tier. /// - /// Available only once values are [`BytesView`] (typically after - /// [`serialize`](crate::CacheBuilder::serialize)). Keys are left untouched — AES-GCM - /// output is non-deterministic, so an encrypted key could never be looked up — but - /// each value is cryptographically bound to its storage key, so a value cannot be - /// relocated to a different key in the backing store. + /// A convenience over [`encrypt_with`](Self::encrypt_with) using + /// [`Aes256GcmCipher`](crate::Aes256GcmCipher); available with the `symcrypt` + /// feature. Keys are left untouched — AES-GCM output is non-deterministic, so an + /// encrypted key could never be looked up — but each value is cryptographically + /// bound to its storage key, so a value cannot be relocated to a different key in + /// the backing store. /// /// # Security /// @@ -91,16 +95,52 @@ impl TransformBuilder { /// .fallback(remote) /// .build(); /// ``` + #[cfg(feature = "symcrypt")] #[must_use] pub fn encrypt(self, key: &[u8; 32]) -> EncryptedTransformBuilder { - self.encrypt_with(Aes256GcmCipher::new(key)) + self.encrypt_with(crate::transform::Aes256GcmCipher::new(key)) } - /// Encrypts values with the given authenticated cipher before they reach the - /// post-transform tier. Internal seam for [`encrypt`](Self::encrypt); the cipher - /// abstraction is not part of the public API yet. + /// 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 + /// + /// ```no_run + /// 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] - fn encrypt_with(self, cipher: impl AeadCipher + 'static) -> EncryptedTransformBuilder { + pub fn encrypt_with(self, cipher: impl AeadCipher + 'static) -> EncryptedTransformBuilder { EncryptedTransformBuilder { pre: self.pre, post: self.post, diff --git a/crates/cachet/src/lib.rs b/crates/cachet/src/lib.rs index 13d4fdf52..bc9e239d2 100644 --- a/crates/cachet/src/lib.rs +++ b/crates/cachet/src/lib.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. #![cfg_attr(docsrs, feature(doc_cfg))] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] //! A composable, multi-tier caching library with stampede protection, background //! refresh, and structured telemetry. @@ -154,7 +155,8 @@ //! | `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(&key)` on serialized builders for authenticated (AES-256-GCM) value encryption. | +//! | `encrypt` | ❌ | Enables `.encrypt_with(cipher)` on serialized builders and the `AeadCipher` trait for authenticated value encryption with a caller-supplied cipher. | +//! | `symcrypt` | ❌ | Enables the built-in `Aes256GcmCipher` (SymCrypt-backed AES-256-GCM) and the `.encrypt(&key)` convenience method. Implies `encrypt`. | //! | `test-util` | ❌ | Enables `MockCache`, frozen-clock utilities, and other test helpers. | //! //! # Examples @@ -226,12 +228,20 @@ //! //! ## Encryption Boundary //! -//! With the `encrypt` feature, chain `.encrypt(&key)` after `.serialize()` to encrypt -//! values with AES-256-GCM before they reach the fallback tier. Each value is -//! encrypted with a fresh random nonce and cryptographically bound to its storage key; -//! keys are left serialized-but-unencrypted so they remain deterministic and can be -//! looked up. A stored value that fails to decrypt — corrupt, truncated, wrong key, or -//! relocated to a different key — is treated as a cache miss and emits a +//! 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 can 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. +//! +//! For convenience, the `symcrypt` feature provides a built-in `Aes256GcmCipher` +//! (SymCrypt-backed AES-256-GCM, FIPS-certifiable) and a `.encrypt(&key)` shortcut for +//! `.encrypt_with(Aes256GcmCipher::new(&key))`. Each value is encrypted with a fresh +//! random nonce; keys are left serialized-but-unencrypted so they remain deterministic +//! and can be looked up. A stored value that fails to decrypt — corrupt, truncated, +//! wrong key, or relocated to a different key — is treated as a cache miss and emits a //! `cache.decrypt_failed` telemetry event. //! //! Only values are encrypted: keys are stored in plaintext in the backing tier, so do @@ -250,7 +260,7 @@ //! let cache = Cache::builder::(clock) //! .memory() //! .serialize() -//! .encrypt(&key) +//! .encrypt(&key) // requires the `symcrypt` feature //! .fallback(remote) //! .build(); //! @@ -346,5 +356,11 @@ 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; +#[cfg(feature = "symcrypt")] +#[doc(inline)] +pub use transform::Aes256GcmCipher; #[doc(inline)] pub use transform::{Codec, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, infallible, infallible_owned}; diff --git a/crates/cachet/src/transform/encrypt.rs b/crates/cachet/src/transform/encrypt.rs index 528e33387..8ee04e110 100644 --- a/crates/cachet/src/transform/encrypt.rs +++ b/crates/cachet/src/transform/encrypt.rs @@ -3,15 +3,19 @@ //! Authenticated encryption of cache values stored in an untrusted tier. //! -//! [`AeadCipher`] is the pluggable encryption contract; [`Aes256GcmCipher`] is -//! the built-in AES-256-GCM implementation. [`EncryptedTier`] installs a cipher -//! at the storage boundary, where both the key and value are available, and -//! authenticates each value against its storage key. +//! The base `encrypt` feature 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. +//! +//! If you don't need to supply your own cipher, enable the optional `symcrypt` feature +//! to get a ready-made, FIPS-certifiable implementation (`Aes256GcmCipher`) plus the +//! `encrypt(&key)` convenience method. use std::borrow::Cow; -use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload}; -use aes_gcm::{Aes256Gcm, Nonce}; use bytesbuf::BytesView; use crate::cache::CacheName; @@ -19,15 +23,9 @@ use crate::telemetry::CacheTelemetry; use crate::transform::DecodeOutcome; use crate::{CacheEntry, CacheTier, Error, SizeError}; -/// Length of the AES-GCM nonce, in bytes. Stored in front of every ciphertext. -const NONCE_SIZE: usize = 12; - -/// Length of the AES-GCM authentication tag, in bytes. Stored after the ciphertext. -const TAG_SIZE: usize = 16; - /// Returns a contiguous byte slice from a [`BytesView`]. Borrows for single-span /// views (the common case) and gathers into a `Vec` only for multi-span views. -fn to_contiguous(view: &BytesView) -> Cow<'_, [u8]> { +pub(crate) fn to_contiguous(view: &BytesView) -> Cow<'_, [u8]> { let first = view.first_slice(); if first.len() == view.len() { Cow::Borrowed(first) @@ -43,25 +41,30 @@ fn to_contiguous(view: &BytesView) -> Cow<'_, [u8]> { /// 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. +/// 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. +/// +/// The base `encrypt` feature supplies no cipher of its own: implement this trait +/// with your organization's approved cryptographic library and register it via +/// [`encrypt_with`](crate::TransformBuilder::encrypt_with). Alternatively, enable the +/// `symcrypt` feature for the built-in `Aes256GcmCipher`. /// /// # 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 are also responsible for nonce discipline — use a -/// fresh nonce per [`encrypt`](Self::encrypt), or a nonce-misuse-resistant scheme. +/// 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` 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. +/// [`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(crate) trait AeadCipher: Send + Sync { +pub trait AeadCipher: Send + Sync { /// Encrypts `plaintext`, authenticating `aad`, and returns the stored representation. /// /// # Errors @@ -73,101 +76,11 @@ pub(crate) trait AeadCipher: Send + Sync { /// /// # Errors /// - /// Returns `Err` only if decryption could not be attempted. An authentication - /// or format failure is reported as `Ok(DecodeOutcome::SoftFailure(_))`. + /// 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>; } -/// An AES-256-GCM implementation of [`AeadCipher`]. -/// -/// Encryption writes a fresh random 12-byte nonce in front of the ciphertext -/// (`nonce || ciphertext || tag`) and authenticates the AAD supplied by -/// [`EncryptedTier`] (the storage key). Decryption failures — truncation, -/// corruption, tag mismatch, AAD mismatch, or the wrong key — are reported as -/// [`DecodeOutcome::SoftFailure`], so an undecodable entry is treated as a cache -/// miss rather than a hard error. -/// -/// Because each encryption uses a fresh random nonce, output is non-deterministic; -/// this cipher is applied to cache *values* only, never to keys. -/// -/// # Nonce reuse -/// -/// A 96-bit random nonce is safe for a very large volume of writes under one key, -/// but not unbounded: the reuse probability follows the birthday bound and becomes -/// non-negligible only after an extremely large number of writes under the same key. -/// For extreme write volumes, rotate the key periodically. -#[derive(Clone)] -pub(crate) struct Aes256GcmCipher { - cipher: Aes256Gcm, -} - -impl Aes256GcmCipher { - /// Creates a new AES-256-GCM cipher from a 32-byte key. - #[must_use] - pub(crate) fn new(key: &[u8; 32]) -> Self { - Self { - cipher: Aes256Gcm::new(key.into()), - } - } -} - -impl std::fmt::Debug for Aes256GcmCipher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Never render the key material. - f.debug_struct("Aes256GcmCipher").finish_non_exhaustive() - } -} - -impl AeadCipher for Aes256GcmCipher { - fn encrypt(&self, aad: &[u8], plaintext: &BytesView) -> Result { - let mut nonce_bytes = [0u8; NONCE_SIZE]; - getrandom::fill(&mut nonce_bytes).map_err(|e| Error::from_message(format!("failed to generate nonce: {e}")))?; - let nonce = Nonce::from_slice(&nonce_bytes); - - // Assemble `nonce || plaintext` in one buffer, encrypt the plaintext region - // in place, then append the detached tag. The plaintext spans are copied - // exactly once, straight into their final location. - // - // This copy cannot be eliminated: AES-GCM encrypts in place and so needs a - // single mutable, contiguous buffer, but `plaintext` is a shared, immutable - // `BytesView` (its memory may back other views) that can also be split across - // multiple spans. We therefore must gather it into our own writable buffer — - // which we do directly into `result` so it is the only copy. - let mut result = Vec::with_capacity(NONCE_SIZE + plaintext.len() + TAG_SIZE); - result.extend_from_slice(&nonce_bytes); - for (slice, _) in plaintext.slices() { - result.extend_from_slice(slice); - } - - let tag = self - .cipher - .encrypt_in_place_detached(nonce, aad, &mut result[NONCE_SIZE..]) - .map_err(|e| Error::from_message(format!("AES-GCM encryption failed: {e}")))?; - result.extend_from_slice(tag.as_slice()); - Ok(result.into()) - } - - fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error> { - // Borrow the ciphertext contiguously (zero-copy for the common single-span - // case; gather only when it is split across spans). The plaintext is then - // produced by the allocating `decrypt`, which is the single unavoidable copy: - // decryption must write plaintext into fresh memory since the input view is - // shared and immutable and cannot be decrypted in place. - let bytes = to_contiguous(ciphertext); - if bytes.len() < NONCE_SIZE { - return Ok(DecodeOutcome::SoftFailure("AES-GCM ciphertext too short: missing nonce")); - } - - let (nonce_bytes, body) = bytes.split_at(NONCE_SIZE); - let nonce = Nonce::from_slice(nonce_bytes); - - match self.cipher.decrypt(nonce, Payload { msg: body, aad }) { - Ok(plaintext) => Ok(DecodeOutcome::Value(plaintext.into())), - Err(_) => Ok(DecodeOutcome::SoftFailure("AES-GCM decryption failed")), - } - } -} - /// A cache tier that transparently encrypts values with an [`AeadCipher`]. /// /// It wraps an inner `CacheTier` (typically a remote tier @@ -254,151 +167,81 @@ where #[cfg(test)] mod tests { - use super::*; + use cachet_tier::MockCache; - const KEY: [u8; 32] = [42u8; 32]; - const AAD: &[u8] = b"cache-key"; + use super::*; fn view(data: &[u8]) -> BytesView { BytesView::from(data.to_vec()) } - fn test_tier(inner: S) -> EncryptedTier { - EncryptedTier::new(inner, Box::new(Aes256GcmCipher::new(&KEY)), CacheTelemetry::new(), "encrypted-test") - } - - #[test] - fn encrypt_decrypt_round_trip() { - let cipher = Aes256GcmCipher::new(&KEY); - let plaintext = view(b"the quick brown fox"); - - let encrypted = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed"); - assert_ne!(encrypted.to_vec(), plaintext.to_vec(), "ciphertext must differ from plaintext"); - - match cipher.decrypt(AAD, &encrypted).expect("decrypt should not hard-error") { - DecodeOutcome::Value(v) => assert_eq!(v.to_vec(), plaintext.to_vec()), - DecodeOutcome::SoftFailure(reason) => panic!("expected a decoded value, got soft failure: {reason}"), + /// 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()) } - } - - #[test] - fn decrypt_with_wrong_aad_is_soft_failure() { - // The core relocation defense: a ciphertext authenticated under one key's - // AAD must not decrypt under a different key's AAD. - let cipher = Aes256GcmCipher::new(&KEY); - let encrypted = cipher.encrypt(b"key-A", &view(b"secret")).expect("encrypt should succeed"); - - let outcome = cipher.decrypt(b"key-B", &encrypted).expect("decrypt should not hard-error"); - assert!( - matches!(outcome, DecodeOutcome::SoftFailure(_)), - "AAD mismatch must be a soft failure" - ); - } - - #[test] - fn each_encrypt_uses_a_fresh_nonce() { - let cipher = Aes256GcmCipher::new(&KEY); - let plaintext = view(b"same input"); - - let a = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed").to_vec(); - let b = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed").to_vec(); - assert_ne!(a, b, "distinct nonces should yield distinct ciphertexts for identical input"); - } - #[test] - fn decrypt_too_short_is_soft_failure() { - let cipher = Aes256GcmCipher::new(&KEY); - let outcome = cipher - .decrypt(AAD, &view(&[0u8; NONCE_SIZE - 1])) - .expect("decrypt should not hard-error"); - assert!( - matches!(outcome, DecodeOutcome::SoftFailure(_)), - "truncated input should be a soft failure" - ); + 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())) + } } - #[test] - fn decrypt_tampered_ciphertext_is_soft_failure() { - let cipher = Aes256GcmCipher::new(&KEY); - let mut encrypted = cipher.encrypt(AAD, &view(b"secret")).expect("encrypt should succeed").to_vec(); - *encrypted.last_mut().expect("ciphertext is non-empty") ^= 0x01; - - let outcome = cipher - .decrypt(AAD, &BytesView::from(encrypted)) - .expect("decrypt should not hard-error"); - assert!( - matches!(outcome, DecodeOutcome::SoftFailure(_)), - "tampered ciphertext should be a soft failure" - ); - } + /// A cipher whose operations always hard-error, for exercising error propagation. + struct FailingCipher; - #[test] - fn decrypt_with_wrong_key_is_soft_failure() { - let encrypted = Aes256GcmCipher::new(&KEY) - .encrypt(AAD, &view(b"secret")) - .expect("encrypt should succeed"); - let other = Aes256GcmCipher::new(&[7u8; 32]); + impl AeadCipher for FailingCipher { + fn encrypt(&self, _aad: &[u8], _plaintext: &BytesView) -> Result { + Err(Error::from_message("encrypt failed")) + } - let outcome = other.decrypt(AAD, &encrypted).expect("decrypt should not hard-error"); - assert!( - matches!(outcome, DecodeOutcome::SoftFailure(_)), - "wrong key should be a soft failure" - ); + fn decrypt(&self, _aad: &[u8], _ciphertext: &BytesView) -> Result, Error> { + Err(Error::from_message("decrypt failed")) + } } - #[test] - fn round_trip_over_multi_span_view() { - let cipher = Aes256GcmCipher::new(&KEY); - let encrypted = cipher.encrypt(AAD, &view(b"multi span payload")).expect("encrypt should succeed"); - - // Split the ciphertext into two spans so decrypt must handle a multi-span view. - let bytes = encrypted.to_vec(); - let mid = bytes.len() / 2; - let mut multi = BytesView::from(bytes[..mid].to_vec()); - multi.append(BytesView::from(bytes[mid..].to_vec())); - assert_ne!(multi.first_slice().len(), multi.len(), "test fixture should be multi-span"); - - let outcome = cipher.decrypt(AAD, &multi).expect("decrypt should succeed"); - assert!(matches!(outcome, DecodeOutcome::Value(v) if v.to_vec() == b"multi span payload")); + fn tier(inner: S) -> EncryptedTier { + EncryptedTier::new(inner, Box::new(MockAeadCipher), CacheTelemetry::new(), "encrypted-test") } - #[test] - fn round_trip_over_multi_span_plaintext() { - // Exercise the multi-span gather path in `encrypt`: the plaintext view is - // split across two spans, so `encrypt` must collect them into one buffer. - let cipher = Aes256GcmCipher::new(&KEY); - let mut plaintext = BytesView::from(b"multi span ".to_vec()); - plaintext.append(BytesView::from(b"plaintext value".to_vec())); - assert_ne!(plaintext.first_slice().len(), plaintext.len(), "test fixture should be multi-span"); - - let encrypted = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed"); - let outcome = cipher.decrypt(AAD, &encrypted).expect("decrypt should succeed"); - assert!(matches!(outcome, DecodeOutcome::Value(v) if v.to_vec() == b"multi span plaintext value")); - } - - #[test] - fn debug_does_not_leak_key() { - let rendered = format!("{:?}", Aes256GcmCipher::new(&KEY)); - assert!(rendered.contains("Aes256GcmCipher")); - assert!(!rendered.contains("42"), "Debug must not render key material"); + fn failing_tier(inner: S) -> EncryptedTier { + EncryptedTier::new(inner, Box::new(FailingCipher), CacheTelemetry::new(), "failing-test") } #[cfg_attr(miri, ignore)] #[tokio::test] - async fn encrypted_tier_round_trips_through_inner() { - use cachet_tier::MockCache; - + async fn round_trips_through_inner() { let inner = MockCache::::new(); - let tier = test_tier(inner.clone()); + 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 ciphertext, not the plaintext value. + // 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 hold ciphertext"); + 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"); @@ -406,35 +249,88 @@ mod tests { #[cfg_attr(miri, ignore)] #[tokio::test] - async fn encrypted_tier_relocated_value_is_a_miss() { - use cachet_tier::MockCache; - + async fn relocated_value_is_a_miss() { let inner = MockCache::::new(); - let tier = test_tier(inner.clone()); + let tier = tier(inner.clone()); - // Legitimately store a value under key A. 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 ciphertext blob under key B in the untrusted inner tier. + // 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 => decryption fails => miss. - let fetched = tier.get(&key_b).await.expect("get ok"); - assert!(fetched.is_none(), "relocated ciphertext must fail AAD check and read as a miss"); + // 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 cachet_tier::MockCache; use testing_aids::LogCapture; let capture = LogCapture::new(); @@ -443,20 +339,66 @@ mod tests { let inner = MockCache::::new(); let tier = EncryptedTier::new( inner.clone(), - Box::new(Aes256GcmCipher::new(&KEY)), + Box::new(MockAeadCipher), CacheTelemetry::with_logging(), "encrypted-test", ); - // Plant a garbage "ciphertext" that cannot authenticate. - let key = view(b"key"); + // Plant a malformed blob that cannot be decoded. inner - .insert(key.clone(), CacheEntry::new(view(&[0u8; 64]))) + .insert(view(b"k"), CacheEntry::new(view(&[0u8; 2]))) .await .expect("insert should succeed"); - let fetched = tier.get(&key).await.expect("get ok"); - assert!(fetched.is_none(), "undecryptable value must read as a miss"); + 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"); + } } diff --git a/crates/cachet/src/transform/mod.rs b/crates/cachet/src/transform/mod.rs index 6fbb857f8..9fbda40dc 100644 --- a/crates/cachet/src/transform/mod.rs +++ b/crates/cachet/src/transform/mod.rs @@ -38,11 +38,17 @@ mod codec; #[cfg(feature = "encrypt")] mod encrypt; +#[cfg(feature = "symcrypt")] +mod symcrypt_cipher; #[cfg(test)] pub(crate) mod testing; mod tier; pub use codec::{Codec, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, infallible, infallible_owned}; #[cfg(feature = "encrypt")] -pub(crate) use encrypt::{AeadCipher, Aes256GcmCipher, EncryptedTier}; +pub use encrypt::AeadCipher; +#[cfg(feature = "encrypt")] +pub(crate) use encrypt::EncryptedTier; +#[cfg(feature = "symcrypt")] +pub use symcrypt_cipher::Aes256GcmCipher; pub(crate) use tier::TransformAdapter; diff --git a/crates/cachet/src/transform/symcrypt_cipher.rs b/crates/cachet/src/transform/symcrypt_cipher.rs new file mode 100644 index 000000000..da4cf18de --- /dev/null +++ b/crates/cachet/src/transform/symcrypt_cipher.rs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! SymCrypt-backed AES-256-GCM implementation of [`AeadCipher`]. +//! +//! Available behind the `symcrypt` feature. Requires the `SymCrypt` library to be +//! present at build and run time (see the crate-level `symcrypt` feature docs). + +use bytesbuf::BytesView; +use symcrypt::cipher::BlockCipherType; +use symcrypt::gcm::GcmExpandedKey; + +use super::encrypt::{to_contiguous, AeadCipher}; +use super::DecodeOutcome; +use crate::Error; + +/// Length of the AES-GCM nonce, in bytes. Stored in front of the ciphertext. +const NONCE_SIZE: usize = 12; + +/// Length of the AES-GCM authentication tag, in bytes. Stored after the ciphertext. +const TAG_SIZE: usize = 16; + +/// An AES-256-GCM [`AeadCipher`] backed by `SymCrypt`. +/// +/// `encrypt` writes a fresh random 12-byte nonce in front of the ciphertext +/// (`nonce || ciphertext || tag`) and authenticates the AAD supplied by the +/// encrypted tier (the storage key). Decryption failures — truncation, corruption, +/// tag mismatch, AAD mismatch, or the wrong key — are reported as +/// [`DecodeOutcome::SoftFailure`], so an undecryptable entry is treated as a cache +/// miss rather than a hard error. +/// +/// Because each encryption uses a fresh random nonce, output is non-deterministic; +/// this cipher is applied to cache *values* only, never to keys. +/// +/// # Nonce reuse +/// +/// A 96-bit random nonce is safe for a very large volume of writes under one key, +/// but not unbounded: the reuse probability follows the birthday bound and becomes +/// non-negligible only after an extremely large number of writes under the same key. +/// For extreme write volumes, rotate the key periodically. +pub struct Aes256GcmCipher { + key: GcmExpandedKey, +} + +impl Aes256GcmCipher { + /// Creates a new `SymCrypt` AES-256-GCM cipher from a 32-byte key. + /// + /// # Panics + /// + /// Panics if `SymCrypt` key expansion fails, which cannot happen for a valid + /// 32-byte AES-256 key (the only failure mode is an unsupported key length). + #[must_use] + pub fn new(key: &[u8; 32]) -> Self { + let key = GcmExpandedKey::new(key, BlockCipherType::AesBlock) + .expect("SymCrypt AES-256-GCM key expansion cannot fail for a valid 32-byte key"); + Self { key } + } +} + +impl std::fmt::Debug for Aes256GcmCipher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never render the key material. + f.debug_struct("Aes256GcmCipher").finish_non_exhaustive() + } +} + +impl AeadCipher for Aes256GcmCipher { + fn encrypt(&self, aad: &[u8], plaintext: &BytesView) -> Result { + let nonce = generate_nonce()?; + + // Gather the plaintext into a single mutable buffer (the one unavoidable + // copy), encrypt it in place, then assemble `nonce || ciphertext || tag`. + let mut buffer = to_contiguous(plaintext).into_owned(); + let mut tag = [0u8; TAG_SIZE]; + self.key.encrypt_in_place(&nonce, aad, &mut buffer, &mut tag); + + let mut result = Vec::with_capacity(NONCE_SIZE + buffer.len() + TAG_SIZE); + result.extend_from_slice(&nonce); + result.extend_from_slice(&buffer); + result.extend_from_slice(&tag); + Ok(result.into()) + } + + fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error> { + let bytes = to_contiguous(ciphertext); + if bytes.len() < NONCE_SIZE + TAG_SIZE { + return Ok(DecodeOutcome::SoftFailure("SymCrypt AES-GCM 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("split_at(NONCE_SIZE) yields exactly 12 bytes"); + + let mut buffer = body.to_vec(); + match self.key.decrypt_in_place(nonce, aad, &mut buffer, tag) { + Ok(()) => Ok(DecodeOutcome::Value(buffer.into())), + Err(_) => Ok(DecodeOutcome::SoftFailure("SymCrypt AES-GCM decryption failed")), + } + } +} + +/// Generates a fresh random 12-byte nonce. +/// +/// Excluded from coverage: `getrandom::fill` only errors if the OS RNG is +/// unavailable, which cannot be exercised deterministically in a test. +#[cfg_attr(coverage_nightly, coverage(off))] +fn generate_nonce() -> Result<[u8; NONCE_SIZE], Error> { + let mut nonce = [0u8; NONCE_SIZE]; + getrandom::fill(&mut nonce).map_err(|e| Error::from_message(format!("failed to generate nonce: {e}")))?; + Ok(nonce) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; 32] = [42u8; 32]; + const AAD: &[u8] = b"cache-key"; + + fn view(data: &[u8]) -> BytesView { + BytesView::from(data.to_vec()) + } + + fn is_soft_failure(outcome: &Result, Error>) -> bool { + matches!(outcome, Ok(DecodeOutcome::SoftFailure(_))) + } + + #[test] + fn encrypt_decrypt_round_trip() { + let cipher = Aes256GcmCipher::new(&KEY); + let plaintext = view(b"the quick brown fox"); + + let encrypted = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed"); + assert_ne!(encrypted.to_vec(), plaintext.to_vec(), "ciphertext must differ from plaintext"); + + let outcome = cipher.decrypt(AAD, &encrypted).expect("decrypt should not hard-error"); + assert!(matches!(outcome, DecodeOutcome::Value(v) if v.to_vec() == plaintext.to_vec())); + } + + #[test] + fn decrypt_with_wrong_aad_is_soft_failure() { + let cipher = Aes256GcmCipher::new(&KEY); + let encrypted = cipher.encrypt(b"key-A", &view(b"secret")).expect("encrypt should succeed"); + assert!( + is_soft_failure(&cipher.decrypt(b"key-B", &encrypted)), + "AAD mismatch must be a soft failure" + ); + } + + #[test] + fn each_encrypt_uses_a_fresh_nonce() { + let cipher = Aes256GcmCipher::new(&KEY); + let plaintext = view(b"same input"); + let a = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed").to_vec(); + let b = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed").to_vec(); + assert_ne!(a, b, "distinct nonces should yield distinct ciphertexts for identical input"); + } + + #[test] + fn decrypt_too_short_is_soft_failure() { + let cipher = Aes256GcmCipher::new(&KEY); + assert!( + is_soft_failure(&cipher.decrypt(AAD, &view(&[0u8; NONCE_SIZE]))), + "truncated input should be a soft failure" + ); + } + + #[test] + fn decrypt_tampered_ciphertext_is_soft_failure() { + let cipher = Aes256GcmCipher::new(&KEY); + let mut encrypted = cipher.encrypt(AAD, &view(b"secret")).expect("encrypt should succeed").to_vec(); + *encrypted.last_mut().expect("ciphertext is non-empty") ^= 0x01; + assert!( + is_soft_failure(&cipher.decrypt(AAD, &BytesView::from(encrypted))), + "tampered ciphertext should be a soft failure" + ); + } + + #[test] + fn decrypt_with_wrong_key_is_soft_failure() { + let encrypted = Aes256GcmCipher::new(&KEY) + .encrypt(AAD, &view(b"secret")) + .expect("encrypt should succeed"); + let other = Aes256GcmCipher::new(&[7u8; 32]); + assert!( + is_soft_failure(&other.decrypt(AAD, &encrypted)), + "wrong key should be a soft failure" + ); + } + + #[test] + fn round_trip_over_multi_span_plaintext() { + let cipher = Aes256GcmCipher::new(&KEY); + let mut plaintext = BytesView::from(b"multi span ".to_vec()); + plaintext.append(BytesView::from(b"plaintext value".to_vec())); + assert_ne!(plaintext.first_slice().len(), plaintext.len(), "test fixture should be multi-span"); + + let encrypted = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed"); + let outcome = cipher.decrypt(AAD, &encrypted).expect("decrypt should succeed"); + assert!(matches!(outcome, DecodeOutcome::Value(v) if v.to_vec() == b"multi span plaintext value")); + } + + #[test] + fn debug_does_not_leak_key() { + let rendered = format!("{:?}", Aes256GcmCipher::new(&KEY)); + assert!(rendered.contains("Aes256GcmCipher")); + assert!(!rendered.contains("42"), "Debug must not render key material"); + } +} diff --git a/crates/cachet/tests/encrypt.rs b/crates/cachet/tests/encrypt.rs index 717d267ba..aa013b628 100644 --- a/crates/cachet/tests/encrypt.rs +++ b/crates/cachet/tests/encrypt.rs @@ -1,17 +1,66 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Integration tests for the AES-256-GCM encryption transform via `CacheBuilder`. +//! 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. +//! The SymCrypt-backed `.encrypt(&key)` convenience is exercised in a separate +//! module gated on the `symcrypt` feature. #![cfg(all(feature = "encrypt", feature = "serialize", feature = "test-util"))] +use std::sync::atomic::{AtomicU32, Ordering}; + use bytesbuf::BytesView; -use cachet::{Cache, CacheEntry, CacheOp, CacheTier, MockCache}; +use cachet::{AeadCipher, Cache, CacheEntry, CacheOp, CacheTier, DecodeOutcome, Error, MockCache}; use tick::Clock; -const KEY: [u8; 32] = [42u8; 32]; const NONCE_SIZE: usize = 12; -const GCM_TAG_SIZE: usize = 16; + +/// A crypto-free [`AeadCipher`] for exercising the pipeline. The stored form is +/// `nonce(12) || aad_len(4, LE) || aad || plaintext`. 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 AeadCipher for MockCipher { + fn encrypt(&self, aad: &[u8], plaintext: &BytesView) -> Result { + let nonce = self.counter.fetch_add(1, Ordering::Relaxed); + let mut out = Vec::with_capacity(NONCE_SIZE + 4 + aad.len() + plaintext.len()); + out.extend_from_slice(&[0u8; NONCE_SIZE - 4]); + out.extend_from_slice(&nonce.to_le_bytes()); + out.extend_from_slice(&u32::try_from(aad.len()).expect("aad fits in u32").to_le_bytes()); + out.extend_from_slice(aad); + for (slice, _) in plaintext.slices() { + out.extend_from_slice(slice); + } + Ok(BytesView::from(out)) + } + + fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error> { + let bytes = ciphertext.to_vec(); + let Some(rest) = bytes.get(NONCE_SIZE..) else { + return Ok(DecodeOutcome::SoftFailure("truncated")); + }; + 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 plaintext = &rest[4 + aad_len..]; + Ok(DecodeOutcome::Value(BytesView::from(plaintext.to_vec()))) + } +} /// Returns the serialized (version byte + postcard) form of a value, matching /// what the `serialize()` boundary produces before encryption. @@ -30,7 +79,7 @@ async fn encrypt_pipeline_stores_ciphertext_and_round_trips() { let cache = Cache::builder::(Clock::new_frozen()) .storage(l1.clone()) .serialize() - .encrypt(&KEY) + .encrypt_with(MockCipher::default()) .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) .build(); @@ -53,15 +102,9 @@ async fn encrypt_pipeline_stores_ciphertext_and_round_trips() { // 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 carry the nonce + GCM tag overhead. + // Values ARE encrypted: the stored bytes differ from the plaintext-serialized form. let plaintext = serialized(&value); assert_ne!(stored_value.to_vec(), plaintext, "stored value must be ciphertext, not plaintext"); - assert_eq!( - stored_value.len(), - NONCE_SIZE + plaintext.len() + GCM_TAG_SIZE, - "ciphertext must be nonce + plaintext + GCM tag" - ); // Force the read to fall back to the encrypted tier and decrypt. l1.invalidate(&key).await.expect("invalidate should succeed"); @@ -76,7 +119,7 @@ async fn encrypt_each_insert_uses_fresh_nonce() { let cache = Cache::builder::(Clock::new_frozen()) .storage(MockCache::::new()) .serialize() - .encrypt(&KEY) + .encrypt_with(MockCipher::default()) .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) .build(); @@ -105,13 +148,13 @@ async fn encrypt_each_insert_uses_fresh_nonce() { #[cfg_attr(miri, ignore)] #[tokio::test] async fn encrypt_on_fallback_builder() { - // `.encrypt()` must be reachable after `.serialize()` on a FallbackBuilder path. + // `.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(&KEY) + .encrypt_with(MockCipher::default()) .fallback(Cache::builder::(Clock::new_frozen()).storage(l3.clone())) .build(); @@ -146,7 +189,7 @@ async fn relocated_ciphertext_reads_as_a_miss() { let cache = Cache::builder::(Clock::new_frozen()) .storage(l1.clone()) .serialize() - .encrypt(&KEY) + .encrypt_with(MockCipher::default()) .fallback(Cache::builder::(Clock::new_frozen()).storage(remote.clone())) .build(); @@ -179,3 +222,113 @@ async fn relocated_ciphertext_reads_as_a_miss() { 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"); +} + +/// SymCrypt-backed `.encrypt(&key)` convenience, gated on the `symcrypt` feature. +#[cfg(feature = "symcrypt")] +mod symcrypt { + use super::{NONCE_SIZE, serialized}; + use bytesbuf::BytesView; + use cachet::{Cache, CacheOp, CacheTier, MockCache}; + use tick::Clock; + + const KEY: [u8; 32] = [42u8; 32]; + const GCM_TAG_SIZE: usize = 16; + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn symcrypt_encrypt_round_trips_with_gcm_overhead() { + let l1 = MockCache::::new(); + let l2 = MockCache::::new(); + + let cache = Cache::builder::(Clock::new_frozen()) + .storage(l1.clone()) + .serialize() + .encrypt(&KEY) + .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) + .build(); + + let value = "Hello, world!".to_string(); + cache + .insert("greeting".to_string(), value.clone()) + .await + .expect("insert should succeed"); + + let stored_value = l2 + .operations() + .iter() + .find_map(|op| match op { + CacheOp::Insert { entry, .. } => Some(entry.value().to_vec()), + _ => None, + }) + .expect("post-transform tier should have received an insert"); + + let plaintext = serialized(&value); + assert_ne!(stored_value, plaintext, "stored value must be ciphertext"); + assert_eq!( + stored_value.len(), + NONCE_SIZE + plaintext.len() + GCM_TAG_SIZE, + "ciphertext must be nonce + plaintext + GCM tag" + ); + + l1.invalidate(&"greeting".to_string()).await.expect("invalidate should succeed"); + let fetched = cache + .get(&"greeting".to_string()) + .await + .expect("get should succeed") + .expect("value should be present"); + assert_eq!(*fetched.value(), value, "decrypted value must match the original"); + } +} From 9fe3bb0e850e3c958e77468817b74b77721c897b Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Tue, 14 Jul 2026 12:02:08 -0400 Subject: [PATCH 05/20] Add symcrypt encryption example --- crates/cachet/Cargo.toml | 4 + crates/cachet/examples/encrypt_symcrypt.rs | 85 +++++++++++++++++++ .../cachet/src/transform/symcrypt_cipher.rs | 2 +- 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 crates/cachet/examples/encrypt_symcrypt.rs diff --git a/crates/cachet/Cargo.toml b/crates/cachet/Cargo.toml index da2d118a5..ed10107af 100644 --- a/crates/cachet/Cargo.toml +++ b/crates/cachet/Cargo.toml @@ -175,5 +175,9 @@ required-features = ["memory", "logs"] name = "serialization" required-features = ["memory", "serialize"] +[[example]] +name = "encrypt_symcrypt" +required-features = ["memory", "serialize", "symcrypt"] + [lints] workspace = true diff --git a/crates/cachet/examples/encrypt_symcrypt.rs b/crates/cachet/examples/encrypt_symcrypt.rs new file mode 100644 index 000000000..c503c4d2a --- /dev/null +++ b/crates/cachet/examples/encrypt_symcrypt.rs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! SymCrypt-backed value encryption example. +//! +//! Demonstrates the `symcrypt` feature's built-in `Aes256GcmCipher` via the +//! `.encrypt(&key)` convenience method. Values are encrypted with FIPS-certifiable +//! AES-256-GCM before they reach an untrusted fallback tier, while keys stay +//! plaintext so they remain usable for lookups. +//! +//! Run with: +//! +//! ```text +//! cargo run -p cachet --example encrypt_symcrypt --features "memory,serialize,symcrypt" +//! ``` +//! +//! Requires the `SymCrypt` library to be available at build and run time (see the +//! crate-level `symcrypt` feature docs). + +use bytesbuf::BytesView; +use cachet::{Cache, CacheEntry, CacheTier, InMemoryCache}; +use tick::Clock; + +#[tokio::main] +async fn main() { + let clock = Clock::new_tokio(); + + // In production, load the 32-byte key from a secret store — never hard-code it. + let key = [0x42u8; 32]; + + // L2: an untrusted byte-oriented tier (imagine a shared/remote store). We keep a + // direct handle so we can peek at exactly what gets persisted there. + let l2 = InMemoryCache::::new(); + + // L1 typed cache: serialize typed values to bytes, then encrypt them with the + // built-in SymCrypt AES-256-GCM cipher before they cross into L2. + let cache = Cache::builder::(clock.clone()) + .memory() + .serialize() + .encrypt(&key) + .fallback(Cache::builder::(clock).storage(l2.clone())) + .build(); + + let key_name = "session-token".to_string(); + let secret = "super-secret-value".to_string(); + + cache + .insert(key_name.clone(), CacheEntry::new(secret.clone())) + .await + .expect("insert failed"); + + // Peek into the untrusted L2 tier: the stored key is the plaintext (serialized) + // key, but the value is opaque ciphertext — a leak of L2 reveals nothing. + let stored_key = BytesView::from(serialized(&key_name)); + let stored = l2 + .get(&stored_key) + .await + .expect("l2 get failed") + .expect("value should be present in L2"); + let ciphertext = stored.value().to_vec(); + println!("stored in L2 ({} bytes): {ciphertext:02x?}", ciphertext.len()); + assert_ne!(ciphertext, serialized(&secret), "L2 must hold ciphertext, not plaintext"); + + // Reading back through the cache transparently decrypts and deserializes. + let value = cache.get(&key_name).await.expect("get failed").expect("entry not found"); + println!("get({key_name}): {:?}", value.value()); + assert_eq!(*value.value(), secret); + + // A value that fails authentication — here, a ciphertext relocated to a different + // key in the untrusted tier — is treated as a cache miss rather than leaking the + // original plaintext, because each value is cryptographically bound to its key. + let planted_key = BytesView::from(serialized("other-key")); + l2.insert(planted_key, stored).await.expect("plant failed"); + let miss = cache.get(&"other-key".to_string()).await.expect("get failed"); + println!("get(other-key) after relocating ciphertext: {miss:?} (miss — value is bound to its key)"); + assert!(miss.is_none()); +} + +/// Reproduces the serialized (version byte + postcard) form the `.serialize()` +/// boundary produces, purely so this example can locate the stored key in L2. +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 failed")); + out +} diff --git a/crates/cachet/src/transform/symcrypt_cipher.rs b/crates/cachet/src/transform/symcrypt_cipher.rs index da4cf18de..4fe2d7e52 100644 --- a/crates/cachet/src/transform/symcrypt_cipher.rs +++ b/crates/cachet/src/transform/symcrypt_cipher.rs @@ -10,8 +10,8 @@ use bytesbuf::BytesView; use symcrypt::cipher::BlockCipherType; use symcrypt::gcm::GcmExpandedKey; -use super::encrypt::{to_contiguous, AeadCipher}; use super::DecodeOutcome; +use super::encrypt::{AeadCipher, to_contiguous}; use crate::Error; /// Length of the AES-GCM nonce, in bytes. Stored in front of the ciphertext. From 89a1f10ab72df8f0578f499ec2616f6a42f22dba Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Wed, 15 Jul 2026 12:16:44 -0400 Subject: [PATCH 06/20] Remove symcrypt built-in. No opinion on encryption crate for now, only the mechanism --- .spelling | 1 + Cargo.lock | 22 -- Cargo.toml | 2 - crates/cachet/Cargo.toml | 7 - crates/cachet/README.md | 120 +++++++--- crates/cachet/examples/encrypt_symcrypt.rs | 85 ------- crates/cachet/src/builder/encrypt.rs | 59 +---- crates/cachet/src/lib.rs | 99 +++++++-- crates/cachet/src/transform/encrypt.rs | 25 +-- crates/cachet/src/transform/mod.rs | 4 - .../cachet/src/transform/symcrypt_cipher.rs | 209 ------------------ crates/cachet/tests/encrypt.rs | 109 ++++----- 12 files changed, 239 insertions(+), 503 deletions(-) delete mode 100644 crates/cachet/examples/encrypt_symcrypt.rs delete mode 100644 crates/cachet/src/transform/symcrypt_cipher.rs diff --git a/.spelling b/.spelling index 8f5aad092..19b7f64f2 100644 --- a/.spelling +++ b/.spelling @@ -279,6 +279,7 @@ crates.io crypto cryptographic cryptographically +SymCrypt undecryptable customizable cutover diff --git a/Cargo.lock b/Cargo.lock index c8935f94c..2b02ef815 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -638,7 +638,6 @@ dependencies = [ "dashmap", "dynosaur", "futures", - "getrandom 0.3.4", "layered", "ohno 0.3.8", "opentelemetry", @@ -649,7 +648,6 @@ dependencies = [ "recoverable", "seatbelt", "serde", - "symcrypt", "testing_aids", "tick", "tokio", @@ -3618,26 +3616,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "symcrypt" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45f6e93e13900bae533f1f347c82d2fcf9d7ad7fffd7e58bc9fbd1b68575ca1c" -dependencies = [ - "lazy_static", - "libc", - "symcrypt-sys", -] - -[[package]] -name = "symcrypt-sys" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0878df9feb068709a68ccdb5cc3201649201e79e823566863f6ca4db4b9201aa" -dependencies = [ - "libc", -] - [[package]] name = "symlink" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 55c11972a..4782905b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,7 +122,6 @@ futures = { version = "0.3.31", default-features = false } futures-channel = { version = "0.3.31", default-features = false } futures-core = { version = "0.3.31", default-features = false } futures-util = { version = "0.3.31", default-features = false } -getrandom = { version = "0.3.4", default-features = false, features = ["std"] } gungraun = { version = "0.19.2", default-features = false } hashbrown = { version = "0.17.0", default-features = false } http = { version = "1.4.1", default-features = false, features = ["std"] } @@ -180,7 +179,6 @@ smallvec = { version = "1.15.1", default-features = false } smol = { version = "2.0.0", default-features = false } static_assertions = { version = "1.1.0", default-features = false } syn = { version = "2.0.111", default-features = false } -symcrypt = { version = "0.5.1", default-features = false } thiserror = { version = "2.0.17", default-features = false } time = { version = "0.3.47", default-features = false } tokio = { version = "1.48.0", default-features = false } diff --git a/crates/cachet/Cargo.toml b/crates/cachet/Cargo.toml index ed10107af..02b3e6d09 100644 --- a/crates/cachet/Cargo.toml +++ b/crates/cachet/Cargo.toml @@ -43,7 +43,6 @@ memory = ["dep:cachet_memory"] service = ["dep:cachet_service", "dep:layered"] serialize = ["dep:serde", "dep:postcard", "dep:bytesbuf"] encrypt = ["dep:bytesbuf"] -symcrypt = ["encrypt", "dep:symcrypt", "dep:getrandom"] telemetry = [] [dependencies] @@ -53,14 +52,12 @@ cachet_memory = { workspace = true, optional = true } cachet_service = { workspace = true, optional = true } cachet_tier = { workspace = true } futures = { workspace = true, features = ["async-await", "executor"] } -getrandom = { workspace = true, optional = true } layered = { workspace = true, optional = true } ohno = { workspace = true } parking_lot = { workspace = true } pin-project-lite = { workspace = true } postcard = { workspace = true, optional = true } serde = { workspace = true, optional = true, features = ["derive"] } -symcrypt = { workspace = true, optional = true } tick = { workspace = true, features = [] } tracing = { workspace = true, optional = true } uniflight = { workspace = true } @@ -175,9 +172,5 @@ required-features = ["memory", "logs"] name = "serialization" required-features = ["memory", "serialize"] -[[example]] -name = "encrypt_symcrypt" -required-features = ["memory", "serialize", "symcrypt"] - [lints] workspace = true diff --git a/crates/cachet/README.md b/crates/cachet/README.md index fda5e64e3..5901cb198 100644 --- a/crates/cachet/README.md +++ b/crates/cachet/README.md @@ -166,7 +166,6 @@ most commonly used types from all of them. |`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.| -|`symcrypt`|❌|Enables the built-in `Aes256GcmCipher` (SymCrypt-backed AES-256-GCM) and the `.encrypt(&key)` convenience method. Implies `encrypt`.| |`test-util`|❌|Enables `MockCache`, frozen-clock utilities, and other test helpers.| ## Examples @@ -232,42 +231,109 @@ cache.insert("key".to_string(), "value".to_string()).await?; 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 can plug in a cipher backed by whichever +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. -For convenience, the `symcrypt` feature provides a built-in `Aes256GcmCipher` -(SymCrypt-backed AES-256-GCM, FIPS-certifiable) and a `.encrypt(&key)` shortcut for -`.encrypt_with(Aes256GcmCipher::new(&key))`. Each value is encrypted with a fresh -random nonce; keys are left serialized-but-unencrypted so they remain deterministic -and can be looked up. A stored value that fails to decrypt — corrupt, truncated, -wrong key, or relocated to a different key — is treated as a cache miss and emits a +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. -Only values are encrypted: keys are stored in plaintext in the backing tier, so do -not place secrets or PII in cache keys. For extreme write volumes, rotate the key -periodically to stay well within the random-nonce birthday bound. - ```rust use cachet::Cache; use tick::Clock; let clock = Clock::new_tokio(); let remote = Cache::builder::(clock.clone()).memory(); -let key = [0u8; 32]; // in production, load from a secret store let cache = Cache::builder::(clock) .memory() .serialize() - .encrypt(&key) // requires the `symcrypt` feature + .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: @@ -275,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 @@ -307,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. @@ -322,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]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbYTLaPDmxhiUbCENNbnE-_ecbUgmxqbnD8oYbvcUbjsva5KFhZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC44LjCCbWNhY2hldF9tZW1vcnllMC40LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA + [__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 @@ -336,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/examples/encrypt_symcrypt.rs b/crates/cachet/examples/encrypt_symcrypt.rs deleted file mode 100644 index c503c4d2a..000000000 --- a/crates/cachet/examples/encrypt_symcrypt.rs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! SymCrypt-backed value encryption example. -//! -//! Demonstrates the `symcrypt` feature's built-in `Aes256GcmCipher` via the -//! `.encrypt(&key)` convenience method. Values are encrypted with FIPS-certifiable -//! AES-256-GCM before they reach an untrusted fallback tier, while keys stay -//! plaintext so they remain usable for lookups. -//! -//! Run with: -//! -//! ```text -//! cargo run -p cachet --example encrypt_symcrypt --features "memory,serialize,symcrypt" -//! ``` -//! -//! Requires the `SymCrypt` library to be available at build and run time (see the -//! crate-level `symcrypt` feature docs). - -use bytesbuf::BytesView; -use cachet::{Cache, CacheEntry, CacheTier, InMemoryCache}; -use tick::Clock; - -#[tokio::main] -async fn main() { - let clock = Clock::new_tokio(); - - // In production, load the 32-byte key from a secret store — never hard-code it. - let key = [0x42u8; 32]; - - // L2: an untrusted byte-oriented tier (imagine a shared/remote store). We keep a - // direct handle so we can peek at exactly what gets persisted there. - let l2 = InMemoryCache::::new(); - - // L1 typed cache: serialize typed values to bytes, then encrypt them with the - // built-in SymCrypt AES-256-GCM cipher before they cross into L2. - let cache = Cache::builder::(clock.clone()) - .memory() - .serialize() - .encrypt(&key) - .fallback(Cache::builder::(clock).storage(l2.clone())) - .build(); - - let key_name = "session-token".to_string(); - let secret = "super-secret-value".to_string(); - - cache - .insert(key_name.clone(), CacheEntry::new(secret.clone())) - .await - .expect("insert failed"); - - // Peek into the untrusted L2 tier: the stored key is the plaintext (serialized) - // key, but the value is opaque ciphertext — a leak of L2 reveals nothing. - let stored_key = BytesView::from(serialized(&key_name)); - let stored = l2 - .get(&stored_key) - .await - .expect("l2 get failed") - .expect("value should be present in L2"); - let ciphertext = stored.value().to_vec(); - println!("stored in L2 ({} bytes): {ciphertext:02x?}", ciphertext.len()); - assert_ne!(ciphertext, serialized(&secret), "L2 must hold ciphertext, not plaintext"); - - // Reading back through the cache transparently decrypts and deserializes. - let value = cache.get(&key_name).await.expect("get failed").expect("entry not found"); - println!("get({key_name}): {:?}", value.value()); - assert_eq!(*value.value(), secret); - - // A value that fails authentication — here, a ciphertext relocated to a different - // key in the untrusted tier — is treated as a cache miss rather than leaking the - // original plaintext, because each value is cryptographically bound to its key. - let planted_key = BytesView::from(serialized("other-key")); - l2.insert(planted_key, stored).await.expect("plant failed"); - let miss = cache.get(&"other-key".to_string()).await.expect("get failed"); - println!("get(other-key) after relocating ciphertext: {miss:?} (miss — value is bound to its key)"); - assert!(miss.is_none()); -} - -/// Reproduces the serialized (version byte + postcard) form the `.serialize()` -/// boundary produces, purely so this example can locate the stored key in L2. -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 failed")); - out -} diff --git a/crates/cachet/src/builder/encrypt.rs b/crates/cachet/src/builder/encrypt.rs index 108d003c6..e1bea7462 100644 --- a/crates/cachet/src/builder/encrypt.rs +++ b/crates/cachet/src/builder/encrypt.rs @@ -7,8 +7,7 @@ //! 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. With the `symcrypt` feature, `.encrypt(&key)` -//! is available as a convenience over the built-in `SymCrypt` cipher. +//! internal `EncryptedTier` at build time. use std::fmt::Debug; use std::hash::Hash; @@ -26,8 +25,7 @@ use crate::telemetry::CacheTelemetry; use crate::transform::{AeadCipher, EncryptedTier, TransformAdapter}; use crate::{Codec, Encoder}; -/// The builder produced by [`TransformBuilder::encrypt_with`] (and, with the -/// `symcrypt` feature, the `encrypt` convenience method). +/// 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 @@ -60,47 +58,6 @@ impl Debug for EncryptedTransformBuilder TransformBuilder { - /// Encrypts values with the built-in `SymCrypt` AES-256-GCM cipher before they - /// reach the post-transform tier. - /// - /// A convenience over [`encrypt_with`](Self::encrypt_with) using - /// [`Aes256GcmCipher`](crate::Aes256GcmCipher); available with the `symcrypt` - /// feature. Keys are left untouched — AES-GCM output is non-deterministic, so an - /// encrypted key could never be looked up — but each value is cryptographically - /// bound to its storage key, so a value cannot be relocated to a different key in - /// the backing store. - /// - /// # Security - /// - /// Only values are encrypted. Keys are serialized and stored **in plaintext** in - /// the backing tier (they must stay deterministic to remain usable as lookup keys), - /// so do not place secrets or PII in cache keys. A stored value that fails - /// authentication (corrupt, truncated, wrong key, tampered, or relocated) is treated - /// as a cache miss and emits a `cache.decrypt_failed` telemetry event. - /// - /// # Examples - /// - /// ```no_run - /// use cachet::Cache; - /// use tick::Clock; - /// - /// let clock = Clock::new_tokio(); - /// let remote = Cache::builder::(clock.clone()).memory(); - /// let key = [0u8; 32]; - /// - /// let cache = Cache::builder::(clock) - /// .memory() - /// .serialize() - /// .encrypt(&key) - /// .fallback(remote) - /// .build(); - /// ``` - #[cfg(feature = "symcrypt")] - #[must_use] - pub fn encrypt(self, key: &[u8; 32]) -> EncryptedTransformBuilder { - self.encrypt_with(crate::transform::Aes256GcmCipher::new(key)) - } - /// Encrypts values with the given [`AeadCipher`](crate::AeadCipher) before they /// reach the post-transform tier. /// @@ -119,11 +76,19 @@ impl TransformBuilder { /// /// struct MyCipher; /// impl AeadCipher for MyCipher { - /// fn encrypt(&self, aad: &[u8], plaintext: &bytesbuf::BytesView) -> Result { + /// 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> { + /// fn decrypt( + /// &self, + /// aad: &[u8], + /// ciphertext: &bytesbuf::BytesView, + /// ) -> Result, Error> { /// # unimplemented!() /// // ... decrypt, returning SoftFailure on any authentication failure ... /// } diff --git a/crates/cachet/src/lib.rs b/crates/cachet/src/lib.rs index bc9e239d2..ef88f6892 100644 --- a/crates/cachet/src/lib.rs +++ b/crates/cachet/src/lib.rs @@ -156,7 +156,6 @@ //! | `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. | -//! | `symcrypt` | ❌ | Enables the built-in `Aes256GcmCipher` (SymCrypt-backed AES-256-GCM) and the `.encrypt(&key)` convenience method. Implies `encrypt`. | //! | `test-util` | ❌ | Enables `MockCache`, frozen-clock utilities, and other test helpers. | //! //! # Examples @@ -230,24 +229,18 @@ //! //! 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 can plug in a cipher backed by whichever +//! 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. //! -//! For convenience, the `symcrypt` feature provides a built-in `Aes256GcmCipher` -//! (SymCrypt-backed AES-256-GCM, FIPS-certifiable) and a `.encrypt(&key)` shortcut for -//! `.encrypt_with(Aes256GcmCipher::new(&key))`. Each value is encrypted with a fresh -//! random nonce; keys are left serialized-but-unencrypted so they remain deterministic -//! and can be looked up. A stored value that fails to decrypt — corrupt, truncated, -//! wrong key, or relocated to a different key — is treated as a cache miss and emits a +//! 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. //! -//! Only values are encrypted: keys are stored in plaintext in the backing tier, so do -//! not place secrets or PII in cache keys. For extreme write volumes, rotate the key -//! periodically to stay well within the random-nonce birthday bound. -//! //! ```ignore //! use cachet::Cache; //! use tick::Clock; @@ -255,12 +248,11 @@ //! //! let clock = Clock::new_tokio(); //! let remote = Cache::builder::(clock.clone()).memory(); -//! let key = [0u8; 32]; // in production, load from a secret store //! //! let cache = Cache::builder::(clock) //! .memory() //! .serialize() -//! .encrypt(&key) // requires the `symcrypt` feature +//! .encrypt_with(my_cipher) // any `AeadCipher` implementation //! .fallback(remote) //! .build(); //! @@ -269,6 +261,80 @@ //! # }; //! ``` //! +//! ### 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: @@ -359,8 +425,5 @@ pub use telemetry::handler::{CacheEventHandler, CacheOperationEvent, CacheTierEv #[cfg(feature = "encrypt")] #[doc(inline)] pub use transform::AeadCipher; -#[cfg(feature = "symcrypt")] -#[doc(inline)] -pub use transform::Aes256GcmCipher; #[doc(inline)] pub use transform::{Codec, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, infallible, infallible_owned}; diff --git a/crates/cachet/src/transform/encrypt.rs b/crates/cachet/src/transform/encrypt.rs index 8ee04e110..0a94a8ab2 100644 --- a/crates/cachet/src/transform/encrypt.rs +++ b/crates/cachet/src/transform/encrypt.rs @@ -3,16 +3,15 @@ //! Authenticated encryption of cache values stored in an untrusted tier. //! -//! The base `encrypt` feature 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. +//! 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. //! -//! If you don't need to supply your own cipher, enable the optional `symcrypt` feature -//! to get a ready-made, FIPS-certifiable implementation (`Aes256GcmCipher`) plus the -//! `encrypt(&key)` convenience method. +//! See the crate-level "Encryption Boundary" docs for a reference `AeadCipher` +//! implementation backed by `SymCrypt` (FIPS-certifiable AES-256-GCM). use std::borrow::Cow; @@ -45,10 +44,10 @@ pub(crate) fn to_contiguous(view: &BytesView) -> Cow<'_, [u8]> { /// passes the entry's storage key as AAD, so a value is cryptographically bound to /// the key it was stored under. /// -/// The base `encrypt` feature supplies no cipher of its own: implement this trait -/// with your organization's approved cryptographic library and register it via -/// [`encrypt_with`](crate::TransformBuilder::encrypt_with). Alternatively, enable the -/// `symcrypt` feature for the built-in `Aes256GcmCipher`. +/// 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 /// diff --git a/crates/cachet/src/transform/mod.rs b/crates/cachet/src/transform/mod.rs index 9fbda40dc..90ce25ecd 100644 --- a/crates/cachet/src/transform/mod.rs +++ b/crates/cachet/src/transform/mod.rs @@ -38,8 +38,6 @@ mod codec; #[cfg(feature = "encrypt")] mod encrypt; -#[cfg(feature = "symcrypt")] -mod symcrypt_cipher; #[cfg(test)] pub(crate) mod testing; mod tier; @@ -49,6 +47,4 @@ pub use codec::{Codec, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, pub use encrypt::AeadCipher; #[cfg(feature = "encrypt")] pub(crate) use encrypt::EncryptedTier; -#[cfg(feature = "symcrypt")] -pub use symcrypt_cipher::Aes256GcmCipher; pub(crate) use tier::TransformAdapter; diff --git a/crates/cachet/src/transform/symcrypt_cipher.rs b/crates/cachet/src/transform/symcrypt_cipher.rs deleted file mode 100644 index 4fe2d7e52..000000000 --- a/crates/cachet/src/transform/symcrypt_cipher.rs +++ /dev/null @@ -1,209 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! SymCrypt-backed AES-256-GCM implementation of [`AeadCipher`]. -//! -//! Available behind the `symcrypt` feature. Requires the `SymCrypt` library to be -//! present at build and run time (see the crate-level `symcrypt` feature docs). - -use bytesbuf::BytesView; -use symcrypt::cipher::BlockCipherType; -use symcrypt::gcm::GcmExpandedKey; - -use super::DecodeOutcome; -use super::encrypt::{AeadCipher, to_contiguous}; -use crate::Error; - -/// Length of the AES-GCM nonce, in bytes. Stored in front of the ciphertext. -const NONCE_SIZE: usize = 12; - -/// Length of the AES-GCM authentication tag, in bytes. Stored after the ciphertext. -const TAG_SIZE: usize = 16; - -/// An AES-256-GCM [`AeadCipher`] backed by `SymCrypt`. -/// -/// `encrypt` writes a fresh random 12-byte nonce in front of the ciphertext -/// (`nonce || ciphertext || tag`) and authenticates the AAD supplied by the -/// encrypted tier (the storage key). Decryption failures — truncation, corruption, -/// tag mismatch, AAD mismatch, or the wrong key — are reported as -/// [`DecodeOutcome::SoftFailure`], so an undecryptable entry is treated as a cache -/// miss rather than a hard error. -/// -/// Because each encryption uses a fresh random nonce, output is non-deterministic; -/// this cipher is applied to cache *values* only, never to keys. -/// -/// # Nonce reuse -/// -/// A 96-bit random nonce is safe for a very large volume of writes under one key, -/// but not unbounded: the reuse probability follows the birthday bound and becomes -/// non-negligible only after an extremely large number of writes under the same key. -/// For extreme write volumes, rotate the key periodically. -pub struct Aes256GcmCipher { - key: GcmExpandedKey, -} - -impl Aes256GcmCipher { - /// Creates a new `SymCrypt` AES-256-GCM cipher from a 32-byte key. - /// - /// # Panics - /// - /// Panics if `SymCrypt` key expansion fails, which cannot happen for a valid - /// 32-byte AES-256 key (the only failure mode is an unsupported key length). - #[must_use] - pub fn new(key: &[u8; 32]) -> Self { - let key = GcmExpandedKey::new(key, BlockCipherType::AesBlock) - .expect("SymCrypt AES-256-GCM key expansion cannot fail for a valid 32-byte key"); - Self { key } - } -} - -impl std::fmt::Debug for Aes256GcmCipher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Never render the key material. - f.debug_struct("Aes256GcmCipher").finish_non_exhaustive() - } -} - -impl AeadCipher for Aes256GcmCipher { - fn encrypt(&self, aad: &[u8], plaintext: &BytesView) -> Result { - let nonce = generate_nonce()?; - - // Gather the plaintext into a single mutable buffer (the one unavoidable - // copy), encrypt it in place, then assemble `nonce || ciphertext || tag`. - let mut buffer = to_contiguous(plaintext).into_owned(); - let mut tag = [0u8; TAG_SIZE]; - self.key.encrypt_in_place(&nonce, aad, &mut buffer, &mut tag); - - let mut result = Vec::with_capacity(NONCE_SIZE + buffer.len() + TAG_SIZE); - result.extend_from_slice(&nonce); - result.extend_from_slice(&buffer); - result.extend_from_slice(&tag); - Ok(result.into()) - } - - fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error> { - let bytes = to_contiguous(ciphertext); - if bytes.len() < NONCE_SIZE + TAG_SIZE { - return Ok(DecodeOutcome::SoftFailure("SymCrypt AES-GCM 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("split_at(NONCE_SIZE) yields exactly 12 bytes"); - - let mut buffer = body.to_vec(); - match self.key.decrypt_in_place(nonce, aad, &mut buffer, tag) { - Ok(()) => Ok(DecodeOutcome::Value(buffer.into())), - Err(_) => Ok(DecodeOutcome::SoftFailure("SymCrypt AES-GCM decryption failed")), - } - } -} - -/// Generates a fresh random 12-byte nonce. -/// -/// Excluded from coverage: `getrandom::fill` only errors if the OS RNG is -/// unavailable, which cannot be exercised deterministically in a test. -#[cfg_attr(coverage_nightly, coverage(off))] -fn generate_nonce() -> Result<[u8; NONCE_SIZE], Error> { - let mut nonce = [0u8; NONCE_SIZE]; - getrandom::fill(&mut nonce).map_err(|e| Error::from_message(format!("failed to generate nonce: {e}")))?; - Ok(nonce) -} - -#[cfg(test)] -mod tests { - use super::*; - - const KEY: [u8; 32] = [42u8; 32]; - const AAD: &[u8] = b"cache-key"; - - fn view(data: &[u8]) -> BytesView { - BytesView::from(data.to_vec()) - } - - fn is_soft_failure(outcome: &Result, Error>) -> bool { - matches!(outcome, Ok(DecodeOutcome::SoftFailure(_))) - } - - #[test] - fn encrypt_decrypt_round_trip() { - let cipher = Aes256GcmCipher::new(&KEY); - let plaintext = view(b"the quick brown fox"); - - let encrypted = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed"); - assert_ne!(encrypted.to_vec(), plaintext.to_vec(), "ciphertext must differ from plaintext"); - - let outcome = cipher.decrypt(AAD, &encrypted).expect("decrypt should not hard-error"); - assert!(matches!(outcome, DecodeOutcome::Value(v) if v.to_vec() == plaintext.to_vec())); - } - - #[test] - fn decrypt_with_wrong_aad_is_soft_failure() { - let cipher = Aes256GcmCipher::new(&KEY); - let encrypted = cipher.encrypt(b"key-A", &view(b"secret")).expect("encrypt should succeed"); - assert!( - is_soft_failure(&cipher.decrypt(b"key-B", &encrypted)), - "AAD mismatch must be a soft failure" - ); - } - - #[test] - fn each_encrypt_uses_a_fresh_nonce() { - let cipher = Aes256GcmCipher::new(&KEY); - let plaintext = view(b"same input"); - let a = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed").to_vec(); - let b = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed").to_vec(); - assert_ne!(a, b, "distinct nonces should yield distinct ciphertexts for identical input"); - } - - #[test] - fn decrypt_too_short_is_soft_failure() { - let cipher = Aes256GcmCipher::new(&KEY); - assert!( - is_soft_failure(&cipher.decrypt(AAD, &view(&[0u8; NONCE_SIZE]))), - "truncated input should be a soft failure" - ); - } - - #[test] - fn decrypt_tampered_ciphertext_is_soft_failure() { - let cipher = Aes256GcmCipher::new(&KEY); - let mut encrypted = cipher.encrypt(AAD, &view(b"secret")).expect("encrypt should succeed").to_vec(); - *encrypted.last_mut().expect("ciphertext is non-empty") ^= 0x01; - assert!( - is_soft_failure(&cipher.decrypt(AAD, &BytesView::from(encrypted))), - "tampered ciphertext should be a soft failure" - ); - } - - #[test] - fn decrypt_with_wrong_key_is_soft_failure() { - let encrypted = Aes256GcmCipher::new(&KEY) - .encrypt(AAD, &view(b"secret")) - .expect("encrypt should succeed"); - let other = Aes256GcmCipher::new(&[7u8; 32]); - assert!( - is_soft_failure(&other.decrypt(AAD, &encrypted)), - "wrong key should be a soft failure" - ); - } - - #[test] - fn round_trip_over_multi_span_plaintext() { - let cipher = Aes256GcmCipher::new(&KEY); - let mut plaintext = BytesView::from(b"multi span ".to_vec()); - plaintext.append(BytesView::from(b"plaintext value".to_vec())); - assert_ne!(plaintext.first_slice().len(), plaintext.len(), "test fixture should be multi-span"); - - let encrypted = cipher.encrypt(AAD, &plaintext).expect("encrypt should succeed"); - let outcome = cipher.decrypt(AAD, &encrypted).expect("decrypt should succeed"); - assert!(matches!(outcome, DecodeOutcome::Value(v) if v.to_vec() == b"multi span plaintext value")); - } - - #[test] - fn debug_does_not_leak_key() { - let rendered = format!("{:?}", Aes256GcmCipher::new(&KEY)); - assert!(rendered.contains("Aes256GcmCipher")); - assert!(!rendered.contains("42"), "Debug must not render key material"); - } -} diff --git a/crates/cachet/tests/encrypt.rs b/crates/cachet/tests/encrypt.rs index aa013b628..bf25e072b 100644 --- a/crates/cachet/tests/encrypt.rs +++ b/crates/cachet/tests/encrypt.rs @@ -6,8 +6,6 @@ //! 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. -//! The SymCrypt-backed `.encrypt(&key)` convenience is exercised in a separate -//! module gated on the `symcrypt` feature. #![cfg(all(feature = "encrypt", feature = "serialize", feature = "test-util"))] @@ -20,33 +18,54 @@ 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 || plaintext`. 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. +/// `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 (non-zero across bytes). + 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.counter.fetch_add(1, Ordering::Relaxed); + 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(&[0u8; NONCE_SIZE - 4]); - out.extend_from_slice(&nonce.to_le_bytes()); + 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(rest) = bytes.get(NONCE_SIZE..) else { + 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")); }; @@ -57,8 +76,9 @@ impl AeadCipher for MockCipher { if stored_aad != aad { return Ok(DecodeOutcome::SoftFailure("aad mismatch")); } - let plaintext = &rest[4 + aad_len..]; - Ok(DecodeOutcome::Value(BytesView::from(plaintext.to_vec()))) + let mut body = rest[4 + aad_len..].to_vec(); + Self::xor_keystream(&nonce, &mut body); + Ok(DecodeOutcome::Value(BytesView::from(body))) } } @@ -102,9 +122,15 @@ async fn encrypt_pipeline_stores_ciphertext_and_round_trips() { // 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. + // 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); - assert_ne!(stored_value.to_vec(), plaintext, "stored value must be ciphertext, not plaintext"); + 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"); @@ -275,60 +301,3 @@ async fn encrypt_chained_post_transform_fallbacks() { .expect("first post tier should have received an insert"); assert_ne!(stored, serialized("v"), "value must be encrypted in the chained fallback"); } - -/// SymCrypt-backed `.encrypt(&key)` convenience, gated on the `symcrypt` feature. -#[cfg(feature = "symcrypt")] -mod symcrypt { - use super::{NONCE_SIZE, serialized}; - use bytesbuf::BytesView; - use cachet::{Cache, CacheOp, CacheTier, MockCache}; - use tick::Clock; - - const KEY: [u8; 32] = [42u8; 32]; - const GCM_TAG_SIZE: usize = 16; - - #[cfg_attr(miri, ignore)] - #[tokio::test] - async fn symcrypt_encrypt_round_trips_with_gcm_overhead() { - let l1 = MockCache::::new(); - let l2 = MockCache::::new(); - - let cache = Cache::builder::(Clock::new_frozen()) - .storage(l1.clone()) - .serialize() - .encrypt(&KEY) - .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) - .build(); - - let value = "Hello, world!".to_string(); - cache - .insert("greeting".to_string(), value.clone()) - .await - .expect("insert should succeed"); - - let stored_value = l2 - .operations() - .iter() - .find_map(|op| match op { - CacheOp::Insert { entry, .. } => Some(entry.value().to_vec()), - _ => None, - }) - .expect("post-transform tier should have received an insert"); - - let plaintext = serialized(&value); - assert_ne!(stored_value, plaintext, "stored value must be ciphertext"); - assert_eq!( - stored_value.len(), - NONCE_SIZE + plaintext.len() + GCM_TAG_SIZE, - "ciphertext must be nonce + plaintext + GCM tag" - ); - - l1.invalidate(&"greeting".to_string()).await.expect("invalidate should succeed"); - let fetched = cache - .get(&"greeting".to_string()) - .await - .expect("get should succeed") - .expect("value should be present"); - assert_eq!(*fetched.value(), value, "decrypted value must match the original"); - } -} From 4baabe21971cb1832dc730320f5ca9fa072d8faf Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Wed, 15 Jul 2026 12:24:57 -0400 Subject: [PATCH 07/20] doc fix --- crates/cachet/tests/encrypt.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cachet/tests/encrypt.rs b/crates/cachet/tests/encrypt.rs index bf25e072b..116d11935 100644 --- a/crates/cachet/tests/encrypt.rs +++ b/crates/cachet/tests/encrypt.rs @@ -29,7 +29,8 @@ struct MockCipher { } impl MockCipher { - /// Derives a 12-byte nonce from the monotonic counter (non-zero across bytes). + /// 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()); From 0f1235e988da253243777150c3d70a23bc6a33b3 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Wed, 15 Jul 2026 13:57:49 -0400 Subject: [PATCH 08/20] Fix coverage job --- crates/cachet/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/cachet/src/lib.rs b/crates/cachet/src/lib.rs index ef88f6892..afabf05da 100644 --- a/crates/cachet/src/lib.rs +++ b/crates/cachet/src/lib.rs @@ -2,7 +2,6 @@ // Licensed under the MIT License. #![cfg_attr(docsrs, feature(doc_cfg))] -#![cfg_attr(coverage_nightly, feature(coverage_attribute))] //! A composable, multi-tier caching library with stampede protection, background //! refresh, and structured telemetry. From 1819df7b7ba847ae5830d91a5304f1c95a62aa6b Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Wed, 15 Jul 2026 14:37:33 -0400 Subject: [PATCH 09/20] Mutation test and test fixes --- crates/cachet/src/builder/encrypt.rs | 2 +- crates/cachet/src/telemetry/cache.rs | 6 ++++-- crates/cachet/src/transform/encrypt.rs | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/cachet/src/builder/encrypt.rs b/crates/cachet/src/builder/encrypt.rs index e1bea7462..ecd28bb00 100644 --- a/crates/cachet/src/builder/encrypt.rs +++ b/crates/cachet/src/builder/encrypt.rs @@ -70,7 +70,7 @@ impl TransformBuilder { /// /// # Examples /// - /// ```no_run + /// ```ignore /// use cachet::{AeadCipher, Cache, DecodeOutcome, Error}; /// use tick::Clock; /// diff --git a/crates/cachet/src/telemetry/cache.rs b/crates/cachet/src/telemetry/cache.rs index 5cddf66f4..f99e9e8b5 100644 --- a/crates/cachet/src/telemetry/cache.rs +++ b/crates/cachet/src/telemetry/cache.rs @@ -371,7 +371,9 @@ impl CacheTelemetry { /// 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. + /// 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))] @@ -384,7 +386,7 @@ impl CacheTelemetry { cache_name, attributes::EVENT_DECRYPT_FAILED, Duration::ZERO, - false, + true, ); } diff --git a/crates/cachet/src/transform/encrypt.rs b/crates/cachet/src/transform/encrypt.rs index 0a94a8ab2..343677798 100644 --- a/crates/cachet/src/transform/encrypt.rs +++ b/crates/cachet/src/transform/encrypt.rs @@ -400,4 +400,21 @@ mod tests { 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" + ); + } } From d3e4bc4556feede8a982baa2f1d65cdbc92dfdae Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Wed, 15 Jul 2026 14:50:47 -0400 Subject: [PATCH 10/20] Coverage --- crates/cachet/src/transform/encrypt.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/cachet/src/transform/encrypt.rs b/crates/cachet/src/transform/encrypt.rs index 343677798..ccd750e74 100644 --- a/crates/cachet/src/transform/encrypt.rs +++ b/crates/cachet/src/transform/encrypt.rs @@ -417,4 +417,15 @@ mod tests { "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(_))); + } } From 42c7fa9c8f7fd41f2ea529bb3a00de4b7d743348 Mon Sep 17 00:00:00 2001 From: schgoo <138131263+schgoo@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:11:03 -0400 Subject: [PATCH 11/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- crates/cachet/src/builder/encrypt.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cachet/src/builder/encrypt.rs b/crates/cachet/src/builder/encrypt.rs index ecd28bb00..b87a5c5df 100644 --- a/crates/cachet/src/builder/encrypt.rs +++ b/crates/cachet/src/builder/encrypt.rs @@ -55,7 +55,7 @@ impl Debug for EncryptedTransformBuilder TransformBuilder { /// Encrypts values with the given [`AeadCipher`](crate::AeadCipher) before they From 8525616424e229e9a1e84cf92fb6c311c6eead09 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Wed, 22 Jul 2026 11:17:52 -0400 Subject: [PATCH 12/20] Rename ciphers to protectors, following industry conventions. Add MockValueProtector under test-util. Make trait more generic - not aead-specific, but including additional context parameter that can be used that way. --- .spelling | 4 + crates/cachet/README.md | 65 ++--- crates/cachet/src/builder/encrypt.rs | 114 ++++---- crates/cachet/src/builder/mod.rs | 2 +- crates/cachet/src/lib.rs | 70 ++--- crates/cachet/src/telemetry/attributes.rs | 9 +- crates/cachet/src/telemetry/cache.rs | 16 +- crates/cachet/src/transform/encrypt.rs | 310 ++++++++++++++-------- crates/cachet/src/transform/mod.rs | 6 +- crates/cachet/tests/encrypt.rs | 100 ++----- 10 files changed, 362 insertions(+), 334 deletions(-) diff --git a/.spelling b/.spelling index 19b7f64f2..d147e66fb 100644 --- a/.spelling +++ b/.spelling @@ -279,8 +279,12 @@ crates.io crypto cryptographic cryptographically +CryptProtectData +DPAPI +keystream SymCrypt undecryptable +unprotect customizable cutover dSMS diff --git a/crates/cachet/README.md b/crates/cachet/README.md index 5901cb198..6bc6d1e22 100644 --- a/crates/cachet/README.md +++ b/crates/cachet/README.md @@ -165,7 +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.| +|`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 @@ -229,19 +229,20 @@ 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. +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 encrypted: keys are left serialized-but-unencrypted so they remain +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 decrypt (corrupt, truncated, wrong key, tampered, or +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.decrypt_failed` telemetry event. +`cache.unprotect_failed` telemetry event. ```rust use cachet::Cache; @@ -253,38 +254,38 @@ let remote = Cache::builder::(clock.cl let cache = Cache::builder::(clock) .memory() .serialize() - .encrypt_with(my_cipher) // any `AeadCipher` implementation + .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 cipher +#### Example: a `SymCrypt`-backed AES-256-GCM protector [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. +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::{AeadCipher, DecodeOutcome, Error}; +use cachet::{DecodeOutcome, Error, ValueProtector}; use symcrypt::cipher::BlockCipherType; use symcrypt::gcm::GcmExpandedKey; const NONCE_SIZE: usize = 12; const TAG_SIZE: usize = 16; -pub struct Aes256GcmCipher { +pub struct Aes256GcmProtector { key: GcmExpandedKey, } -impl Aes256GcmCipher { +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"); @@ -292,8 +293,8 @@ impl Aes256GcmCipher { } } -impl AeadCipher for Aes256GcmCipher { - fn encrypt(&self, aad: &[u8], plaintext: &BytesView) -> Result { +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}")))?; @@ -308,12 +309,12 @@ impl AeadCipher for Aes256GcmCipher { 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); + self.key.encrypt_in_place(&nonce, context, &mut head[NONCE_SIZE..], tag); Ok(result.into()) } - fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error> { - let bytes = ciphertext.to_vec(); + fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error> { + let bytes = protected.to_vec(); if bytes.len() < NONCE_SIZE + TAG_SIZE { return Ok(DecodeOutcome::SoftFailure("ciphertext too short")); } @@ -322,7 +323,7 @@ impl AeadCipher for Aes256GcmCipher { 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) { + match self.key.decrypt_in_place(nonce, context, &mut buffer, tag) { // Any authentication failure is a soft failure: the entry reads as a miss. Ok(()) => Ok(DecodeOutcome::Value(buffer.into())), Err(_) => Ok(DecodeOutcome::SoftFailure("AES-GCM decryption failed")), @@ -331,8 +332,8 @@ impl AeadCipher for Aes256GcmCipher { } ``` -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. +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 @@ -388,7 +389,7 @@ See the `telemetry_accumulator` example for a DashMap-based accumulation pattern This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbJYL19q8vzBMb_QI_68rGifAb8ItjnxSNMDYbUffk0aZ7s_1hZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC44LjCCbWNhY2hldF9tZW1vcnllMC40LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbuPGLEPMCj2MbgA5ER0E3BJMbfCIg-AE0UIgbL0L1ZOiGk45hZIiCaGJ5dGVzYnVmZTAuNi4wgmZjYWNoZXRlMC44LjCCbWNhY2hldF9tZW1vcnllMC40LjCCbmNhY2hldF9zZXJ2aWNlZTAuMi44gmtjYWNoZXRfdGllcmUwLjIuNoJkdGlja2UwLjQuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjMuMA [__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 diff --git a/crates/cachet/src/builder/encrypt.rs b/crates/cachet/src/builder/encrypt.rs index ecd28bb00..0db32484e 100644 --- a/crates/cachet/src/builder/encrypt.rs +++ b/crates/cachet/src/builder/encrypt.rs @@ -1,13 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Builder for applying an authenticated-encryption boundary in the cache pipeline. +//! Builder for applying an authenticated value-protection boundary in the cache +//! pipeline. //! -//! `.encrypt_with(cipher)` becomes available once a [`TransformBuilder`] has reduced +//! `.protect_with(protector)` 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. +//! [`ProtectedTransformBuilder`], whose post-transform tier chain is wrapped in an +//! internal `ProtectedTier` at build time. use std::fmt::Debug; use std::hash::Hash; @@ -22,31 +23,30 @@ 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::transform::{ProtectedTier, TransformAdapter, ValueProtector}; use crate::{Codec, Encoder}; -/// The builder produced by [`TransformBuilder::encrypt_with`]. +/// The builder produced by [`TransformBuilder::protect_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 { +/// [`BytesView`] and carries a value protector. At build time the post-transform tier +/// chain is wrapped in an internal `ProtectedTier`, which protects values and binds +/// each value to its storage key. Add post tiers with [`fallback`](Self::fallback) and +/// finish with [`build`](Self::build), exactly as with `TransformBuilder`. +pub struct ProtectedTransformBuilder { pre: Pre, post: Post, key_encoder: Box>, value_codec: Box>, - cipher: Box, + protector: Box, clock: Clock, telemetry: CacheTelemetry, stampede_protection: bool, } -impl Debug for EncryptedTransformBuilder { +impl Debug for ProtectedTransformBuilder { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EncryptedTransformBuilder") + f.debug_struct("ProtectedTransformBuilder") .field("pre", &self.pre) .field("post", &self.post) .field("K", &std::any::type_name::()) @@ -55,42 +55,42 @@ impl Debug for EncryptedTransformBuilder TransformBuilder { - /// Encrypts values with the given [`AeadCipher`](crate::AeadCipher) before they - /// reach the post-transform tier. + /// Protects values with the given [`ValueProtector`](crate::ValueProtector) before + /// they reach the post-transform tier. /// /// Available once values are [`BytesView`] (typically after - /// [`serialize`](crate::CacheBuilder::serialize)). Supply a 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. + /// [`serialize`](crate::CacheBuilder::serialize)). Supply a protector backed by + /// your approved cryptographic library; it receives the storage key as its context + /// and must bind it (see the [`ValueProtector`](crate::ValueProtector) contract). + /// Keys themselves are never protected, and a value that fails authentication is + /// treated as a cache miss. /// /// # Examples /// /// ```ignore - /// use cachet::{AeadCipher, Cache, DecodeOutcome, Error}; + /// use cachet::{Cache, DecodeOutcome, Error, ValueProtector}; /// use tick::Clock; /// - /// struct MyCipher; - /// impl AeadCipher for MyCipher { - /// fn encrypt( + /// struct MyProtector; + /// impl ValueProtector for MyProtector { + /// fn protect( /// &self, - /// aad: &[u8], + /// context: &[u8], /// plaintext: &bytesbuf::BytesView, /// ) -> Result { /// # unimplemented!() - /// // ... encrypt with your approved library, authenticating `aad` ... + /// // ... protect with your approved library, binding `context` ... /// } - /// fn decrypt( + /// fn unprotect( /// &self, - /// aad: &[u8], - /// ciphertext: &bytesbuf::BytesView, + /// context: &[u8], + /// protected: &bytesbuf::BytesView, /// ) -> Result, Error> { /// # unimplemented!() - /// // ... decrypt, returning SoftFailure on any authentication failure ... + /// // ... recover, returning SoftFailure on any authentication failure ... /// } /// } /// @@ -100,18 +100,18 @@ impl TransformBuilder { /// let cache = Cache::builder::(clock) /// .memory() /// .serialize() - /// .encrypt_with(MyCipher) + /// .protect_with(MyProtector) /// .fallback(remote) /// .build(); /// ``` #[must_use] - pub fn encrypt_with(self, cipher: impl AeadCipher + 'static) -> EncryptedTransformBuilder { - EncryptedTransformBuilder { + pub fn protect_with(self, protector: impl ValueProtector + 'static) -> ProtectedTransformBuilder { + ProtectedTransformBuilder { pre: self.pre, post: self.post, key_encoder: self.key_encoder, value_codec: self.value_codec, - cipher: Box::new(cipher), + protector: Box::new(protector), clock: self.clock, telemetry: self.telemetry, stampede_protection: self.stampede_protection, @@ -119,20 +119,20 @@ impl TransformBuilder { } } -// ── .fallback() on EncryptedTransformBuilder ── +// ── .fallback() on ProtectedTransformBuilder ── -impl EncryptedTransformBuilder { +impl ProtectedTransformBuilder { /// Sets the first post-transform storage tier (speaks encrypted `BytesView`). - pub fn fallback(self, fallback: FB) -> EncryptedTransformBuilder + pub fn fallback(self, fallback: FB) -> ProtectedTransformBuilder where FB: CacheTierBuilder, { - EncryptedTransformBuilder { + ProtectedTransformBuilder { pre: self.pre, post: fallback, key_encoder: self.key_encoder, value_codec: self.value_codec, - cipher: self.cipher, + protector: self.protector, clock: self.clock, telemetry: self.telemetry, stampede_protection: self.stampede_protection, @@ -140,12 +140,12 @@ impl EncryptedTransformBuilder { } } -impl EncryptedTransformBuilder +impl ProtectedTransformBuilder where Post: CacheTierBuilder, { /// Adds another post-transform fallback tier (speaks encrypted `BytesView`). - pub fn fallback(self, fallback: FB) -> EncryptedTransformBuilder> + pub fn fallback(self, fallback: FB) -> ProtectedTransformBuilder> where FB: CacheTierBuilder, { @@ -164,12 +164,12 @@ where _phantom: PhantomData, }; - EncryptedTransformBuilder { + ProtectedTransformBuilder { pre: self.pre, post: post_chain, key_encoder: self.key_encoder, value_codec: self.value_codec, - cipher: self.cipher, + protector: self.protector, clock, telemetry, stampede_protection, @@ -179,24 +179,24 @@ where // ── Sealed + CacheTierBuilder (allow nesting an encrypted transform) ── -impl Sealed for EncryptedTransformBuilder +impl Sealed for ProtectedTransformBuilder where K: Clone + Hash + Eq + Send + Sync + 'static, V: Clone + Send + Sync + 'static, { } -impl CacheTierBuilder for EncryptedTransformBuilder +impl CacheTierBuilder for ProtectedTransformBuilder where K: Clone + Hash + Eq + Send + Sync + 'static, V: Clone + Send + Sync + 'static, { } -// ── .build() on EncryptedTransformBuilder ── +// ── .build() on ProtectedTransformBuilder ── #[expect(private_bounds, reason = "Buildable is an internal trait")] -impl EncryptedTransformBuilder +impl ProtectedTransformBuilder where K: Clone + Hash + Eq + Send + Sync + 'static, V: Clone + Send + Sync + 'static, @@ -209,7 +209,7 @@ where } } -impl Buildable for EncryptedTransformBuilder +impl Buildable for ProtectedTransformBuilder where K: Clone + Hash + Eq + Send + Sync + 'static, V: Clone + Send + Sync + 'static, @@ -230,16 +230,16 @@ where 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. + // Build the post-transform tier chain and wrap it so values are protected + // (and key-bound) before reaching it. let post_tier = self.post.build_tier(clock.clone(), telemetry.clone(), true); - let encrypted = EncryptedTier::new( + let protected = ProtectedTier::new( post_tier, - self.cipher, + self.protector, telemetry.clone(), - type_name::>(None), + type_name::>(None), ); - let adapted = TransformAdapter::from_boxed(encrypted, self.key_encoder, self.value_codec); + let adapted = TransformAdapter::from_boxed(protected, self.key_encoder, self.value_codec); let fallback = crate::fallback::FallbackCache::new(type_name::(None), pre_tier, adapted, clock, None, telemetry); diff --git a/crates/cachet/src/builder/mod.rs b/crates/cachet/src/builder/mod.rs index 354594131..aad95013a 100644 --- a/crates/cachet/src/builder/mod.rs +++ b/crates/cachet/src/builder/mod.rs @@ -18,7 +18,7 @@ mod transform; pub use cache::CacheBuilder; #[cfg(feature = "encrypt")] -pub use encrypt::EncryptedTransformBuilder; +pub use encrypt::ProtectedTransformBuilder; pub use fallback::FallbackBuilder; pub use sealed::CacheTierBuilder; pub use transform::TransformBuilder; diff --git a/crates/cachet/src/lib.rs b/crates/cachet/src/lib.rs index afabf05da..4060b364c 100644 --- a/crates/cachet/src/lib.rs +++ b/crates/cachet/src/lib.rs @@ -154,7 +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. | +//! | `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,19 +226,20 @@ //! //! ## 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. +//! 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 encrypted: keys are left serialized-but-unencrypted so they remain +//! 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 decrypt (corrupt, truncated, wrong key, tampered, or +//! 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.decrypt_failed` telemetry event. +//! `cache.unprotect_failed` telemetry event. //! //! ```ignore //! use cachet::Cache; @@ -251,7 +252,7 @@ //! let cache = Cache::builder::(clock) //! .memory() //! .serialize() -//! .encrypt_with(my_cipher) // any `AeadCipher` implementation +//! .protect_with(my_protector) // any `ValueProtector` implementation //! .fallback(remote) //! .build(); //! @@ -260,31 +261,31 @@ //! # }; //! ``` //! -//! ### Example: a `SymCrypt`-backed AES-256-GCM cipher +//! ### Example: a `SymCrypt`-backed AES-256-GCM protector //! //! [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. +//! 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::{AeadCipher, DecodeOutcome, Error}; +//! use cachet::{DecodeOutcome, Error, ValueProtector}; //! use symcrypt::cipher::BlockCipherType; //! use symcrypt::gcm::GcmExpandedKey; //! //! const NONCE_SIZE: usize = 12; //! const TAG_SIZE: usize = 16; //! -//! pub struct Aes256GcmCipher { +//! pub struct Aes256GcmProtector { //! key: GcmExpandedKey, //! } //! -//! impl Aes256GcmCipher { +//! 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"); @@ -292,8 +293,8 @@ //! } //! } //! -//! impl AeadCipher for Aes256GcmCipher { -//! fn encrypt(&self, aad: &[u8], plaintext: &BytesView) -> Result { +//! 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}")))?; //! @@ -308,12 +309,12 @@ //! 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); +//! self.key.encrypt_in_place(&nonce, context, &mut head[NONCE_SIZE..], tag); //! Ok(result.into()) //! } //! -//! fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error> { -//! let bytes = ciphertext.to_vec(); +//! fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error> { +//! let bytes = protected.to_vec(); //! if bytes.len() < NONCE_SIZE + TAG_SIZE { //! return Ok(DecodeOutcome::SoftFailure("ciphertext too short")); //! } @@ -322,7 +323,7 @@ //! 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) { +//! match self.key.decrypt_in_place(nonce, context, &mut buffer, tag) { //! // Any authentication failure is a soft failure: the entry reads as a miss. //! Ok(()) => Ok(DecodeOutcome::Value(buffer.into())), //! Err(_) => Ok(DecodeOutcome::SoftFailure("AES-GCM decryption failed")), @@ -331,8 +332,8 @@ //! } //! ``` //! -//! 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. +//! 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 //! @@ -397,7 +398,7 @@ mod wrapper; #[cfg(feature = "encrypt")] #[doc(inline)] -pub use builder::EncryptedTransformBuilder; +pub use builder::ProtectedTransformBuilder; #[doc(inline)] pub use builder::{CacheBuilder, CacheTierBuilder, FallbackBuilder, TransformBuilder}; #[doc(inline)] @@ -421,8 +422,11 @@ pub use policy::InsertPolicy; pub use refresh::TimeToRefresh; #[doc(inline)] pub use telemetry::handler::{CacheEventHandler, CacheOperationEvent, CacheTierEvent}; +#[cfg(all(feature = "encrypt", any(feature = "test-util", test)))] +#[doc(inline)] +pub use transform::MockValueProtector; #[cfg(feature = "encrypt")] #[doc(inline)] -pub use transform::AeadCipher; +pub use transform::ValueProtector; #[doc(inline)] pub use transform::{Codec, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, infallible, infallible_owned}; diff --git a/crates/cachet/src/telemetry/attributes.rs b/crates/cachet/src/telemetry/attributes.rs index e6306ac64..c5d8e6597 100644 --- a/crates/cachet/src/telemetry/attributes.rs +++ b/crates/cachet/src/telemetry/attributes.rs @@ -99,11 +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. +/// 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 ciphertext. -pub const EVENT_DECRYPT_FAILED: &str = "cache.decrypt_failed"; +/// truncated, wrong-key, tampered, or relocated value. +pub const EVENT_UNPROTECT_FAILED: &str = "cache.unprotect_failed"; #[cfg(test)] mod tests { @@ -136,7 +137,7 @@ mod tests { EVENT_REFRESH_HIT, EVENT_REFRESH_MISS, EVENT_EVICTION, - EVENT_DECRYPT_FAILED, + 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 f99e9e8b5..90ad06c99 100644 --- a/crates/cachet/src/telemetry/cache.rs +++ b/crates/cachet/src/telemetry/cache.rs @@ -365,26 +365,26 @@ impl CacheTelemetry { ); } - /// Records that a stored value failed authenticated decryption and was - /// treated as a cache miss. + /// Records that a stored value failed authentication and could not be + /// recovered, so it was treated as a cache miss. /// - /// Fires from an `EncryptedTier` on the `get` path, so the thread-local + /// Fires from a `ProtectedTier` on the `get` path, so the thread-local /// request ID is set and correlates the failure with the operation that /// observed it. Signals a corrupt, truncated, wrong-key, tampered, or - /// relocated ciphertext. The encrypted tier always sits on the - /// post-transform (fallback) side of the hierarchy, so the event is tagged + /// relocated value. The protected tier always sits on the post-transform + /// (fallback) side of the hierarchy, so the event is tagged /// `fallback = true` to match the tier's other events. #[cfg(feature = "encrypt")] - pub(crate) fn record_decrypt_failure(&self, cache_name: CacheName) { + 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_DECRYPT_FAILED); + tracing::warn!(cache.name = cache_name, cache.event = attributes::EVENT_UNPROTECT_FAILED); } self.emit_tier_event( Self::current_request_id(), cache_name, - attributes::EVENT_DECRYPT_FAILED, + attributes::EVENT_UNPROTECT_FAILED, Duration::ZERO, true, ); diff --git a/crates/cachet/src/transform/encrypt.rs b/crates/cachet/src/transform/encrypt.rs index ccd750e74..45e4b0461 100644 --- a/crates/cachet/src/transform/encrypt.rs +++ b/crates/cachet/src/transform/encrypt.rs @@ -1,16 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Authenticated encryption of cache values stored in an untrusted tier. +//! Authenticated protection 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. +//! This provides only the protection *mechanism* — it carries no cryptographic +//! dependency of its own. [`ValueProtector`] is the pluggable contract: you supply the +//! actual implementation, backed by your approved cryptographic library, and register +//! it with [`protect_with`](crate::TransformBuilder::protect_with). [`ProtectedTier`] +//! installs that protector at the storage boundary, where both the key and value are +//! available, and binds each value to its storage key. //! -//! See the crate-level "Encryption Boundary" docs for a reference `AeadCipher` +//! See the crate-level "Encryption Boundary" docs for a reference `ValueProtector` //! implementation backed by `SymCrypt` (FIPS-certifiable AES-256-GCM). use std::borrow::Cow; @@ -37,83 +37,194 @@ pub(crate) fn to_contiguous(view: &BytesView) -> Cow<'_, [u8]> { } } -/// Authenticated encryption with associated data (AEAD) for cache values. +/// Authenticated protection of cache values before they reach an untrusted tier. /// -/// 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. +/// Implementations turn a value's plaintext bytes into stored bytes and back, binding +/// a caller-supplied *context* value. `ProtectedTier` passes the entry's storage key as +/// the context, so a value is cryptographically bound to the key it was stored under. /// -/// This trait supplies no 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 +/// This trait supplies no implementation of its own: implement it with your +/// organization's approved cryptographic library and register it via +/// [`protect_with`](crate::TransformBuilder::protect_with). See the crate-level /// "Encryption Boundary" docs for a reference `SymCrypt`-backed implementation. /// /// # Security contract /// -/// Implementors **must** 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, +/// Implementors **must** bind `context`: [`unprotect`](Self::unprotect) must return +/// [`DecodeOutcome::SoftFailure`] when the `context` does not match the value supplied +/// to [`protect`](Self::protect). This is what binds each value to its storage key, /// preventing a value from being relocated to a different key in the backing store. /// Implementors using a nonce-based scheme are responsible for nonce discipline — use -/// a fresh nonce per [`encrypt`](Self::encrypt), or a nonce-misuse-resistant scheme. +/// a fresh nonce per [`protect`](Self::protect), 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. +/// [`unprotect`](Self::unprotect) distinguishes two failure modes: +/// - `Ok(DecodeOutcome::SoftFailure(_))` — the stored value is unrecoverable (corrupt, +/// truncated, tampered, wrong key, or context mismatch); the cache treats it as a +/// miss. /// - `Err(_)` — the operation could not be attempted (e.g. an unavailable backend); /// the error propagates to the caller. -pub trait AeadCipher: Send + Sync { - /// Encrypts `plaintext`, authenticating `aad`, and returns the stored representation. +pub trait ValueProtector: Send + Sync { + /// Protects `plaintext`, binding `context`, and returns the stored representation. /// /// # Errors /// - /// Returns an error if encryption cannot be performed. - fn encrypt(&self, aad: &[u8], plaintext: &BytesView) -> Result; + /// Returns an error if protection cannot be performed. + fn protect(&self, context: &[u8], plaintext: &BytesView) -> Result; - /// Decrypts `ciphertext`, verifying `aad`. + /// Recovers a value previously protected under `context`. /// /// # Errors /// - /// Returns `Err` only if decryption could not be attempted. An authentication or + /// Returns `Err` only if the operation could not be attempted. An authentication or /// format failure is reported as `Ok(DecodeOutcome::SoftFailure(_))`. - fn decrypt(&self, aad: &[u8], ciphertext: &BytesView) -> Result, Error>; + fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error>; } -/// A cache tier that transparently encrypts values with an [`AeadCipher`]. +/// Length of the mock protector's nonce prefix, in bytes. +#[cfg(any(feature = "test-util", test))] +const MOCK_NONCE_SIZE: usize = 12; + +/// A deterministic, crypto-free [`ValueProtector`] for tests. +/// +/// Available with the `test-util` feature. Use it to exercise a +/// [`protect_with`](crate::TransformBuilder::protect_with) pipeline — round-trips, key +/// binding, and unprotect failures — without a real cryptographic library or a source +/// of entropy, keeping tests fast and reproducible. +/// +/// The stored form is `nonce || context_len || context || masked_body`. The nonce comes +/// from a monotonic counter (so repeated `protect` calls of identical input still +/// differ, yet stay reproducible), and `masked_body` is the plaintext combined with a +/// nonce-derived keystream via XOR. It binds the `context`: [`unprotect`](ValueProtector::unprotect) +/// returns [`DecodeOutcome::SoftFailure`] on a context mismatch, truncation, or +/// corruption, 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(); +/// # } +/// ``` +#[cfg(any(feature = "test-util", test))] +#[derive(Debug, Default)] +pub struct MockValueProtector { + counter: std::sync::atomic::AtomicU32, +} + +#[cfg(any(feature = "test-util", test))] +impl MockValueProtector { + /// Creates a new mock protector. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Derives a nonce from the counter (counter in the first 4 bytes, then filler). + fn nonce_bytes(counter: u32) -> [u8; MOCK_NONCE_SIZE] { + let mut nonce = [0xA5u8; MOCK_NONCE_SIZE]; + nonce[..4].copy_from_slice(&counter.to_le_bytes()); + nonce + } + + /// Reversible keystream transform: `body[i] ^= 0x5A ^ nonce[i % NONCE]`. + 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]; + } + } +} + +#[cfg(any(feature = "test-util", test))] +impl ValueProtector for MockValueProtector { + fn protect(&self, context: &[u8], plaintext: &BytesView) -> Result { + use std::sync::atomic::Ordering; + + let nonce = Self::nonce_bytes(self.counter.fetch_add(1, Ordering::Relaxed)); + let mut out = Vec::with_capacity(MOCK_NONCE_SIZE + 4 + context.len() + plaintext.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&u32::try_from(context.len()).expect("context fits in u32").to_le_bytes()); + out.extend_from_slice(context); + let body_start = out.len(); + for (slice, _) in plaintext.slices() { + out.extend_from_slice(slice); + } + Self::mask(&nonce, &mut out[body_start..]); + Ok(BytesView::from(out)) + } + + fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error> { + let bytes = protected.to_vec(); + let Some(nonce) = bytes.get(..MOCK_NONCE_SIZE) else { + return Ok(DecodeOutcome::SoftFailure("mock: truncated nonce")); + }; + let nonce: [u8; MOCK_NONCE_SIZE] = nonce.try_into().expect("MOCK_NONCE_SIZE bytes"); + let rest = &bytes[MOCK_NONCE_SIZE..]; + let Some(len_bytes) = rest.get(..4) else { + return Ok(DecodeOutcome::SoftFailure("mock: truncated length")); + }; + let context_len = u32::from_le_bytes(len_bytes.try_into().expect("4 bytes")) as usize; + let Some(stored_context) = rest.get(4..4 + context_len) else { + return Ok(DecodeOutcome::SoftFailure("mock: truncated context")); + }; + if stored_context != context { + return Ok(DecodeOutcome::SoftFailure("mock: context mismatch")); + } + let mut body = rest[4 + context_len..].to_vec(); + Self::mask(&nonce, &mut body); + Ok(DecodeOutcome::Value(BytesView::from(body))) + } +} + +/// A cache tier that transparently protects values with a [`ValueProtector`]. /// /// It wraps an inner `CacheTier` (typically a remote tier -/// holding serialized bytes). On insert it 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 { +/// holding serialized bytes). On insert it protects the value, binding the storage key +/// as context; on get it recovers it, and an authentication failure — corrupt bytes, a +/// tampered entry, or a value relocated from a different key — surfaces as a cache miss +/// (`Ok(None)`) rather than an error. Each such failure emits a `cache.unprotect_failed` +/// telemetry event so that tampering with the backing store is observable rather than +/// silent. +pub(crate) struct ProtectedTier { inner: S, - cipher: Box, + protector: Box, telemetry: CacheTelemetry, name: CacheName, } -impl EncryptedTier { - pub(crate) fn new(inner: S, cipher: Box, telemetry: CacheTelemetry, name: CacheName) -> Self { +impl ProtectedTier { + pub(crate) fn new(inner: S, protector: Box, telemetry: CacheTelemetry, name: CacheName) -> Self { Self { inner, - cipher, + protector, telemetry, name, } } } -impl std::fmt::Debug for EncryptedTier { +impl std::fmt::Debug for ProtectedTier { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EncryptedTier").field("inner", &self.inner).finish_non_exhaustive() + f.debug_struct("ProtectedTier").field("inner", &self.inner).finish_non_exhaustive() } } -impl CacheTier for EncryptedTier +impl CacheTier for ProtectedTier where S: CacheTier + Send + Sync, { @@ -124,31 +235,31 @@ where 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)? { + // The storage key is bound as context, so a value planted under the + // wrong key fails to unprotect and is treated as a miss. + let context = to_contiguous(key); + match self.protector.unprotect(context.as_ref(), &value)? { DecodeOutcome::Value(value) => { - let mut decrypted = CacheEntry::new(value); + let mut recovered = CacheEntry::new(value); if let Some(ttl) = ttl { - decrypted.set_ttl(ttl); + recovered.set_ttl(ttl); } if let Some(cached_at) = cached_at { - decrypted.ensure_cached_at(cached_at); + recovered.ensure_cached_at(cached_at); } - Ok(Some(decrypted)) + Ok(Some(recovered)) } DecodeOutcome::SoftFailure(_) => { - self.telemetry.record_decrypt_failure(self.name); + self.telemetry.record_unprotect_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 + let context = to_contiguous(&key); + let protected = entry.try_map_value(|value| self.protector.protect(context.as_ref(), &value))?; + self.inner.insert(key, protected).await } async fn invalidate(&self, key: &BytesView) -> Result<(), Error> { @@ -174,57 +285,25 @@ mod tests { 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; + /// A protector whose operations always hard-error, for exercising error propagation. + struct FailingProtector; - impl AeadCipher for FailingCipher { - fn encrypt(&self, _aad: &[u8], _plaintext: &BytesView) -> Result { - Err(Error::from_message("encrypt failed")) + impl ValueProtector for FailingProtector { + fn protect(&self, _context: &[u8], _plaintext: &BytesView) -> Result { + Err(Error::from_message("protect failed")) } - fn decrypt(&self, _aad: &[u8], _ciphertext: &BytesView) -> Result, Error> { - Err(Error::from_message("decrypt failed")) + fn unprotect(&self, _context: &[u8], _protected: &BytesView) -> Result, Error> { + Err(Error::from_message("unprotect failed")) } } - fn tier(inner: S) -> EncryptedTier { - EncryptedTier::new(inner, Box::new(MockAeadCipher), CacheTelemetry::new(), "encrypted-test") + fn tier(inner: S) -> ProtectedTier { + ProtectedTier::new(inner, Box::new(MockValueProtector::new()), CacheTelemetry::new(), "encrypted-test") } - fn failing_tier(inner: S) -> EncryptedTier { - EncryptedTier::new(inner, Box::new(FailingCipher), CacheTelemetry::new(), "failing-test") + fn failing_tier(inner: S) -> ProtectedTier { + ProtectedTier::new(inner, Box::new(FailingProtector), CacheTelemetry::new(), "failing-test") } #[cfg_attr(miri, ignore)] @@ -324,7 +403,7 @@ mod tests { #[test] fn debug_omits_inner_secrets() { let tier = tier(MockCache::::new()); - assert!(format!("{tier:?}").contains("EncryptedTier")); + assert!(format!("{tier:?}").contains("ProtectedTier")); } #[cfg_attr(miri, ignore)] @@ -336,9 +415,9 @@ mod tests { let _guard = tracing::subscriber::set_default(capture.subscriber()); let inner = MockCache::::new(); - let tier = EncryptedTier::new( + let tier = ProtectedTier::new( inner.clone(), - Box::new(MockAeadCipher), + Box::new(MockValueProtector::new()), CacheTelemetry::with_logging(), "encrypted-test", ); @@ -353,7 +432,7 @@ mod tests { 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); + capture.assert_contains(crate::telemetry::attributes::EVENT_UNPROTECT_FAILED); } #[cfg_attr(miri, ignore)] @@ -419,13 +498,20 @@ mod tests { } #[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(_))); + fn mock_protector_soft_fails_on_malformed_input() { + let p = MockValueProtector::new(); + let soft = |bytes: Vec| matches!(p.unprotect(b"context", &BytesView::from(bytes)), Ok(DecodeOutcome::SoftFailure(_))); + + // Too short to hold the nonce prefix. + assert!(soft(vec![0u8; 4]), "truncated nonce must soft-fail"); + // Nonce present, but no room for the 4-byte length prefix. + assert!(soft(vec![0xA5u8; MOCK_NONCE_SIZE]), "truncated length must soft-fail"); + // Length prefix declares a 4-byte context, but no context bytes follow. + let mut declares_missing_context = vec![0xA5u8; MOCK_NONCE_SIZE]; + declares_missing_context.extend_from_slice(&4u32.to_le_bytes()); + assert!(soft(declares_missing_context), "truncated context must soft-fail"); + // A well-formed round-trip must NOT soft-fail. + let valid = p.protect(b"context", &view(b"value")).expect("protect should succeed"); + assert!(!soft(valid.to_vec()), "a valid round-trip must recover, not soft-fail"); } } diff --git a/crates/cachet/src/transform/mod.rs b/crates/cachet/src/transform/mod.rs index 90ce25ecd..0cce1d0df 100644 --- a/crates/cachet/src/transform/mod.rs +++ b/crates/cachet/src/transform/mod.rs @@ -43,8 +43,10 @@ pub(crate) mod testing; mod tier; pub use codec::{Codec, DecodeOutcome, Encoder, TransformCodec, TransformEncoder, infallible, infallible_owned}; +#[cfg(all(feature = "encrypt", any(feature = "test-util", test)))] +pub use encrypt::MockValueProtector; #[cfg(feature = "encrypt")] -pub use encrypt::AeadCipher; +pub(crate) use encrypt::ProtectedTier; #[cfg(feature = "encrypt")] -pub(crate) use encrypt::EncryptedTier; +pub use encrypt::ValueProtector; pub(crate) use tier::TransformAdapter; diff --git a/crates/cachet/tests/encrypt.rs b/crates/cachet/tests/encrypt.rs index 116d11935..7af12805d 100644 --- a/crates/cachet/tests/encrypt.rs +++ b/crates/cachet/tests/encrypt.rs @@ -1,90 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Integration tests for the value-encryption transform via `CacheBuilder`. +//! Integration tests for the value-protection 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. +//! 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"))] -use std::sync::atomic::{AtomicU32, Ordering}; - use bytesbuf::BytesView; -use cachet::{AeadCipher, Cache, CacheEntry, CacheOp, CacheTier, DecodeOutcome, Error, MockCache}; +use cachet::{Cache, CacheEntry, CacheOp, CacheTier, MockCache, MockValueProtector}; 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. +/// 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.to_string()).expect("postcard serialization should not fail")); @@ -100,7 +30,7 @@ async fn encrypt_pipeline_stores_ciphertext_and_round_trips() { let cache = Cache::builder::(Clock::new_frozen()) .storage(l1.clone()) .serialize() - .encrypt_with(MockCipher::default()) + .protect_with(MockValueProtector::new()) .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) .build(); @@ -146,7 +76,7 @@ async fn encrypt_each_insert_uses_fresh_nonce() { let cache = Cache::builder::(Clock::new_frozen()) .storage(MockCache::::new()) .serialize() - .encrypt_with(MockCipher::default()) + .protect_with(MockValueProtector::new()) .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) .build(); @@ -175,13 +105,13 @@ async fn encrypt_each_insert_uses_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. + // `.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() - .encrypt_with(MockCipher::default()) + .protect_with(MockValueProtector::new()) .fallback(Cache::builder::(Clock::new_frozen()).storage(l3.clone())) .build(); @@ -216,7 +146,7 @@ async fn relocated_ciphertext_reads_as_a_miss() { let cache = Cache::builder::(Clock::new_frozen()) .storage(l1.clone()) .serialize() - .encrypt_with(MockCipher::default()) + .protect_with(MockValueProtector::new()) .fallback(Cache::builder::(Clock::new_frozen()).storage(remote.clone())) .build(); @@ -256,14 +186,14 @@ 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")); + .protect_with(MockValueProtector::new()); + assert!(format!("{builder:?}").contains("ProtectedTransformBuilder")); } #[cfg_attr(miri, ignore)] #[tokio::test] async fn encrypt_chained_post_transform_fallbacks() { - // Chain two post-transform fallback tiers after `.encrypt_with()`, exercising the + // Chain two post-transform fallback tiers after `.protect_with()`, exercising the // second `.fallback()` that folds the existing post tier into a FallbackBuilder. let l1 = MockCache::::new(); let l2 = MockCache::::new(); @@ -271,7 +201,7 @@ async fn encrypt_chained_post_transform_fallbacks() { let cache = Cache::builder::(Clock::new_frozen()) .storage(l1.clone()) .serialize() - .encrypt_with(MockCipher::default()) + .protect_with(MockValueProtector::new()) .fallback(Cache::builder::(Clock::new_frozen()).storage(l2.clone())) .fallback(Cache::builder::(Clock::new_frozen()).storage(l3.clone())) .build(); From 2b1ede5d90881760c837734a232329f7734dc960 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Wed, 22 Jul 2026 12:11:44 -0400 Subject: [PATCH 13/20] Don't use hardcoded nonce even in mock implementation --- crates/cachet/src/transform/encrypt.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/cachet/src/transform/encrypt.rs b/crates/cachet/src/transform/encrypt.rs index 45e4b0461..65c01b51c 100644 --- a/crates/cachet/src/transform/encrypt.rs +++ b/crates/cachet/src/transform/encrypt.rs @@ -135,10 +135,13 @@ impl MockValueProtector { Self::default() } - /// Derives a nonce from the counter (counter in the first 4 bytes, then filler). + /// Derives a deterministic nonce from the counter bytes (repeated to fill). fn nonce_bytes(counter: u32) -> [u8; MOCK_NONCE_SIZE] { - let mut nonce = [0xA5u8; MOCK_NONCE_SIZE]; - nonce[..4].copy_from_slice(&counter.to_le_bytes()); + let counter_bytes = counter.to_le_bytes(); + let mut nonce = [0u8; MOCK_NONCE_SIZE]; + for (i, byte) in nonce.iter_mut().enumerate() { + *byte = counter_bytes[i % counter_bytes.len()]; + } nonce } From 23fcc7b25bdf91dd496af2a3f8ee2fd7b59b59e5 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Wed, 22 Jul 2026 14:19:40 -0400 Subject: [PATCH 14/20] Better fix --- crates/cachet/src/transform/encrypt.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/cachet/src/transform/encrypt.rs b/crates/cachet/src/transform/encrypt.rs index 65c01b51c..578abda2e 100644 --- a/crates/cachet/src/transform/encrypt.rs +++ b/crates/cachet/src/transform/encrypt.rs @@ -138,11 +138,7 @@ impl MockValueProtector { /// Derives a deterministic nonce from the counter bytes (repeated to fill). fn nonce_bytes(counter: u32) -> [u8; MOCK_NONCE_SIZE] { let counter_bytes = counter.to_le_bytes(); - let mut nonce = [0u8; MOCK_NONCE_SIZE]; - for (i, byte) in nonce.iter_mut().enumerate() { - *byte = counter_bytes[i % counter_bytes.len()]; - } - nonce + std::array::from_fn(|i| counter_bytes[i % counter_bytes.len()]) } /// Reversible keystream transform: `body[i] ^= 0x5A ^ nonce[i % NONCE]`. From 0389c642bd6306c324a1f4fc017adf6480e1c58e Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Wed, 22 Jul 2026 18:26:06 -0400 Subject: [PATCH 15/20] Reorganization --- crates/cachet/src/transform/encrypt/mock.rs | 140 ++++++++++++ crates/cachet/src/transform/encrypt/mod.rs | 24 ++ .../cachet/src/transform/encrypt/protector.rs | 52 +++++ .../transform/{encrypt.rs => encrypt/tier.rs} | 210 +----------------- 4 files changed, 222 insertions(+), 204 deletions(-) create mode 100644 crates/cachet/src/transform/encrypt/mock.rs create mode 100644 crates/cachet/src/transform/encrypt/mod.rs create mode 100644 crates/cachet/src/transform/encrypt/protector.rs rename crates/cachet/src/transform/{encrypt.rs => encrypt/tier.rs} (54%) diff --git a/crates/cachet/src/transform/encrypt/mock.rs b/crates/cachet/src/transform/encrypt/mock.rs new file mode 100644 index 000000000..f344cd5ad --- /dev/null +++ b/crates/cachet/src/transform/encrypt/mock.rs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! A deterministic, crypto-free [`ValueProtector`] test double. + +use bytesbuf::BytesView; + +use super::ValueProtector; +use crate::Error; +use crate::transform::DecodeOutcome; + +/// Length of the mock protector's nonce prefix, in bytes. +const MOCK_NONCE_SIZE: usize = 12; + +/// A deterministic, crypto-free [`ValueProtector`] for tests. +/// +/// Available with the `test-util` feature. Use it to exercise a +/// [`protect_with`](crate::TransformBuilder::protect_with) pipeline — round-trips, key +/// binding, and unprotect failures — without a real cryptographic library or a source +/// of entropy, keeping tests fast and reproducible. +/// +/// The stored form is `nonce || context || masked_body`. The nonce comes from a +/// monotonic counter (so repeated `protect` calls of identical input still differ, yet +/// stay reproducible), and `masked_body` is the plaintext combined with a nonce-derived +/// keystream via XOR. It binds the `context`: [`unprotect`](ValueProtector::unprotect) +/// returns [`DecodeOutcome::SoftFailure`] on a context mismatch, truncation, or +/// corruption, mirroring the [`ValueProtector`] security contract. On `unprotect` the +/// caller supplies the `context`, so its length is known and never stored. +/// +/// # Security +/// +/// This provides **no confidentiality or integrity** — the transform is trivially +/// reversible and the key is ignored. It is gated behind `test-util` and must never be +/// used in production. +/// +/// # Examples +/// +/// ``` +/// # #[cfg(all(feature = "serialize", feature = "memory"))] { +/// use cachet::{Cache, MockValueProtector}; +/// use tick::Clock; +/// +/// let clock = Clock::new_frozen(); +/// let remote = Cache::builder::(clock.clone()).memory(); +/// let cache = Cache::builder::(clock) +/// .memory() +/// .serialize() +/// .protect_with(MockValueProtector::new()) +/// .fallback(remote) +/// .build(); +/// # } +/// ``` +#[derive(Debug, Default)] +pub struct MockValueProtector { + counter: std::sync::atomic::AtomicU32, +} + +impl MockValueProtector { + /// Creates a new mock protector. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Derives a deterministic nonce from the counter bytes (repeated to fill). + 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]`. + fn mask(nonce: &[u8; MOCK_NONCE_SIZE], body: &mut [u8]) { + for (i, byte) in body.iter_mut().enumerate() { + *byte ^= 0x5A ^ nonce[i % MOCK_NONCE_SIZE]; + } + } +} + +impl ValueProtector for MockValueProtector { + fn protect(&self, context: &[u8], plaintext: &BytesView) -> Result { + use std::sync::atomic::Ordering; + + let nonce = Self::nonce_bytes(self.counter.fetch_add(1, Ordering::Relaxed)); + let mut out = Vec::with_capacity(MOCK_NONCE_SIZE + context.len() + plaintext.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(context); + let body_start = out.len(); + for (slice, _) in plaintext.slices() { + out.extend_from_slice(slice); + } + Self::mask(&nonce, &mut out[body_start..]); + Ok(BytesView::from(out)) + } + + fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error> { + let bytes = protected.to_vec(); + let Some(nonce) = bytes.get(..MOCK_NONCE_SIZE) else { + return Ok(DecodeOutcome::SoftFailure("mock: truncated nonce")); + }; + let nonce: [u8; MOCK_NONCE_SIZE] = nonce.try_into().expect("MOCK_NONCE_SIZE bytes"); + let rest = &bytes[MOCK_NONCE_SIZE..]; + // The caller supplies `context`, so its length is known; slice it off directly + // without any stored length or arithmetic on untrusted input. + let Some(stored_context) = rest.get(..context.len()) else { + return Ok(DecodeOutcome::SoftFailure("mock: truncated context")); + }; + if stored_context != context { + return Ok(DecodeOutcome::SoftFailure("mock: context mismatch")); + } + let mut body = rest[context.len()..].to_vec(); + Self::mask(&nonce, &mut body); + Ok(DecodeOutcome::Value(BytesView::from(body))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn view(data: &[u8]) -> BytesView { + BytesView::from(data.to_vec()) + } + + #[test] + fn mock_protector_soft_fails_on_malformed_input() { + let p = MockValueProtector::new(); + let soft = |bytes: Vec| matches!(p.unprotect(b"context", &BytesView::from(bytes)), Ok(DecodeOutcome::SoftFailure(_))); + + // Too short to hold the nonce prefix. + assert!(soft(vec![0u8; 4]), "truncated nonce must soft-fail"); + // Nonce present, but fewer bytes remain than the context length. + assert!(soft(vec![0u8; MOCK_NONCE_SIZE]), "truncated context must soft-fail"); + // A blob protected under a different (same-length) context must not match. + let other = p.protect(b"kontext", &view(b"value")).expect("protect should succeed"); + assert!(soft(other.to_vec()), "context mismatch must soft-fail"); + // A well-formed round-trip under the expected context must NOT soft-fail. + let valid = p.protect(b"context", &view(b"value")).expect("protect should succeed"); + assert!(!soft(valid.to_vec()), "a valid round-trip must recover, not soft-fail"); + } +} diff --git a/crates/cachet/src/transform/encrypt/mod.rs b/crates/cachet/src/transform/encrypt/mod.rs new file mode 100644 index 000000000..34b61eb39 --- /dev/null +++ b/crates/cachet/src/transform/encrypt/mod.rs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Authenticated protection of cache values stored in an untrusted tier. +//! +//! This provides only the protection *mechanism* — it carries no cryptographic +//! dependency of its own. [`ValueProtector`] is the pluggable contract: you supply the +//! actual implementation, backed by your approved cryptographic library, and register +//! it with [`protect_with`](crate::TransformBuilder::protect_with). [`ProtectedTier`] +//! installs that protector at the storage boundary, where both the key and value are +//! available, and binds each value to its storage key. +//! +//! See the crate-level "Encryption Boundary" docs for a reference [`ValueProtector`] +//! implementation backed by `SymCrypt` (FIPS-certifiable AES-256-GCM). + +#[cfg(any(feature = "test-util", test))] +mod mock; +mod protector; +mod tier; + +#[cfg(any(feature = "test-util", test))] +pub use mock::MockValueProtector; +pub use protector::ValueProtector; +pub(crate) use tier::ProtectedTier; diff --git a/crates/cachet/src/transform/encrypt/protector.rs b/crates/cachet/src/transform/encrypt/protector.rs new file mode 100644 index 000000000..9d030de73 --- /dev/null +++ b/crates/cachet/src/transform/encrypt/protector.rs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The [`ValueProtector`] authenticated-protection contract. + +use bytesbuf::BytesView; + +use crate::Error; +use crate::transform::DecodeOutcome; + +/// Authenticated protection of cache values before they reach an untrusted tier. +/// +/// Implementations turn a value's plaintext bytes into stored bytes and back, binding +/// a caller-supplied *context* value. `ProtectedTier` passes the entry's storage key as +/// the context, so a value is cryptographically bound to the key it was stored under. +/// +/// This trait supplies no implementation of its own: implement it with your +/// organization's approved cryptographic library and register it via +/// [`protect_with`](crate::TransformBuilder::protect_with). See the crate-level +/// "Encryption Boundary" docs for a reference `SymCrypt`-backed implementation. +/// +/// # Security contract +/// +/// Implementors **must** bind `context`: [`unprotect`](Self::unprotect) must return +/// [`DecodeOutcome::SoftFailure`] when the `context` does not match the value supplied +/// to [`protect`](Self::protect). This is what binds each value to its storage key, +/// preventing a value from being relocated to a different key in the backing store. +/// Implementors using a nonce-based scheme are responsible for nonce discipline — use +/// a fresh nonce per [`protect`](Self::protect), or a nonce-misuse-resistant scheme. +/// +/// [`unprotect`](Self::unprotect) distinguishes two failure modes: +/// - `Ok(DecodeOutcome::SoftFailure(_))` — the stored value is unrecoverable (corrupt, +/// truncated, tampered, wrong key, or context mismatch); the cache treats it as a +/// miss. +/// - `Err(_)` — the operation could not be attempted (e.g. an unavailable backend); +/// the error propagates to the caller. +pub trait ValueProtector: Send + Sync { + /// Protects `plaintext`, binding `context`, and returns the stored representation. + /// + /// # Errors + /// + /// Returns an error if protection cannot be performed. + fn protect(&self, context: &[u8], plaintext: &BytesView) -> Result; + + /// Recovers a value previously protected under `context`. + /// + /// # Errors + /// + /// Returns `Err` only if the operation could not be attempted. An authentication or + /// format failure is reported as `Ok(DecodeOutcome::SoftFailure(_))`. + fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error>; +} diff --git a/crates/cachet/src/transform/encrypt.rs b/crates/cachet/src/transform/encrypt/tier.rs similarity index 54% rename from crates/cachet/src/transform/encrypt.rs rename to crates/cachet/src/transform/encrypt/tier.rs index 578abda2e..9dfc435c0 100644 --- a/crates/cachet/src/transform/encrypt.rs +++ b/crates/cachet/src/transform/encrypt/tier.rs @@ -1,22 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Authenticated protection of cache values stored in an untrusted tier. -//! -//! This provides only the protection *mechanism* — it carries no cryptographic -//! dependency of its own. [`ValueProtector`] is the pluggable contract: you supply the -//! actual implementation, backed by your approved cryptographic library, and register -//! it with [`protect_with`](crate::TransformBuilder::protect_with). [`ProtectedTier`] -//! installs that protector at the storage boundary, where both the key and value are -//! available, and binds each value to its storage key. -//! -//! See the crate-level "Encryption Boundary" docs for a reference `ValueProtector` -//! implementation backed by `SymCrypt` (FIPS-certifiable AES-256-GCM). +//! The [`ProtectedTier`] cache tier that applies a [`ValueProtector`] at the boundary. use std::borrow::Cow; use bytesbuf::BytesView; +use super::ValueProtector; use crate::cache::CacheName; use crate::telemetry::CacheTelemetry; use crate::transform::DecodeOutcome; @@ -24,7 +15,7 @@ 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]> { +fn to_contiguous(view: &BytesView) -> Cow<'_, [u8]> { let first = view.first_slice(); if first.len() == view.len() { Cow::Borrowed(first) @@ -37,159 +28,6 @@ pub(crate) fn to_contiguous(view: &BytesView) -> Cow<'_, [u8]> { } } -/// Authenticated protection of cache values before they reach an untrusted tier. -/// -/// Implementations turn a value's plaintext bytes into stored bytes and back, binding -/// a caller-supplied *context* value. `ProtectedTier` passes the entry's storage key as -/// the context, so a value is cryptographically bound to the key it was stored under. -/// -/// This trait supplies no implementation of its own: implement it with your -/// organization's approved cryptographic library and register it via -/// [`protect_with`](crate::TransformBuilder::protect_with). See the crate-level -/// "Encryption Boundary" docs for a reference `SymCrypt`-backed implementation. -/// -/// # Security contract -/// -/// Implementors **must** bind `context`: [`unprotect`](Self::unprotect) must return -/// [`DecodeOutcome::SoftFailure`] when the `context` does not match the value supplied -/// to [`protect`](Self::protect). This is what binds each value to its storage key, -/// preventing a value from being relocated to a different key in the backing store. -/// Implementors using a nonce-based scheme are responsible for nonce discipline — use -/// a fresh nonce per [`protect`](Self::protect), or a nonce-misuse-resistant scheme. -/// -/// [`unprotect`](Self::unprotect) distinguishes two failure modes: -/// - `Ok(DecodeOutcome::SoftFailure(_))` — the stored value is unrecoverable (corrupt, -/// truncated, tampered, wrong key, or context mismatch); the cache treats it as a -/// miss. -/// - `Err(_)` — the operation could not be attempted (e.g. an unavailable backend); -/// the error propagates to the caller. -pub trait ValueProtector: Send + Sync { - /// Protects `plaintext`, binding `context`, and returns the stored representation. - /// - /// # Errors - /// - /// Returns an error if protection cannot be performed. - fn protect(&self, context: &[u8], plaintext: &BytesView) -> Result; - - /// Recovers a value previously protected under `context`. - /// - /// # Errors - /// - /// Returns `Err` only if the operation could not be attempted. An authentication or - /// format failure is reported as `Ok(DecodeOutcome::SoftFailure(_))`. - fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error>; -} - -/// Length of the mock protector's nonce prefix, in bytes. -#[cfg(any(feature = "test-util", test))] -const MOCK_NONCE_SIZE: usize = 12; - -/// A deterministic, crypto-free [`ValueProtector`] for tests. -/// -/// Available with the `test-util` feature. Use it to exercise a -/// [`protect_with`](crate::TransformBuilder::protect_with) pipeline — round-trips, key -/// binding, and unprotect failures — without a real cryptographic library or a source -/// of entropy, keeping tests fast and reproducible. -/// -/// The stored form is `nonce || context_len || context || masked_body`. The nonce comes -/// from a monotonic counter (so repeated `protect` calls of identical input still -/// differ, yet stay reproducible), and `masked_body` is the plaintext combined with a -/// nonce-derived keystream via XOR. It binds the `context`: [`unprotect`](ValueProtector::unprotect) -/// returns [`DecodeOutcome::SoftFailure`] on a context mismatch, truncation, or -/// corruption, 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(); -/// # } -/// ``` -#[cfg(any(feature = "test-util", test))] -#[derive(Debug, Default)] -pub struct MockValueProtector { - counter: std::sync::atomic::AtomicU32, -} - -#[cfg(any(feature = "test-util", test))] -impl MockValueProtector { - /// Creates a new mock protector. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Derives a deterministic nonce from the counter bytes (repeated to fill). - 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]`. - 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]; - } - } -} - -#[cfg(any(feature = "test-util", test))] -impl ValueProtector for MockValueProtector { - fn protect(&self, context: &[u8], plaintext: &BytesView) -> Result { - use std::sync::atomic::Ordering; - - let nonce = Self::nonce_bytes(self.counter.fetch_add(1, Ordering::Relaxed)); - let mut out = Vec::with_capacity(MOCK_NONCE_SIZE + 4 + context.len() + plaintext.len()); - out.extend_from_slice(&nonce); - out.extend_from_slice(&u32::try_from(context.len()).expect("context fits in u32").to_le_bytes()); - out.extend_from_slice(context); - let body_start = out.len(); - for (slice, _) in plaintext.slices() { - out.extend_from_slice(slice); - } - Self::mask(&nonce, &mut out[body_start..]); - Ok(BytesView::from(out)) - } - - fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result, Error> { - let bytes = protected.to_vec(); - let Some(nonce) = bytes.get(..MOCK_NONCE_SIZE) else { - return Ok(DecodeOutcome::SoftFailure("mock: truncated nonce")); - }; - let nonce: [u8; MOCK_NONCE_SIZE] = nonce.try_into().expect("MOCK_NONCE_SIZE bytes"); - let rest = &bytes[MOCK_NONCE_SIZE..]; - let Some(len_bytes) = rest.get(..4) else { - return Ok(DecodeOutcome::SoftFailure("mock: truncated length")); - }; - let context_len = u32::from_le_bytes(len_bytes.try_into().expect("4 bytes")) as usize; - let Some(stored_context) = rest.get(4..4 + context_len) else { - return Ok(DecodeOutcome::SoftFailure("mock: truncated context")); - }; - if stored_context != context { - return Ok(DecodeOutcome::SoftFailure("mock: context mismatch")); - } - let mut body = rest[4 + context_len..].to_vec(); - Self::mask(&nonce, &mut body); - Ok(DecodeOutcome::Value(BytesView::from(body))) - } -} - /// A cache tier that transparently protects values with a [`ValueProtector`]. /// /// It wraps an inner `CacheTier` (typically a remote tier @@ -278,6 +116,7 @@ where mod tests { use cachet_tier::MockCache; + use super::super::MockValueProtector; use super::*; fn view(data: &[u8]) -> BytesView { @@ -408,9 +247,9 @@ mod tests { #[cfg_attr(miri, ignore)] #[tokio::test] async fn decrypt_failure_emits_telemetry() { - use testing_aids::LogCapture; + use testing_aids::tracing_logs::Capture; - let capture = LogCapture::new(); + let capture = Capture::new(); let _guard = tracing::subscriber::set_default(capture.subscriber()); let inner = MockCache::::new(); @@ -460,25 +299,6 @@ mod tests { ); } - #[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). @@ -495,22 +315,4 @@ mod tests { "to_contiguous must gather every span, not return only the first" ); } - - #[test] - fn mock_protector_soft_fails_on_malformed_input() { - let p = MockValueProtector::new(); - let soft = |bytes: Vec| matches!(p.unprotect(b"context", &BytesView::from(bytes)), Ok(DecodeOutcome::SoftFailure(_))); - - // Too short to hold the nonce prefix. - assert!(soft(vec![0u8; 4]), "truncated nonce must soft-fail"); - // Nonce present, but no room for the 4-byte length prefix. - assert!(soft(vec![0xA5u8; MOCK_NONCE_SIZE]), "truncated length must soft-fail"); - // Length prefix declares a 4-byte context, but no context bytes follow. - let mut declares_missing_context = vec![0xA5u8; MOCK_NONCE_SIZE]; - declares_missing_context.extend_from_slice(&4u32.to_le_bytes()); - assert!(soft(declares_missing_context), "truncated context must soft-fail"); - // A well-formed round-trip must NOT soft-fail. - let valid = p.protect(b"context", &view(b"value")).expect("protect should succeed"); - assert!(!soft(valid.to_vec()), "a valid round-trip must recover, not soft-fail"); - } } From a282c8b51dfc5d527e34685e88127b725d0f49bd Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Thu, 23 Jul 2026 11:55:29 -0400 Subject: [PATCH 16/20] Init tracing in encryption tests --- crates/cachet/tests/encrypt.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/cachet/tests/encrypt.rs b/crates/cachet/tests/encrypt.rs index 7af12805d..e01be6174 100644 --- a/crates/cachet/tests/encrypt.rs +++ b/crates/cachet/tests/encrypt.rs @@ -9,6 +9,10 @@ #![cfg(all(feature = "encrypt", feature = "serialize", feature = "test-util"))] +// Integration binaries link the library with `cfg(test)` false, so the crate-root +// tracing initialization does not run here. Install it directly. See docs/tracing-tests.md. +testing_aids::init_tracing!(); + use bytesbuf::BytesView; use cachet::{Cache, CacheEntry, CacheOp, CacheTier, MockCache, MockValueProtector}; use tick::Clock; From d95313293f2935eb27fcd31da4890b2429aaac17 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Thu, 23 Jul 2026 12:54:39 -0400 Subject: [PATCH 17/20] Ignore mutant tests in mock value protector --- Cargo.lock | 1 + crates/cachet/Cargo.toml | 1 + crates/cachet/src/transform/encrypt/mock.rs | 2 ++ 3 files changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 4454983e2..a53fb4dd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -638,6 +638,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 33e82c902..1d0fa1515 100644 --- a/crates/cachet/Cargo.toml +++ b/crates/cachet/Cargo.toml @@ -75,6 +75,7 @@ cachet_tier = { path = "../cachet_tier", features = ["test-util"] } criterion = { workspace = true } ctor = { workspace = true } dashmap = { workspace = true } +mutants = { workspace = true } postcard = { workspace = true } recoverable = { path = "../recoverable" } seatbelt = { path = "../seatbelt", features = ["retry", "tower-service"] } diff --git a/crates/cachet/src/transform/encrypt/mock.rs b/crates/cachet/src/transform/encrypt/mock.rs index f344cd5ad..e52a193ff 100644 --- a/crates/cachet/src/transform/encrypt/mock.rs +++ b/crates/cachet/src/transform/encrypt/mock.rs @@ -63,12 +63,14 @@ impl MockValueProtector { } /// 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]; From edbc6878533c3c11a1786b2458a64754fdb49978 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Thu, 23 Jul 2026 14:41:39 -0400 Subject: [PATCH 18/20] PR comment nits --- crates/cachet/src/transform/encrypt/mock.rs | 4 +++- crates/cachet/tests/encrypt.rs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/cachet/src/transform/encrypt/mock.rs b/crates/cachet/src/transform/encrypt/mock.rs index e52a193ff..279a0fef4 100644 --- a/crates/cachet/src/transform/encrypt/mock.rs +++ b/crates/cachet/src/transform/encrypt/mock.rs @@ -99,7 +99,9 @@ impl ValueProtector for MockValueProtector { let Some(nonce) = bytes.get(..MOCK_NONCE_SIZE) else { return Ok(DecodeOutcome::SoftFailure("mock: truncated nonce")); }; - let nonce: [u8; MOCK_NONCE_SIZE] = nonce.try_into().expect("MOCK_NONCE_SIZE bytes"); + let nonce: [u8; MOCK_NONCE_SIZE] = nonce + .try_into() + .expect("nonce slice is MOCK_NONCE_SIZE bytes, guarded by get(..MOCK_NONCE_SIZE) above"); let rest = &bytes[MOCK_NONCE_SIZE..]; // The caller supplies `context`, so its length is known; slice it off directly // without any stored length or arithmetic on untrusted input. diff --git a/crates/cachet/tests/encrypt.rs b/crates/cachet/tests/encrypt.rs index e01be6174..cd23e290d 100644 --- a/crates/cachet/tests/encrypt.rs +++ b/crates/cachet/tests/encrypt.rs @@ -21,7 +21,7 @@ use tick::Clock; /// 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.to_string()).expect("postcard serialization should not fail")); + out.extend_from_slice(&postcard::to_allocvec(value).expect("postcard serialization should not fail")); out } From 0e53fe795323358eb84c1233e41ab3ac3ae82503 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Thu, 23 Jul 2026 15:19:53 -0400 Subject: [PATCH 19/20] PR comment fix. Small bugfix --- crates/cachet/src/transform/encrypt/mock.rs | 50 ++++++++++++++------- crates/cachet/tests/encrypt.rs | 8 +--- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/crates/cachet/src/transform/encrypt/mock.rs b/crates/cachet/src/transform/encrypt/mock.rs index 279a0fef4..a2bfe599a 100644 --- a/crates/cachet/src/transform/encrypt/mock.rs +++ b/crates/cachet/src/transform/encrypt/mock.rs @@ -19,13 +19,15 @@ const MOCK_NONCE_SIZE: usize = 12; /// 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 || masked_body`. The nonce comes from a -/// monotonic counter (so repeated `protect` calls of identical input still differ, yet -/// stay reproducible), and `masked_body` is the plaintext combined with a nonce-derived -/// keystream via XOR. It binds the `context`: [`unprotect`](ValueProtector::unprotect) -/// returns [`DecodeOutcome::SoftFailure`] on a context mismatch, truncation, or -/// corruption, mirroring the [`ValueProtector`] security contract. On `unprotect` the -/// caller supplies the `context`, so its length is known and never stored. +/// The stored form is `nonce || context_len(8, LE) || context || masked_body`. The +/// nonce comes from a monotonic counter (so repeated `protect` calls of identical input +/// still differ, yet stay reproducible), and `masked_body` is the plaintext combined +/// with a nonce-derived keystream via XOR. It binds the `context`: +/// [`unprotect`](ValueProtector::unprotect) returns [`DecodeOutcome::SoftFailure`] +/// unless the caller's `context` matches the stored one *exactly* — same length and +/// bytes — so a value cannot be recovered under a different key, including one that is a +/// prefix or extension of the original. Truncated or corrupt input soft-fails too, +/// mirroring the [`ValueProtector`] security contract. /// /// # Security /// @@ -83,8 +85,9 @@ impl ValueProtector for MockValueProtector { use std::sync::atomic::Ordering; let nonce = Self::nonce_bytes(self.counter.fetch_add(1, Ordering::Relaxed)); - let mut out = Vec::with_capacity(MOCK_NONCE_SIZE + context.len() + plaintext.len()); + let mut out = Vec::with_capacity(MOCK_NONCE_SIZE + 8 + context.len() + plaintext.len()); out.extend_from_slice(&nonce); + out.extend_from_slice(&(context.len() as u64).to_le_bytes()); out.extend_from_slice(context); let body_start = out.len(); for (slice, _) in plaintext.slices() { @@ -103,15 +106,24 @@ impl ValueProtector for MockValueProtector { .try_into() .expect("nonce slice is MOCK_NONCE_SIZE bytes, guarded by get(..MOCK_NONCE_SIZE) above"); let rest = &bytes[MOCK_NONCE_SIZE..]; - // The caller supplies `context`, so its length is known; slice it off directly - // without any stored length or arithmetic on untrusted input. - let Some(stored_context) = rest.get(..context.len()) else { + let Some(len_bytes) = rest.get(..8) else { + return Ok(DecodeOutcome::SoftFailure("mock: truncated length")); + }; + let stored_len = u64::from_le_bytes(len_bytes.try_into().expect("8 bytes, guarded by get(..8) above")); + // The stored context length must match the caller's exactly, so a context that + // is a prefix (or extension) of the stored key is rejected outright rather than + // partially matched. Compared in u64 space to avoid any usize conversion. + if stored_len != context.len() as u64 { + return Ok(DecodeOutcome::SoftFailure("mock: context length mismatch")); + } + let after_len = &rest[8..]; + let Some(stored_context) = after_len.get(..context.len()) else { return Ok(DecodeOutcome::SoftFailure("mock: truncated context")); }; if stored_context != context { return Ok(DecodeOutcome::SoftFailure("mock: context mismatch")); } - let mut body = rest[context.len()..].to_vec(); + let mut body = after_len[context.len()..].to_vec(); Self::mask(&nonce, &mut body); Ok(DecodeOutcome::Value(BytesView::from(body))) } @@ -126,14 +138,22 @@ mod tests { } #[test] - fn mock_protector_soft_fails_on_malformed_input() { + fn mock_protector_soft_fails_on_malformed_or_mismatched_input() { let p = MockValueProtector::new(); let soft = |bytes: Vec| matches!(p.unprotect(b"context", &BytesView::from(bytes)), Ok(DecodeOutcome::SoftFailure(_))); // Too short to hold the nonce prefix. assert!(soft(vec![0u8; 4]), "truncated nonce must soft-fail"); - // Nonce present, but fewer bytes remain than the context length. - assert!(soft(vec![0u8; MOCK_NONCE_SIZE]), "truncated context must soft-fail"); + // Nonce present, but no room for the 8-byte length field. + assert!(soft(vec![0u8; MOCK_NONCE_SIZE]), "truncated length must soft-fail"); + // Length field declares a 7-byte context, but fewer context bytes follow. + let mut truncated_ctx = vec![0u8; MOCK_NONCE_SIZE]; + truncated_ctx.extend_from_slice(&7u64.to_le_bytes()); + truncated_ctx.extend_from_slice(b"abc"); + assert!(soft(truncated_ctx), "truncated context must soft-fail"); + // A key of which the read context is a strict prefix must NOT match (relocation). + let extended = p.protect(b"context-long", &view(b"value")).expect("protect should succeed"); + assert!(soft(extended.to_vec()), "prefix/length-mismatched context must soft-fail"); // A blob protected under a different (same-length) context must not match. let other = p.protect(b"kontext", &view(b"value")).expect("protect should succeed"); assert!(soft(other.to_vec()), "context mismatch must soft-fail"); diff --git a/crates/cachet/tests/encrypt.rs b/crates/cachet/tests/encrypt.rs index cd23e290d..3a9b782f1 100644 --- a/crates/cachet/tests/encrypt.rs +++ b/crates/cachet/tests/encrypt.rs @@ -180,7 +180,7 @@ async fn relocated_ciphertext_reads_as_a_miss() { .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"); + let result = cache.get("B").await.expect("get should succeed"); assert!(result.is_none(), "relocated ciphertext must not decrypt under a different key"); } @@ -214,11 +214,7 @@ async fn encrypt_chained_post_transform_fallbacks() { // 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"); + let fetched = cache.get("k").await.expect("get should succeed").expect("value present"); assert_eq!( *fetched.value(), "v", From d05a3569380bd5052bb76373ad71e44ac57395f5 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Mon, 27 Jul 2026 16:19:00 -0400 Subject: [PATCH 20/20] Doc updates for PR comment --- crates/cachet/src/telemetry/attributes.rs | 5 +++-- crates/cachet/src/telemetry/cache.rs | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/cachet/src/telemetry/attributes.rs b/crates/cachet/src/telemetry/attributes.rs index c5d8e6597..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`. diff --git a/crates/cachet/src/telemetry/cache.rs b/crates/cachet/src/telemetry/cache.rs index a1075edf5..4b3de4e86 100644 --- a/crates/cachet/src/telemetry/cache.rs +++ b/crates/cachet/src/telemetry/cache.rs @@ -380,7 +380,9 @@ impl CacheTelemetry { /// observed it. Signals a corrupt, truncated, wrong-key, tampered, or /// relocated value. The protected tier always sits on the post-transform /// (fallback) side of the hierarchy, so the event is tagged - /// `fallback = true` to match the tier's other events. + /// `fallback = true` to match the tier's other events. Like other validation + /// outcomes (e.g. `insert_rejected`), it is not a timed operation and so omits + /// `cache.duration_ns`. #[cfg(feature = "encrypt")] pub(crate) fn record_unprotect_failure(&self, cache_name: CacheName) { #[cfg(any(feature = "logs", test))]