Skip to content

feat(cachet): add encrypt feature for authenticated value encryption - #558

Open
schgoo wants to merge 33 commits into
mainfrom
cachet_encrypt
Open

feat(cachet): add encrypt feature for authenticated value encryption#558
schgoo wants to merge 33 commits into
mainfrom
cachet_encrypt

Conversation

@schgoo

@schgoo schgoo commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an optional encrypt feature to cachet that protects cache values
before they reach a fallback/remote tier, binding each value to its storage key so
it cannot be read back under a different key.

The feature ships only the protection mechanism and carries no cryptographic
dependency of its own
— callers plug in a protector backed by their approved
cryptographic library via .protect_with(protector):

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

Motivation

When a cache tiers down to an untrusted store (Redis, S3, etc.), values are
exposed at rest. This feature keeps the ergonomic typed cache API while
transparently protecting values on the way out and recovering them on the way
back, with no hand-rolled serialization/encryption pipeline. Shipping the
mechanism without any bundled crypto lets each consumer satisfy its own
cryptographic-library compliance requirements and keeps the crate dependency-free
and portable across all CI targets.

What it does

  • .protect_with(protector) — available after .serialize() (once values are
    BytesView). Protects each value with a caller-supplied ValueProtector.
  • ValueProtector trait — the pluggable protect(context, plaintext) /
    unprotect(context, protected) contract. The verb pair mirrors OS
    data-protection APIs (Windows DPAPI CryptProtectData, .NET IDataProtector);
    context is the AEAD-associated-data / DPAPI-entropy role.
  • Key binding — the storage key is passed as the context and must be bound,
    so a value protected for one key fails to recover under any other key. This
    prevents an attacker with write access to the backing store from relocating or
    swapping values between keys.
  • Keys are not protected — they stay serialized-but-plaintext to remain
    deterministic and lookupable, so secrets/PII must not be placed in cache keys.
  • Unrecoverable entries read as a miss — a value that fails authentication
    (corrupt, truncated, wrong key, tampered, or relocated) is treated as a cache
    miss (Ok(None)) and emits a cache.unprotect_failed telemetry event (tagged
    fallback = true, since the protected tier always sits on the fallback side) so
    tampering is observable, consistent with the serialization codec's soft-failure
    behavior.

Design

  • Protection is applied by an internal ProtectedTier installed at the storage
    boundary, where both the key and value are in scope — this is what makes the
    key-as-context binding possible.
  • .protect_with() returns a dedicated ProtectedTransformBuilder (storage types
    fixed to BytesView) supporting .fallback() and .build(), mirroring
    TransformBuilder. It lives in its own builder/encrypt.rs, matching the
    serialize feature's file layout.
  • The public ValueProtector trait is the pluggable seam; the crate provides no
    cryptographic implementation. A complete reference ValueProtector backed by
    SymCrypt (FIPS-certifiable AES-256-GCM) is documented in the crate-level docs as
    a copy-paste example, since SymCrypt requires a native library at build/run time
    and pulling it into the workspace would force it onto all --all-features CI.
  • Naming follows a capability-vs-mechanism split: the encrypt feature names
    the capability (discoverable; avoids overloading the existing
    stampede_protection), while the API speaks protect/unprotect — the same way
    .NET's DataProtection package exposes an IDataProtector.Protect method.

Testability

Per the Microsoft Pragmatic Rust Guidelines (M-MOCKABLE-SYSCALLS,
M-DESIGN-FOR-AI, M-TEST-UTIL), the crate abstracts the non-deterministic
crypto/entropy work behind the ValueProtector seam and ships a mock so
downstream consumers can test their protected-cache pipelines without a crypto
dependency:

  • MockValueProtector — a deterministic, crypto-free ValueProtector gated
    behind test-util and re-exported at the crate root next to MockCache. It
    binds context and soft-fails on mismatch/corruption, but provides no
    confidentiality
    (documented as test-only).

Dependencies

None. The encrypt feature adds no cryptographic dependency; consumers bring
their own approved library.

