Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e2cfa1b
Add bytes encryption layer for cachet
schgoo Jul 9, 2026
cc909f4
add decrypt failed event and some more documentation
schgoo Jul 9, 2026
3c91aa6
Fix PR comments
schgoo Jul 9, 2026
3b0e843
Make encryption a plugin. Add symcrypt option
schgoo Jul 14, 2026
9fe3bb0
Add symcrypt encryption example
schgoo Jul 14, 2026
89a1f10
Remove symcrypt built-in. No opinion on encryption crate for now, onl…
schgoo Jul 15, 2026
4baabe2
doc fix
schgoo Jul 15, 2026
0f1235e
Fix coverage job
schgoo Jul 15, 2026
1819df7
Mutation test and test fixes
schgoo Jul 15, 2026
d3e4bc4
Coverage
schgoo Jul 15, 2026
cfbe843
Merge branch 'main' into cachet_encrypt
schgoo Jul 15, 2026
42c7fa9
Potential fix for pull request finding
schgoo Jul 15, 2026
8525616
Rename ciphers to protectors, following industry conventions. Add Moc…
schgoo Jul 22, 2026
67c5d14
Merge with cachet_encrypt
schgoo Jul 22, 2026
2b1ede5
Don't use hardcoded nonce even in mock implementation
schgoo Jul 22, 2026
23fcc7b
Better fix
schgoo Jul 22, 2026
3117baa
Merge with main
schgoo Jul 22, 2026
0389c64
Reorganization
schgoo Jul 22, 2026
a282c8b
Init tracing in encryption tests
schgoo Jul 23, 2026
d953132
Ignore mutant tests in mock value protector
schgoo Jul 23, 2026
edbc687
PR comment nits
schgoo Jul 23, 2026
24dd547
Merge branch 'main' of https://github.com/microsoft/oxidizer into cac…
schgoo Jul 23, 2026
0e53fe7
PR comment fix. Small bugfix
schgoo Jul 23, 2026
9f2613e
Merge with main
schgoo Jul 24, 2026
572982c
Merge branch 'main' into cachet_encrypt
schgoo Jul 27, 2026
02108f5
Merge branch 'main' of https://github.com/microsoft/oxidizer into cac…
schgoo Jul 27, 2026
d05a356
Doc updates for PR comment
schgoo Jul 27, 2026
d383fe2
Merge branch 'cachet_encrypt' of https://github.com/microsoft/oxidize…
schgoo Jul 27, 2026
69e63be
PR comment fixes. Add test-util cache event handler
schgoo Jul 28, 2026
b19d807
Add test for fallback to second remote tier when first has soft failu…
schgoo Jul 28, 2026
53b335e
Lots of refactoring.
schgoo Aug 4, 2026
ddada8c
Some comment changes
schgoo Aug 4, 2026
ec21d73
Merge with main
schgoo Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .spelling
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
5xx
=
>=
AAD
AEAD
ABA
ABI
ACLs
Expand Down Expand Up @@ -137,6 +139,7 @@ RAII
RDME
RMW
RMWs
RNG
RPC
RSS
Rc
Expand Down Expand Up @@ -254,6 +257,7 @@ chainable
chrono
chunked
chunkless
ciphertext
clippt
clippy
clonable
Expand All @@ -277,6 +281,14 @@ covariant
coverage.json
crates.io
crypto
cryptographic
cryptographically
CryptProtectData
DPAPI
keystream
SymCrypt
undecryptable
unprotect
customizable
cutover
dSMS
Expand All @@ -290,6 +302,8 @@ decorrelate
decrement
decrementer
decrementers
decrypt
decrypts
dedup
deduplicated
deduplicating
Expand Down Expand Up @@ -345,6 +359,7 @@ freelist
freezable
frontend
fundle
GCM
gRPC
getter
getters
Expand Down Expand Up @@ -460,6 +475,8 @@ passthrough
performant
pessimizes
pointee
plaintext
pluggable
pointees
polyfill
pre-approved
Expand Down Expand Up @@ -786,6 +803,7 @@ valgrind
vendored
webhook
whitespace
keyless
Quiesce
transactional
snapshotted
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/cachet/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ test-util = ["cachet_tier/test-util", "tick/test-util"]
memory = ["dep:cachet_memory"]
service = ["dep:cachet_service", "dep:layered"]
serialize = ["dep:serde", "dep:postcard", "dep:bytesbuf"]
encrypt = ["serialize", "dep:bytesbuf"]
telemetry = []

