Skip to content

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

Open
schgoo wants to merge 1 commit into
mainfrom
cachet_encrypt
Open

feat(cachet): add encrypt feature for authenticated value encryption#558
schgoo wants to merge 1 commit 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 encrypts cache values
with AES-256-GCM before they reach a fallback/remote tier. Values are
cryptographically bound to their storage key, so a value cannot be read back
under a different key. Enabled via a single builder method:

let cache = Cache::builder::<String, String>(clock)
    .memory()
    .serialize()
    .encrypt(&key)          // &[u8; 32]
    .fallback(remote)
    .build();

Motivation

When a cache tiers down to an untrusted store (Redis, S3, etc.), values are
exposed at rest. This feature lets callers keep the ergonomic typed cache API
while transparently encrypting values on the way out and decrypting them on the
way back, without hand-rolling a serialization/encryption pipeline.

What it does

  • .encrypt(&key) — available after .serialize() (once values are
    BytesView). Encrypts each value with AES-256-GCM using a fresh random nonce.
  • Key binding — the storage key is authenticated as GCM associated data
    (AAD), so a ciphertext produced for one key fails to decrypt under any other
    key. This prevents an attacker with write access to the backing store from
    relocating or swapping ciphertexts between keys.
  • Keys are not encrypted — AES-GCM output is non-deterministic, so keys are
    left serialized-but-unencrypted to remain deterministic and lookupable.
  • Undecryptable 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 recomputed, consistent with the existing serialization
    codec's soft-failure behavior.

Design

  • Encryption is applied by an internal EncryptedTier installed at the storage
    boundary, where both the key and value are in scope — this is what makes
    key-as-AAD binding possible.
  • .encrypt() returns a dedicated EncryptedTransformBuilder (storage types
    fixed to BytesView), which supports .fallback() and .build(), mirroring
    TransformBuilder. It lives in its own builder/encrypt.rs, matching the
    serialize feature's file layout.
  • An internal AeadCipher trait abstracts the cipher (Aes256GcmCipher is the
    only implementation). It is intentionally crate-private for now; the public
    surface is just .encrypt(&key). The seam can be exposed later without a
    breaking change if pluggable ciphers are needed.
  • Encryption/decryption gather the multi-span BytesView into a contiguous
    buffer exactly once per direction (encrypt-in-place with a detached tag on the
    way out; borrow-or-gather on the way in), avoiding redundant copies.

Dependencies

Adds aes-gcm and getrandom to the workspace, pulled in only under the
optional encrypt feature. Licenses verified via cargo deny check licenses.

Testing

  • Unit tests for the cipher and tier: round-trip, fresh-nonce-per-encrypt,
    wrong-key / wrong-AAD / tampered / too-short soft-failures, multi-span
    plaintext and ciphertext handling, and that Debug never renders key material.
  • Integration tests for the full .serialize().encrypt().fallback() pipeline:
    stored bytes are ciphertext (not plaintext), round-trips through the encrypted
    tier, fresh nonce per insert, reachable on a FallbackBuilder, and a
    ciphertext relocated to a different key reads as a miss.

Notes / follow-ups (not in this PR)

  • Values that fail decryption currently surface as a silent miss with no
    telemetry; emitting an event on decrypt soft-failure (to make tampering
    observable) is a possible follow-up.
  • Docs could add guidance on key rotation for very high write volumes (96-bit
    random-nonce birthday bound) and an explicit note that cache keys are stored in
    plaintext, so secrets/PII should not be placed in keys.

Copilot AI 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 on lines +29 to +36
pub(crate) pre: Pre,
pub(crate) post: Post,
pub(crate) key_encoder: Box<dyn Encoder<K, KT>>,
pub(crate) value_codec: Box<dyn Codec<V, VT>>,
pub(crate) clock: Clock,
pub(crate) telemetry: CacheTelemetry,
pub(crate) stampede_protection: bool,
pub(crate) _phantom: PhantomData<(K, V, KT, VT)>,
Comment on lines +197 to +200
// 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) => {
Comment on lines +214 to +218
async fn insert(&self, key: BytesView, entry: CacheEntry<BytesView>) -> 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
}
@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.54982% with 50 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.8%. Comparing base (3c61157) to head (e2cfa1b).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
crates/cachet/src/builder/encrypt.rs 57.3% 35 Missing ⚠️
crates/cachet/src/transform/encrypt.rs 92.0% 15 Missing ⚠️

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

Additional details and impacted files
@@           Coverage Diff            @@
##             main    #558     +/-   ##
========================================
- Coverage   100.0%   99.8%   -0.2%     
========================================
  Files         356     358      +2     
  Lines       26966   27237    +271     
========================================
+ Hits        26966   27187    +221     
- Misses          0      50     +50     

☔ 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.


use std::borrow::Cow;

use aes_gcm::aead::{Aead, AeadInPlace, KeyInit, Payload};

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.

From a compliance perspective, should this code really come from that crate? Would love to hear Sergey's input here.

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.

@ralfbiedert 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)

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.

3 participants