Testing

  • Unit tests for the ProtectedTier mechanism and the MockValueProtector.
  • Integration tests drive the full .serialize().protect_with().fallback()
    pipeline via MockValueProtector: stored bytes are ciphertext with the
    plaintext never appearing verbatim, values round-trip through the protected tier,
    a fresh nonce is used per insert, the boundary is reachable on a FallbackBuilder
    and through chained post-transform fallbacks, and a value relocated to a
    different key reads as a miss.
  • 100% line and region coverage on the added transform/encrypt.rs and
    builder/encrypt.rs.

Copilot AI lite review requested due to automatic review settings July 9, 2026 18:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an optional encrypt feature to cachet that introduces an authenticated encryption boundary for cache values (AES-256-GCM) before data reaches an untrusted fallback tier, binding ciphertext to the storage key via GCM AAD.

Changes:

  • Introduces AeadCipher/Aes256GcmCipher and an EncryptedTier that encrypts values and treats undecryptable entries as cache misses.
  • Adds .encrypt(&[u8; 32]) to the serialized builder pipeline via EncryptedTransformBuilder, supporting fallback chaining and build().
  • Adds unit + integration tests, docs/README updates, and feature-gated dependencies (aes-gcm, getrandom).

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
crates/cachet/tests/encrypt.rs Integration tests for .serialize().encrypt().fallback() behavior and key-binding relocation defense.
crates/cachet/src/transform/mod.rs Wires in the encrypt transform module behind the encrypt feature gate.
crates/cachet/src/transform/encrypt.rs Implements AES-256-GCM cipher + EncryptedTier wrapper for value encryption/decryption at the storage boundary.
crates/cachet/src/lib.rs Documents the new encrypt feature and re-exports EncryptedTransformBuilder when enabled.
crates/cachet/src/builder/transform.rs Adjusts TransformBuilder field visibility to enable the .encrypt() builder transition.
crates/cachet/src/builder/mod.rs Adds the encrypt builder module and exports EncryptedTransformBuilder behind the feature.
crates/cachet/src/builder/encrypt.rs Implements .encrypt(&key) and the EncryptedTransformBuilder fallback/build pipeline.
crates/cachet/README.md Regenerated README content to document the new feature.
crates/cachet/Cargo.toml Adds the encrypt feature and its optional deps.
Cargo.toml Adds workspace dependency entries for aes-gcm and getrandom.
Cargo.lock Locks new crypto/randomness transitive dependencies.
.spelling Adds crypto-related terminology used by new docs/tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/cachet/src/builder/transform.rs Outdated
Comment thread crates/cachet/src/transform/encrypt.rs Outdated
Comment thread crates/cachet/src/transform/encrypt/tier.rs Outdated
@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.0%. Comparing base (d799037) to head (ec21d73).
⚠️ Report is 18 commits behind head on main.

❌ Your project check has failed because the head coverage (64.0%) is below the target coverage (100.0%). You can increase the head coverage or adjust the target coverage.

❗ There is a different number of reports uploaded between BASE (d799037) and HEAD (ec21d73). Click for more details.

HEAD has 18 uploads less than BASE
Flag BASE (d799037) HEAD (ec21d73)
3 0
linux-arm 3 1
scheduled 9 0
linux 3 1
windows 3 1
Additional details and impacted files
@@            Coverage Diff            @@
##             main    #558      +/-   ##
=========================================
- Coverage   100.0%   64.0%   -36.0%     
=========================================
  Files         473     115     -358     
  Lines       45493    7475   -38018     