[dependencies]
Expand Down Expand Up @@ -75,6 +76,7 @@ cachet_tier = { path = "../cachet_tier", features = ["test-util"] }
criterion = { workspace = true }
ctor = { workspace = true }
dashmap = { workspace = true }
mutants = { workspace = true }
postcard = { workspace = true }
recoverable = { path = "../recoverable" }
seatbelt = { path = "../seatbelt", features = ["retry", "tower-service"] }
Expand Down
136 changes: 125 additions & 11 deletions crates/cachet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ most commonly used types from all of them.
|`logs`|❌|Enables structured `tracing` log events for every cache operation. Subscribe via [`telemetry::attributes`][__link18] constants.|
|`service`|❌|Enables `ServiceAdapter`, `CacheServiceExt`, and `CacheOperation`/`CacheResponse` types for service middleware integration.|
|`serialize`|❌|Enables `.serialize()` on builders for automatic postcard serialization of keys and values to `BytesView`.|
|`encrypt`|❌|Enables `.protect_with(protector)` on serialized builders and the `ValueProtector` trait for authenticated value protection with a caller-supplied implementation.|
|`test-util`|❌|Enables `MockCache`, frozen-clock utilities, and other test helpers.|

## Examples
Expand Down Expand Up @@ -226,20 +227,130 @@ let cache = Cache::builder::<String, String>(clock)
cache.insert("key".to_string(), "value".to_string()).await?;
```

### Encryption Boundary

With the `encrypt` feature, chain `.protect_with(protector)` after `.serialize()` to
protect values with a caller-supplied `ValueProtector` before they reach the
fallback tier. The cachet crate ships only the protection *mechanism* — it has **no
cryptographic dependency of its own**, so you plug in a protector backed by whichever
approved cryptographic library your project mandates. The protector receives each
value’s storage key as its context and must bind it, which cryptographically binds
every value to its key. (The protect/unprotect contract mirrors OS data-protection
APIs such as the Windows DPAPI `CryptProtectData` function.)

Only values are protected: keys are left serialized-but-unprotected so they remain
deterministic and can be looked up — so do not place secrets or PII in cache keys.
A stored value that fails to unprotect (corrupt, truncated, wrong key, tampered, or
relocated to a different key) is treated as a cache miss and emits a
`cache.unprotect_failed` telemetry event.

```rust
use cachet::Cache;
use tick::Clock;

let clock = Clock::new_tokio();
let remote = Cache::builder::<bytesbuf::BytesView, bytesbuf::BytesView>(clock.clone()).memory();

let cache = Cache::builder::<String, String>(clock)
.memory()
.serialize()
.protect_with(my_protector) // any `ValueProtector` implementation
.fallback(remote)
.build();

cache.insert("key".to_string(), "value".to_string()).await?;
```

#### Example: a `SymCrypt`-backed AES-256-GCM protector

[SymCrypt][__link20] is a FIPS-certifiable,
SDL-approved cryptographic library. The following `ValueProtector` implementation
wraps it using the [`symcrypt`][__link21] crate; it stores
each value as `nonce || ciphertext || tag` with a fresh random 96-bit nonce and
binds the storage key as associated data. It is shown here as a reference rather
than shipped as a compiled feature, because `SymCrypt` requires the native library
to be present at build and run time. Add `symcrypt` and `getrandom` to your own
crate to use it.

```rust
use bytesbuf::BytesView;
use cachet::{Error, Rejection, Unprotected, ValueProtector};
use symcrypt::cipher::BlockCipherType;
use symcrypt::gcm::GcmExpandedKey;

const NONCE_SIZE: usize = 12;
const TAG_SIZE: usize = 16;

pub struct Aes256GcmProtector {
key: GcmExpandedKey,
}

impl Aes256GcmProtector {
pub fn new(key: &[u8; 32]) -> Self {
let key = GcmExpandedKey::new(key, BlockCipherType::AesBlock)
.expect("AES-256-GCM key expansion cannot fail for a valid 32-byte key");
Self { key }
}
}

impl ValueProtector for Aes256GcmProtector {
fn protect(&self, context: &[u8], plaintext: &BytesView) -> Result<BytesView, Error> {
let mut nonce = [0u8; NONCE_SIZE];
getrandom::fill(&mut nonce).map_err(|e| Error::from_message(format!("nonce: {e}")))?;

// Assemble `nonce || plaintext || tag`, copying the plaintext in once, then
// encrypt the ciphertext region in place and write the tag into the tail.
let plaintext_len = plaintext.len();
let mut result = vec![0u8; NONCE_SIZE + plaintext_len + TAG_SIZE];
result[..NONCE_SIZE].copy_from_slice(&nonce);
let mut offset = NONCE_SIZE;
for (slice, _) in plaintext.slices() {
result[offset..offset + slice.len()].copy_from_slice(slice);
offset += slice.len();
}
let (head, tag) = result.split_at_mut(NONCE_SIZE + plaintext_len);
self.key.encrypt_in_place(&nonce, context, &mut head[NONCE_SIZE..], tag);
Ok(result.into())
}

fn unprotect(&self, context: &[u8], protected: &BytesView) -> Result<Unprotected, Error> {
let bytes = protected.to_vec();
if bytes.len() < NONCE_SIZE + TAG_SIZE {
// Not even a well-formed envelope — a benign structural reject.
return Ok(Unprotected::Rejected(Rejection::Malformed));
}
let (nonce, rest) = bytes.split_at(NONCE_SIZE);
let (body, tag) = rest.split_at(rest.len() - TAG_SIZE);
let nonce: &[u8; NONCE_SIZE] = nonce.try_into().expect("exactly 12 bytes");

let mut buffer = body.to_vec();
match self.key.decrypt_in_place(nonce, context, &mut buffer, tag) {
// Authenticated: recovered. A tag failure (tampering, wrong key,
// relocation) reads as an authentication failure.
Ok(()) => Ok(Unprotected::Recovered(buffer.into())),
Err(_) => Ok(Unprotected::Rejected(Rejection::AuthenticationFailed)),
}
}
}
```

Because each protect uses a fresh random 96-bit nonce, rotate the key periodically
under extreme write volumes to stay well within the birthday bound.

## Telemetry

Cachet provides two complementary telemetry channels:

### 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
Expand All @@ -260,15 +371,16 @@ See the `telemetry_subscriber` example for a complete demonstration.
|Level|Events|
|-----|------|
|ERROR|`cache.get_error`, `cache.insert_error`, `cache.invalidate_error`, `cache.clear_error`|
|WARN|`cache.unprotect_failed` (with the `encrypt` feature)|
|INFO|`cache.expired`, `cache.refresh_miss`, `cache.inserted`, `cache.insert_rejected`, `cache.invalidated`, `cache.eviction`|
|DEBUG|`cache.hit`, `cache.miss`, `cache.refresh_hit`, `cache.cleared`|

### Event handler callback API

Register a [`CacheEventHandler`][__link22] via
Register a [`CacheEventHandler`][__link24] via
`.event_handler(handler)` on the cache builder to receive typed
[`CacheTierEvent`][__link23] and
[`CacheOperationEvent`][__link24] callbacks.
[`CacheTierEvent`][__link25] and
[`CacheOperationEvent`][__link26] callbacks.
Events carry a `request_id` for correlating tier outcomes with their parent
operation. Works independently of the `logs` feature.

Expand All @@ -280,7 +392,7 @@ See the `telemetry_accumulator` example for a DashMap-based accumulation pattern
This crate was developed as part of <a href="https://github.com/microsoft/oxidizer">The Oxidizer Project</a>. Browse this crate's <a href="https://github.com/microsoft/oxidizer/tree/main/crates/cachet">source code</a>.
</sub>

[__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQb11VxC_uAPOQbtUn4Wx2-BfAbid3Nt1Y27Pobprn8Z6FjFy9hYvRhcoQb_xlIDv3a6WgboIYzdhk5tYwbm8NaNvZXwrcbhIXs0eaeycFhZIiCaGJ5dGVzYnVmZTAuNy4wgmZjYWNoZXRmMC4xMC4wgm1jYWNoZXRfbWVtb3J5ZTAuNS4xgm5jYWNoZXRfc2VydmljZWUwLjIuOYJrY2FjaGV0X3RpZXJlMC4yLjeCZHRpY2tlMC40LjCCZ3RyYWNpbmdmMC4xLjQ0gml1bmlmbGlnaHRlMC4zLjA
[__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQb11VxC_uAPOQbtUn4Wx2-BfAbid3Nt1Y27Pobprn8Z6FjFy9hYvRhcoQbEu82gxmuwisbbRVw3TEnX4YbwFMVyLfb6D0btdKyrvo5pT5hZIiCaGJ5dGVzYnVmZTAuNy4wgmZjYWNoZXRmMC4xMC4wgm1jYWNoZXRfbWVtb3J5ZTAuNS4xgm5jYWNoZXRfc2VydmljZWUwLjIuOYJrY2FjaGV0X3RpZXJlMC4yLjeCZHRpY2tlMC40LjCCZ3RyYWNpbmdmMC4xLjQ0gml1bmlmbGlnaHRlMC4zLjA
[__link0]: https://docs.rs/cachet/0.10.0/cachet/?search=TimeToRefresh
[__link1]: https://crates.io/crates/uniflight/0.3.0
[__link10]: https://docs.rs/cachet_tier/0.2.7/cachet_tier/?search=CacheTier
Expand All @@ -294,11 +406,13 @@ This crate was developed as part of <a href="https://github.com/microsoft/oxidiz
[__link18]: https://docs.rs/cachet/0.10.0/cachet/?search=telemetry::attributes
[__link19]: https://docs.rs/bytesbuf/0.7.0/bytesbuf/?search=BytesView
[__link2]: https://docs.rs/cachet/0.10.0/cachet/?search=CacheBuilder::stampede_protection
[__link20]: https://crates.io/crates/tracing/0.1.44
[__link21]: https://docs.rs/cachet/0.10.0/cachet/?search=telemetry::attributes
[__link22]: https://docs.rs/cachet/0.10.0/cachet/?search=telemetry::handler::CacheEventHandler
[__link23]: https://docs.rs/cachet/0.10.0/cachet/?search=telemetry::handler::CacheTierEvent
[__link24]: https://docs.rs/cachet/0.10.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.10.0/cachet/?search=telemetry::attributes
[__link24]: https://docs.rs/cachet/0.10.0/cachet/?search=telemetry::handler::CacheEventHandler
[__link25]: https://docs.rs/cachet/0.10.0/cachet/?search=telemetry::handler::CacheTierEvent
[__link26]: https://docs.rs/cachet/0.10.0/cachet/?search=telemetry::handler::CacheOperationEvent
[__link3]: https://docs.rs/cachet_tier/0.2.7/cachet_tier/?search=CacheTier
[__link4]: https://docs.rs/cachet_tier/0.2.7/cachet_tier/?search=DynamicCache
[__link5]: https://docs.rs/cachet/0.10.0/cachet/?search=InsertPolicy
Expand Down
51 changes: 51 additions & 0 deletions crates/cachet/src/builder/encrypt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//! The `.protect_with()` pipeline stage for the serialization builder.
//!
//! `.protect_with(protector)` is available on a [`SerializeBuilder`] that has not yet
//! been protected. It appends an authenticated-protection stage to the value pipeline
//! and flips the builder's `PROTECTED` type-state to `true`, so the method disappears
//! and protection cannot be configured twice.

use std::marker::PhantomData;
use std::sync::Arc;

use super::serialize::SerializeBuilder;
use crate::ValueProtector;
use crate::transform::ProtectorCodec;

impl<K, V, Pre> SerializeBuilder<K, V, Pre, false> {
/// Protects values with the given [`ValueProtector`] before they reach any storage
/// tier, binding each to its storage key.
///
/// Available after [`serialize`](crate::CacheBuilder::serialize), and only if
/// protection isn't already configured. The protector — backed by your approved
/// cryptographic library — receives the storage key as context and must bind it (see
/// the [`ValueProtector`] contract). Keys are never protected; a value that fails
/// authentication reads as a miss, so the fallback chain continues to the next tier.
///
/// # Examples
///
/// ```ignore
/// let cache = Cache::builder::<String, String>(clock)
/// .memory()
/// .serialize()
/// .protect_with(my_protector) // any `ValueProtector` implementation
/// .fallback(remote)
/// .build();
/// ```
#[must_use]
pub fn protect_with(self, protector: impl ValueProtector + 'static) -> SerializeBuilder<K, V, Pre, true> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new per-access protection stage has no benchmark or allocation guard — Performance · Low · Structural

Every insert reaching a protected fallback and every protected fallback hit executes serialization, context construction, an additional dynamic codec stage, and the caller's protector. The existing cachet benchmarks cover plain wrapper/fallback operations but no serialize/protect path, so this hot-path cost and its allocations per operation can regress without evidence. No magnitude is claimed here; the path frequency is one protection call per affected remote-tier access. This partially misses Pragmatic Rust guideline M-HOTPATH, which requires identifying, profiling, and optimizing the hot path early.

Direction: Add paired benchmarks for protected insert and fallback hit across representative payload sizes and single-/multi-span keys, recording wall time, instruction count, and allocations per operation against an unprotected serialization baseline.

Done when: The path has a reproducible baseline and regression gate, and any optimization is resolved by measurement—landed with evidence or closed as not worthwhile.

let protect = ProtectorCodec::new(Arc::new(protector), self.telemetry.clone());
SerializeBuilder {
pre: self.pre,
pool: self.pool,
protect: Some(Box::new(protect)),
clock: self.clock,
telemetry: self.telemetry,
stampede_protection: self.stampede_protection,
_phantom: PhantomData,
}
}
}
4 changes: 4 additions & 0 deletions crates/cachet/src/builder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

mod buildable;
mod cache;
#[cfg(feature = "encrypt")]
mod encrypt;
mod fallback;
mod sealed;
#[cfg(any(feature = "serialize", test))]
Expand All @@ -17,4 +19,6 @@ mod transform;
pub use cache::CacheBuilder;
pub use fallback::FallbackBuilder;
pub use sealed::CacheTierBuilder;
#[cfg(any(feature = "serialize", test))]
pub use serialize::SerializeBuilder;
pub use transform::TransformBuilder;
Loading
Loading