Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@
"filename": "src/cachekit/cache_handler.py",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 427
"line_number": 430
}
],
"src/cachekit/config/decorator.py": [
Expand Down Expand Up @@ -887,5 +887,5 @@
}
]
},
"generated_at": "2026-08-04T22:16:14Z"
"generated_at": "2026-08-07T16:45:43Z"
}
5 changes: 3 additions & 2 deletions Cargo.lock

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

6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,12 @@ def get_patient_data(hospital_id: int):
> [!CAUTION]
> When handling PII, medical, or financial data, always use `@cache.secure` to enforce encryption.

**Zero-downtime key rotation**: promote a new `CACHEKIT_MASTER_KEY` and keep the
retiring key readable via `CACHEKIT_PREVIOUS_MASTER_KEYS` (comma-separated hex,
max 3 decrypt-only keys). Entries are selected by exact key fingerprint — never
trial decryption — and old entries age out via TTL, no cache flush required. See
[Zero-Knowledge Encryption](docs/features/zero-knowledge-encryption.md#key-rotation-pattern).

cachekit employs comprehensive security tooling:

- **Supply Chain Security**: cargo-deny for license compliance + RustSec scanning
Expand Down
6 changes: 6 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ CACHEKIT_ARROW_COMPRESSION=zstd

# Encryption (for @cache.secure)
CACHEKIT_MASTER_KEY=<hex-encoded-key-32-bytes-minimum>
# Key rotation: decrypt-only previous master keys (comma-separated hex, max 3,
# same per-key requirements as CACHEKIT_MASTER_KEY). Entries written under a
Comment thread
27Bslash6 marked this conversation as resolved.
# listed key stay readable through the rotation window; writes always use
# CACHEKIT_MASTER_KEY. More than 3 keys, or the current master key re-appearing
# in this list, is rejected at load.
CACHEKIT_PREVIOUS_MASTER_KEYS=<old-key-hex>,<older-key-hex>
# Fail closed on decrypt authentication failures (default: false = fail open/recompute).
# When true, AES-GCM auth failures and key-fingerprint mismatches raise
# DecryptionAuthenticationError to the caller instead of silently recomputing.
Expand Down
75 changes: 53 additions & 22 deletions docs/features/zero-knowledge-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,17 @@ export CACHEKIT_MASTER_KEY=$(openssl rand -hex 32)
### Key Rotation

```bash
# Changed CACHEKIT_MASTER_KEY
# Changed CACHEKIT_MASTER_KEY without retaining the old key
# Old encrypted data in Redis → Can't decrypt
# Error: "Decryption failed: authentication tag verification failed"
# Solution: Clear cache before rotating keys
redis-cli FLUSHDB # Clear Redis
export CACHEKIT_MASTER_KEY=new_key
# Restart app → re-populates cache with new key
# Solution: keep the retiring key decrypt-only for the rotation window
export CACHEKIT_MASTER_KEY=new_key # encrypts + decrypts
export CACHEKIT_PREVIOUS_MASTER_KEYS=old_key # decrypt-only (comma-separated, max 3)
# Restart app → old entries stay readable, new writes use the new key.
# Old-key entries age out via TTL; drop the old key from the list once the
# window (≥ longest TTL in use) has passed. Rotation is forward-only: never
# re-promote a retired key to CACHEKIT_MASTER_KEY — a configuration where the
# current key also appears in the previous-keys list is rejected at load.
```

### Enabling Encryption on an Existing (Plaintext) Cache
Expand Down Expand Up @@ -281,19 +285,41 @@ data_b = get_user_data(123) # Same user_id, different tenant, different encrypt
```

### Key Rotation Pattern
```python notest
# Gradual key rotation (for zero-downtime)
@cache.secure(ttl=3600, master_key="a" * 64, backend=None)
def get_data(x):
return sensitive_data(x) # illustrative - sensitive_data not defined

# 1. Add new key to CACHEKIT_MASTER_KEY_ROTATION
# 2. Old key still decrypts old data
# 3. New data encrypted with new key
# 4. Eventually old data expires from cache
# 5. Remove old key from rotation list
Zero-downtime rotation via the keyring: one **current** master key
(`CACHEKIT_MASTER_KEY`, encrypts and decrypts) plus up to **3 decrypt-only**
previous keys (`CACHEKIT_PREVIOUS_MASTER_KEYS`, comma-separated hex, same
per-key requirements as the master key). Entries carry the fingerprint of
their HKDF-derived per-tenant encryption key, so reads select the exact
keyring entry that wrote them — never trial decryption.

```bash
# 1. Promote the new key; retain the old key decrypt-only
export CACHEKIT_MASTER_KEY=<new-key-hex>
export CACHEKIT_PREVIOUS_MASTER_KEYS=<old-key-hex>
# 2. Old entries still decrypt (selected by key fingerprint); new writes use the new key
# 3. Old-key entries age out via TTL (or re-encrypt on the next write)
# 4. After the window (≥ longest TTL in use), drop the old key
unset CACHEKIT_PREVIOUS_MASTER_KEYS
```

Rules enforced at config load — rejected, never truncated or silently fixed:

- **Cap**: at most 3 decrypt-only keys.
- **Per-key validation**: identical to `CACHEKIT_MASTER_KEY` (hex-encoded, ≥32 bytes).
- **Forward-only**: the current master key must not re-appear in the
decrypt-only list. A key that has ever encrypted is never re-promoted —
that would resume a used AES-GCM nonce budget and risk catastrophic nonce
reuse. Backing out a rotation means rotating *forward* to a fresh key.

An empty decrypt-only list is legal — that is the hard cut-over used for
compromise response (old entries become unreadable immediately).

[Interop-mode](../../README.md) entries store no per-entry key fingerprint
(no CK frame), so rotation there attempts keyring keys sequentially — current
key first, identical AAD per attempt — instead of fingerprint selection. Same
environment variables, same rotation window, same fail policy on exhaustion.

---

## Technical Deep Dive
Expand Down Expand Up @@ -433,12 +459,13 @@ config = EncryptionConfig(enabled=True, master_key="a" * 64,
```

> **⚠️ Key rotation under fail-closed:** with `fail_closed` enabled there is no
> silent self-heal — rotating `CACHEKIT_MASTER_KEY` without clearing the cache makes
> **every** pre-rotation entry raise `DecryptionAuthenticationError` on read (the
> fingerprint mismatch refuses decryption, and the entry is retained, not evicted).
> Follow the documented rotation procedure: flush (or namespace-version) the cache
> *before* rotating. This is the deliberate cost of failing closed; the default
> fail-open mode self-heals rotations as ordinary misses.
> silent self-heal — rotating `CACHEKIT_MASTER_KEY` **without retaining the old key
> in `CACHEKIT_PREVIOUS_MASTER_KEYS`** makes every pre-rotation entry raise
> `DecryptionAuthenticationError` on read (the fingerprint matches no keyring entry,
> decryption is refused, and the entry is retained, not evicted). Follow the keyring
> rotation pattern above: keep the retiring key decrypt-only for the full window.
> This is the deliberate cost of failing closed; the default fail-open mode treats
> keyless entries as ordinary misses.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Note the boundary with the integrity checksum: the ByteStorage **xxHash3-64 checksum
is corruption detection only** — it is not cryptographic and an attacker who can write
Expand Down Expand Up @@ -546,7 +573,11 @@ def get_data():
A: Key mismatch or data corruption. Check CACHEKIT_MASTER_KEY hasn't changed.

**Q: Key rotation failing**
A: Ensure CACHEKIT_MASTER_KEY_ROTATION is formatted correctly.
A: Check `CACHEKIT_PREVIOUS_MASTER_KEYS` — comma-separated hex, each key subject to
the same rules as `CACHEKIT_MASTER_KEY` (≥32 bytes), at most 3 entries, and the
current `CACHEKIT_MASTER_KEY` must **not** appear in the list. Follow the keyring
rotation pattern above: keep the retiring key decrypt-only for the full rotation
window before dropping it.

**Q: Performance degraded after enabling encryption**
A: Expected 100-500μs overhead. Profile to confirm acceptable.
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ dependencies = [
"redis[hiredis]>=4.0.0",
# Configuration and validation
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"pydantic-settings>=2.7.0", # NoDecode (previous_master_keys env parsing) first shipped in 2.7.0
# Monitoring and observability
"prometheus-client>=0.22.1",
"psutil>=7.0.0",
Expand Down Expand Up @@ -251,4 +251,8 @@ constraint-dependencies = [
# PYSEC-2026-196 (entry-point path traversal), GHSA-58qw-9mgm-455v (tar/zip
# confusion) and GHSA-jp4c-xjxw-mgf9 (self-update import ordering).
"pip>=26.1.2",
# h2 is a transitive dep (httpx[http2] -> h2). 4.4.1 fixes
# GHSA-6hr6-w5qg-qmwg (duplicate Host headers forwarded on HTTP/2 ->
# HTTP/1.1 downgrade — request smuggling primitive).
"h2>=4.4.1",
]
8 changes: 6 additions & 2 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@ crate-type = ["cdylib", "rlib"]

[dependencies]
# Compression, checksums, encryption (https://crates.io/crates/cachekit-core)
cachekit-core = { version = "0.4.0", features = ["compression", "checksum", "messagepack", "encryption"] }
cachekit-core = { version = "0.5.0", features = ["compression", "checksum", "messagepack", "encryption"] }
Comment thread
27Bslash6 marked this conversation as resolved.

# Python integration - optional for Rust-only builds
pyo3 = { workspace = true, optional = true }

# Wipe the PyO3-side copies of master key material on drop. Only used behind
# the encryption feature, so gated the same way pyo3 is.
zeroize = { version = "1", optional = true }

# Feature flags
[features]
default = ["python", "compression", "checksum", "messagepack", "encryption"]
Expand All @@ -36,7 +40,7 @@ python = ["dep:pyo3"]
compression = []
checksum = []
messagepack = []
encryption = []
encryption = ["dep:zeroize"]

[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
Expand Down
7 changes: 2 additions & 5 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,8 @@ pub use cachekit_core::{ByteStorage, OperationMetrics, StorageEnvelope};
#[cfg(feature = "encryption")]
pub use cachekit_core::{
derive_domain_key,
encryption::{
key_derivation::{derive_tenant_keys, key_fingerprint, TenantKeys},
key_rotation::KeyRotationState,
},
EncryptionError, ZeroKnowledgeEncryptor,
encryption::key_derivation::{derive_tenant_keys, key_fingerprint, TenantKeys},
EncryptionError, Keyring, ZeroKnowledgeEncryptor,
};

// Python bindings (gated behind python feature)
Expand Down
Loading
Loading