=========================================
- Hits        45493    4791   -40702     
- Misses          0    2684    +2684     
Flag Coverage Δ
linux 64.5% <100.0%> (-35.5%) ⬇️
linux-arm 65.2% <100.0%> (-34.8%) ⬇️
scheduled ?
windows 65.6% <100.0%> (-34.4%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread crates/cachet/src/transform/encrypt.rs Outdated

@ralfbiedert Ralf Biedert (ralfbiedert) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(Adding a blocker for now until we know more about that)

Copilot AI review requested due to automatic review settings July 14, 2026 16:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 16 changed files in this pull request and generated 2 comments.

Comment thread crates/cachet/src/transform/symcrypt_cipher.rs Outdated
Comment thread crates/cachet/Cargo.toml Outdated
Copilot AI review requested due to automatic review settings July 15, 2026 16:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Comment thread crates/cachet/src/builder/encrypt.rs Outdated
Comment thread crates/cachet/src/telemetry/cache.rs Outdated
Comment thread crates/cachet/tests/encrypt.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment thread crates/cachet/src/telemetry/cache.rs
Comment thread crates/cachet/src/builder/encrypt.rs Outdated
Copilot AI review requested due to automatic review settings July 15, 2026 18:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Comment thread crates/cachet/src/builder/encrypt.rs Outdated
Copilot AI review requested due to automatic review settings July 24, 2026 14:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 27, 2026 15:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 16 changed files in this pull request and generated 1 comment.

Comment on lines +386 to +389
#[cfg(any(feature = "logs", test))]
if self.logging_enabled {
tracing::warn!(cache.name = cache_name, cache.event = attributes::EVENT_UNPROTECT_FAILED);
}
Copilot AI review requested due to automatic review settings July 27, 2026 20:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

crates/cachet/src/transform/encrypt/mock.rs:36

  • MockValueProtector is documented as being available/gated only behind test-util, but it also requires the encrypt feature (the whole module is #[cfg(feature = "encrypt")]). This can mislead consumers about which features they need to enable.
/// Available with the `test-util` feature. Use it to exercise a
/// [`protect_with`](crate::TransformBuilder::protect_with) pipeline — round-trips, key
/// binding, and unprotect failures — without a real cryptographic library or a source
/// of entropy, keeping tests fast and reproducible.

Comment thread crates/cachet/src/builder/encrypt.rs Outdated
// Build the post-transform tier chain and wrap it so values are protected
// (and key-bound) before reaching it.
let post_tier = self.post.build_tier(clock.clone(), telemetry.clone(), true);
let protected = ProtectedTier::new(

@martin-kolinek martin-kolinek Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖: Wrapping the already-built post_tier in a single ProtectedTier means unprotect soft-failures happen outside the post chain's own FallbackCache. With chained post tiers, if the first post tier returns tampered/corrupt bytes that inner fallback treats it as a hit and never consults the next post tier; only afterward does the outer ProtectedTier turn the result into None. That breaks the promised "soft failure = miss, fall through" behavior for chained post tiers. Consider applying the protection decorator to each post storage tier before composing the fallback chain (e.g. FallbackCache<ProtectedTier<L2>, ProtectedTier<L3>>, sharing the protector via Arc), or add validated-hit handling that lets the chain continue after an unprotect soft-failure. Worth covering with an integration test that nests an unbuilt ProtectedTransformBuilder as a .fallback(...) across multiple post tiers and forces a tampered read on the first store — the current chained-fallback test only adds tiers inside one protected builder, so it would not catch this.

Comment thread crates/cachet/src/builder/encrypt.rs Outdated
/// chain is wrapped in an internal `ProtectedTier`, which protects values and binds
/// each value to its storage key. Add post tiers with [`fallback`](Self::fallback) and
/// finish with [`build`](Self::build), exactly as with `TransformBuilder`.
pub struct ProtectedTransformBuilder<K, V, Pre, Post = ()> {

@martin-kolinek martin-kolinek Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖: Design/maintainability: ProtectedTransformBuilder re-implements almost the entire TransformBuilder surface (both fallback impls, Sealed, CacheTierBuilder, Buildable, build, Debug, and the field set). After .protect_with, the storage types are already fixed to BytesView, BytesView and TransformBuilder::fallback already constrains post tiers to that same pair, so the parallel builder buys little extra type-state safety — it mainly carries the Box<dyn ValueProtector>. Consider instead giving TransformBuilder a feature-gated Option<Box<dyn ValueProtector>> field that .protect_with sets, and branching in build_tier to wrap the post tier in ProtectedTier when present. That removes ~200 lines of parallel surface that otherwise must be kept in lockstep as fallback/refresh/stampede wiring evolves. Tradeoff: it threads an optional crypto concern through the general builder and adds some cfg-gated field noise — a maintainability-vs-clarity call for a maintainer, not a correctness issue.

pub(crate) fn record_unprotect_failure(&self, cache_name: CacheName) {
#[cfg(any(feature = "logs", test))]
if self.logging_enabled {
tracing::warn!(cache.name = cache_name, cache.event = attributes::EVENT_UNPROTECT_FAILED);

@martin-kolinek martin-kolinek Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖: This emits cache.unprotect_failed at WARN, but the public event-level table in src/lib.rs (the ### Event types table, ~lines 370-374) has only ERROR/INFO/DEBUG rows — no WARN row — and omits this stable event, and its generated README.md mirror is likewise missing it. Please add a WARN row for cache.unprotect_failed to the table and regenerate the README so the documented telemetry inventory matches what this feature emits.

tracing::warn!(cache.name = cache_name, cache.event = attributes::EVENT_UNPROTECT_FAILED);
}

self.emit_tier_event(

@martin-kolinek martin-kolinek Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖: Consider a builder-level test that drives an unprotect soft-failure (malformed or relocated ciphertext) through a CacheEventHandler and asserts the cache.unprotect_failed tier callback carries fallback = true and the same nonzero request ID as the completed get. The existing focused test only greps tracing text for the event name, so regressions in the public structured telemetry fields / request-id correlation would still pass.

Copilot AI review requested due to automatic review settings August 4, 2026 16:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 23 out of 24 changed files in this pull request and generated 2 comments.

Suppressed comments (2)

crates/cachet/src/builder/encrypt.rs:20

  • The docs say protection happens "before they reach any storage tier", but .protect_with() is configured on a SerializeBuilder and only applies to the post-serialize() fallback tier(s); the pre-serialize tier still stores plaintext values.
    /// Protects values with the given [`ValueProtector`] before they reach any storage
    /// tier, binding each to its storage key.

crates/cachet/src/transform/encrypt/codec.rs:47

  • record_unprotect_failure is being emitted with std::any::type_name::<Self>() as the tier name, so the cache.name / tier_name field won’t identify the actual cache tier that observed the authentication failure. This makes the new cache.unprotect_failed signal harder to attribute in logs/handlers when multiple tiers are present.
            Unprotected::Rejected(Rejection::AuthenticationFailed) => {
                self.telemetry.record_unprotect_failure(std::any::type_name::<Self>());
                Ok(DecodeOutcome::SoftFailure)

Comment thread crates/cachet/src/transform/encrypt/mock.rs
Comment thread crates/cachet/src/transform/codec.rs
Copilot AI review requested due to automatic review settings August 4, 2026 17:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 23 out of 24 changed files in this pull request and generated no new comments.

Suppressed comments (2)

crates/cachet/src/builder/encrypt.rs:20

  • The doc comment says values are protected “before they reach any storage tier”, but .protect_with() only applies to the next byte-speaking fallback tier introduced by this serialization boundary (the pre-serialize tier remains unprotected). Wording this as “any storage tier” is misleading for users building multi-tier caches.
    /// Protects values with the given [`ValueProtector`] before they reach any storage
    /// tier, binding each to its storage key.

crates/cachet/src/transform/encrypt/codec.rs:47

  • record_unprotect_failure is emitted as a tier event, but the tier_name being recorded is type_name::<ProtectorCodec>() rather than the actual cache tier name (e.g. "l2"). This makes it hard to attribute unprotect failures to a specific tier and becomes ambiguous when multiple protected tiers exist in a fallback chain.
            Unprotected::Rejected(Rejection::AuthenticationFailed) => {
                self.telemetry.record_unprotect_failure(std::any::type_name::<Self>());
                Ok(DecodeOutcome::SoftFailure)

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

⚠️ Potential breaking changes detected

cargo semver-checks flagged the following on this PR. This is informational -- breaking changes between commits are expected; the major-version bump happens at release time, not on every PR.

cachet

     Cloning origin/main
    Building cachet v0.10.0 (current)
       Built [  11.705s] (current)
     Parsing cachet v0.10.0 (current)
      Parsed [   0.015s] (current)
    Building cachet v0.10.0 (baseline)
       Built [  12.015s] (baseline)
     Parsing cachet v0.10.0 (baseline)
      Parsed [   0.012s] (baseline)
    Checking cachet v0.10.0 -> v0.10.0 (no change; assume minor)

     Checked [   0.021s] 196 checks: 189 pass, 7 fail, 0 warn, 49 skip
--- failure enum_tuple_variant_changed_kind: An enum tuple variant changed kind ---

Description:
A public enum's exhaustive tuple variant has changed to a different kind of enum variant, breaking possible instantiations and patterns.
        ref: https://doc.rust-lang.org/reference/items/enumerations.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/enum_tuple_variant_changed_kind.ron

Failed in:
  variant DecodeOutcome::SoftFailure in /home/runner/work/oxidizer/oxidizer/crates/cachet/src/transform/codec.rs:118

--- failure inherent_method_missing: pub method removed or renamed ---

Description:
A publicly-visible method or associated fn is no longer available under its prior name. It may have been renamed or removed entirely.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#item-remove
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/inherent_method_missing.ron

Failed in:
  TransformBuilder::build, previously in file /home/runner/work/oxidizer/oxidizer/target/semver-checks/git-origin_main/491b4a939cbd3b50adf8144e425c7d843b6541f9/crates/cachet/src/builder/transform.rs:226

--- failure trait_added_supertrait: non-sealed trait added new supertraits ---

Description:
A non-sealed trait added one or more supertraits, which breaks downstream implementations of the trait
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#generic-bounds-tighten
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/trait_added_supertrait.ron

Failed in:
  trait cachet::Codec gained Send in file /home/runner/work/oxidizer/oxidizer/crates/cachet/src/transform/codec.rs:130
  trait cachet::Codec gained Sync in file /home/runner/work/oxidizer/oxidizer/crates/cachet/src/transform/codec.rs:130

--- failure trait_method_added: pub trait method added ---

Description:
A non-sealed public trait added a new method without a default implementation, which breaks downstream implementations of the trait
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#trait-new-item-no-default
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/trait_method_added.ron

Failed in:
  trait method cachet::Codec::encode in file /home/runner/work/oxidizer/oxidizer/crates/cachet/src/transform/codec.rs:136

--- failure trait_method_parameter_count_changed: pub trait method parameter count changed ---

Description:
A trait method now takes a different number of parameters.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#major-any-change-to-trait-item-signatures
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/trait_method_parameter_count_changed.ron

Failed in:
  Codec::decode now takes 2 instead of 1 parameters, in file /home/runner/work/oxidizer/oxidizer/crates/cachet/src/transform/codec.rs:149

--- failure trait_removed_supertrait: supertrait removed or renamed ---

Description:
A supertrait was removed from a trait. Users of the trait can no longer assume it can also be used like its supertrait.
        ref: https://doc.rust-lang.org/reference/items/traits.html#supertraits
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/trait_removed_supertrait.ron

Failed in:
  supertrait cachet::Encoder of trait Codec in file /home/runner/work/oxidizer/oxidizer/crates/cachet/src/transform/codec.rs:130

--- failure type_allows_fewer_generic_type_params: type now allows fewer generic type parameters ---

Description:
A type now allows fewer generic type parameters than it used to. Uses of this type that supplied all previously-supported generic types will be broken.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#trait-new-parameter-no-default
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.46.0/src/lints/type_allows_fewer_generic_type_params.ron

Failed in:
  Struct TransformBuilder allows 6 -> 5 generic types in /home/runner/work/oxidizer/oxidizer/crates/cachet/src/builder/transform.rs:41

     Summary semver requires new major version: 7 major and 0 minor checks failed
    Finished [  24.955s] cachet

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants