From e2cfa1be213bac15b75ce0f7197c89f4cc1d1f89 Mon Sep 17 00:00:00 2001 From: Schuyler Goodman Date: Thu, 9 Jul 2026 14:21:29 -0400 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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