Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ jobs:
- name: Scan Python dependencies for CVEs
run: |
# No suppressions: every prior CVE is resolved at source on the py3.10+
# resolution. urllib3>=2.7.0 and pip>=26.1.2 are pinned via
# resolution. urllib3>=2.7.0 and pip>=26.2 are pinned via
# [tool.uv] constraint-dependencies; pygments/pyarrow advisories cleared
# by their py3.10+ fix versions. Keep this list IDENTICAL to
# security-fast.yml's pip-audit so the two cannot drift.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security-fast.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ jobs:
- name: Run pip-audit
run: |
# No suppressions: every prior CVE is resolved at source on the py3.10+
# resolution. urllib3>=2.7.0 and pip>=26.1.2 are pinned via
# resolution. urllib3>=2.7.0 and pip>=26.2 are pinned via
# [tool.uv] constraint-dependencies; pygments/pyarrow advisories cleared
# by their py3.10+ fix versions. Keep this list IDENTICAL to ci.yml's
# post-merge pip-audit so the two cannot drift.
Expand Down
4 changes: 2 additions & 2 deletions .secrets.baseline

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

27 changes: 27 additions & 0 deletions docs/backends/cachekitio.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,33 @@ CACHEKIT_API_KEY=ck_live_...

See [Zero-Knowledge Encryption](../features/zero-knowledge-encryption.md) for full details on key derivation and serialization format implications.

### `.secure` + explicit backend vs `.io()` + env var — which one?

There is a second path to encrypted SaaS caching: `@cache.io()` with
`CACHEKIT_MASTER_KEY` set. Encryption is then auto-detected downstream — the same
zero-knowledge bytes on the wire — **but the failure mode is inverted**:

- `@cache.secure(backend=CachekitIOBackend())` — **fails closed.** Encryption is
forced on in code; a missing master key (param or `CACHEKIT_MASTER_KEY`) raises
`ValueError` at decoration time. No plaintext **values** can ever reach the
backend (cache keys and the frame header stay plaintext by design).
- `@cache.io()` + `CACHEKIT_MASTER_KEY` — **fails open.** If the env var is absent,
the same code silently caches **plaintext** to the SaaS.

Use `.secure` + explicit backend when encryption is a security requirement (PII,
PHI, compliance arguments — a HIPAA/PCI DSS scope-*reduction* argument can only be
made on this path, and even then is subject to assessment and your surrounding
controls; encryption alone does not remove regulated data from scope). Use `.io()`
+ env when encryption is a fleet-wide opt-in convenience and plaintext caching is
an acceptable state.

Two caveats, covered in depth in
[Which Path](../features/zero-knowledge-encryption.md#which-path-cachesecure-vs-cacheio--cachekit_master_key):
`.secure` does **not** pin the SaaS backend (env auto-detect can silently route
encrypted values to Redis — pass `backend=` explicitly, as above), and fail-closed
on a *missing key* is separate from `fail_closed` on a *decrypt failure*, which
defaults to off.

## See Also

- [Backend Guide](README.md) — Backend comparison and resolution priority
Expand Down
92 changes: 83 additions & 9 deletions docs/features/zero-knowledge-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,79 @@ data = get_sensitive_data(123) # Encrypted in Redis

---

## Which Path: `@cache.secure` vs `@cache.io` + `CACHEKIT_MASTER_KEY`

There are two real, shipped paths to encrypted caching on the cachekit.io SaaS. Both are
zero-knowledge on the wire **when a master key is present** — the difference is what
happens when it isn't, and which backend you actually reach.

| | `@cache.secure(backend=CachekitIOBackend())` | `@cache.io()` + `CACHEKIT_MASTER_KEY` env |
|---|---|---|
| Encryption | Forced ON in code (`EncryptionConfig.enabled=True`) | Auto-detected from the env var (tri-state `enabled=None`) |
| **No master key present** | **Fails closed** — raises `ValueError` at decoration time | **Fails open** — silently caches plaintext to the SaaS |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| Integrity checking | Forced `True`, cannot be overridden | On by preset default |
| Backend | Env auto-detect — **not pinned to the SaaS**, see footgun below; pass `backend=` explicitly | `CachekitIOBackend` created by the preset — `backend=` is unsupported, see note below; requires `CACHEKIT_API_KEY` at decoration time |
| Tenant mode | `single_tenant_mode` handled automatically | Handled automatically (auto-detect path) |
| Backend SWR (`stale_ttl`) | Off unless requested (L1 SWR on in both) | On by default (`stale_ttl` sized from `ttl`) |

**`@cache.io()` does not take a `backend=` argument.** The preset always
constructs its own `CachekitIOBackend`: a non-`None` `backend=` passed to the
decorator is silently discarded, and `backend=None` flips the wrapper into
L1-only mode (in-process memory — the SaaS is never contacted, despite the
`.io` name). Calling `DecoratorConfig.io(backend=...)` directly raises
`TypeError` (duplicate keyword argument). To target any other backend, use a
different preset with an explicit `backend=`.

**Rule of thumb**: encryption as a **security requirement** → `@cache.secure` +
explicit backend. The intent is auditable in code. Encryption as a **fleet-wide
opt-in convenience** → set `CACHEKIT_MASTER_KEY` and let auto-detect do it (this
applies to every preset, not just `.io`). Compliance arguments — "the SaaS only
ever stores ciphertext" — should only be hung on the fail-closed path: on the
auto-detect path, one missing env var quietly puts plaintext on the backend. Even
on the fail-closed path, client-side encryption may *reduce* HIPAA/PCI DSS scope
subject to assessment and your surrounding controls — it does not remove regulated
data from scope on its own (see [Compliance Implications](#compliance-implications)).

> [!WARNING]
> **`@cache.secure` does NOT pin the SaaS backend.** Backend resolution is the
> same lookup as every preset: explicit `backend=` → `set_default_backend()` →
> environment auto-detect at **first call** (`CACHEKIT_API_KEY` → cachekit.io SaaS;
> `CACHEKIT_REDIS_URL` → Redis; then the Memcached/File selectors; else
> `REDIS_URL` / localhost Redis fallback). Two consequences: (1) in a 12-factor
> environment where `REDIS_URL` is set and `CACHEKIT_API_KEY` is not,
> `@cache.secure` **silently encrypts to Redis instead of the SaaS**; (2) because
> resolution is lazy, a backend misconfiguration (e.g. two auto-detect selectors
> set at once) surfaces as a `ConfigurationError` at first call, not at import.
> When the SaaS is the requirement, pass `backend=CachekitIOBackend()` explicitly
> — auditable in code and immune to environment drift.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
```python notest
from cachekit import cache
from cachekit.backends.cachekitio import CachekitIOBackend

# Security requirement: fail-closed, auditable, explicitly targets the SaaS
@cache.secure(backend=CachekitIOBackend(), ttl=3600)
def get_patient_record(patient_id: str):
return fetch_phi(patient_id) # illustrative

# Fleet-wide convenience: encrypts iff CACHEKIT_MASTER_KEY is set,
# silently plaintext if it is not
@cache.io(ttl=300)
def get_dashboard_stats(org_id: str):
return compute_stats(org_id) # illustrative
```

> [!IMPORTANT]
> **Two separate fail-closed guarantees — don't conflate them.** `.secure` is
> fail-closed on a *missing key* (decoration-time `ValueError`). But `fail_closed`
> on a *decrypt failure* (e.g. an AES-GCM auth-tag mismatch at read time) is a
> separate tri-state setting that defers to `CACHEKIT_ENCRYPTION_FAIL_CLOSED`,
> which **defaults to `False`** — so even `.secure` fails *open* on tampered or
> key-mismatched entries (miss + recompute) unless you opt in. See
> [Corruption vs Tamper: Telemetry and Fail-Closed Mode](#corruption-vs-tamper-telemetry-and-fail-closed-mode).

---

## What It Does

**Encryption pipeline** (works with ANY serializer):
Expand Down Expand Up @@ -121,7 +194,7 @@ def get_user_ssn(user_id):

### Missing Master Key
> [!WARNING]
> `cache.secure` requires a master key. Omitting it raises a `ConfigurationError` at decoration time, not at call time.
> `cache.secure` requires a master key. Omitting it raises a `ValueError` at decoration time, not at call time — this is the fail-closed guarantee that distinguishes `.secure` from env-var auto-detection (see [Which Path](#which-path-cachesecure-vs-cacheio--cachekit_master_key) above).

```python notest
# Forget to set master_key parameter
Expand Down Expand Up @@ -160,12 +233,14 @@ export CACHEKIT_PREVIOUS_MASTER_KEYS=old_key # decrypt-only (comma-separat
### Enabling Encryption on an Existing (Plaintext) Cache

When you turn encryption on over a cache that already holds plaintext entries, those
entries are **rejected, never read**. The read path fails closed: the entry raises a
`SerializationError`, the caller treats it as a miss, evicts the stale entry, recomputes,
and re-stores the value encrypted. Migration is therefore lazy and self-healing:
entries are **rejected, never read**: the entry raises a `SerializationError`
internally, the caller treats it as a miss, evicts the stale entry, recomputes,
and re-stores the value encrypted. (This rejection is unconditional — it is not
governed by the `fail_closed` setting, which applies only to authenticated-decrypt
failures.) Migration is therefore lazy and self-healing:

```text
read plaintext entry → SerializationError (fail closed) → evict → recompute → re-store encrypted
read plaintext entry → SerializationError (rejected, never deserialized) → evict → recompute → re-store encrypted
```

There is deliberately **no opt-in flag** to let an encryption-enabled reader accept
Expand Down Expand Up @@ -255,7 +330,7 @@ def get_patient_records(hospital_id: int):
)

df = get_patient_records(42)
# DataFrame encrypted client-side, HIPAA-compliant zero-knowledge storage
# DataFrame encrypted client-side zero-knowledge storage
```

### Multi-Tenant Isolation
Expand Down Expand Up @@ -381,7 +456,7 @@ path when encryption is configured:
```text
Handler configured with encryption:
entry header claims encrypted → authenticated decrypt (AAD + GCM tag verified)
entry header claims plaintext → SerializationError (fail closed, entry evicted)
entry header claims plaintext → SerializationError (plaintext never returned; miss + evict, independent of `fail_closed`)
```

The plaintext deserializer is unreachable on an encryption-enabled handler, regardless
Expand Down Expand Up @@ -622,7 +697,6 @@ export default {
// NEVER sees plaintext (no decryption key)
await KV.put(key, value);

// Compliance: GDPR, HIPAA, PCI-DSS satisfied
// Backend cannot read user data even if compromised
return new Response("OK");
}
Expand All @@ -632,7 +706,7 @@ export default {
**Benefits**:
- ✅ Backend compromise doesn't expose user data
- ✅ Multi-tenant isolation (per-tenant encryption keys)
- ✅ GDPR/HIPAA/PCI-DSS compliance out of the box
- ✅ Supports GDPR/HIPAA/PCI-DSS arguments on the fail-closed path (`@cache.secure` + explicit backend — see [Which Path](#which-path-cachesecure-vs-cacheio--cachekit_master_key))
- ✅ Works with any data type (JSON, MessagePack, DataFrames)

---
Expand Down
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,11 @@ constraint-dependencies = [
"urllib3>=2.7.0",
"fonttools>=4.60.2",
"werkzeug>=3.1.4",
# pip is a dev-only transitive dep (pip-audit -> pip-api -> pip). 26.1.2 fixes
# 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",
# pip is a dev-only transitive dep (pip-audit -> pip-api -> pip). 26.2 fixes
# PYSEC-2026-3721 (doubly-encoded index URLs writing files to arbitrary
# paths); 26.1.2 fixed PYSEC-2026-196, GHSA-58qw-9mgm-455v and
# GHSA-jp4c-xjxw-mgf9.
"pip>=26.2",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 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).
Expand Down
11 changes: 10 additions & 1 deletion src/cachekit/config/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,12 @@ def secure(cls, master_key: str, tenant_extractor: Callable[..., str] | None = N
Use cases: PII, medical data, financial records, GDPR compliance
Architecture: Both L1 and L2 store encrypted bytes (encrypt-at-rest everywhere)

Note: Backend resolved from CACHEKIT_API_KEY, REDIS_URL, set_default_backend(), or explicit backend= kwarg
Note: Backend resolution is the same as every preset — explicit backend= kwarg, then
set_default_backend(), then DefaultBackendProvider env auto-detect at FIRST CALL
(CACHEKIT_API_KEY → cachekit.io SaaS; CACHEKIT_REDIS_URL → Redis; then Memcached/File
selectors; else REDIS_URL / localhost Redis fallback). .secure does NOT pin the SaaS:
with REDIS_URL set and CACHEKIT_API_KEY unset, encrypted values silently go to Redis.
When the SaaS is a requirement, pass backend=CachekitIOBackend() explicitly.
Note: integrity_checking is forced to True (non-negotiable for security)

Args:
Expand Down Expand Up @@ -552,6 +557,10 @@ def io(cls, **kwargs: Any) -> DecoratorConfig:
Encryption: Set CACHEKIT_MASTER_KEY env var to enable automatic client-side
AES-256-GCM encryption — no code changes needed. Auto-detection happens in
CacheSerializationHandler and applies to ALL presets, not just .io().
FAIL-OPEN caveat: if CACHEKIT_MASTER_KEY is absent, the same code silently
caches plaintext to the SaaS. When encryption is a security requirement,
use @cache.secure(backend=CachekitIOBackend()) instead — it raises at
decoration time when no key is present.

Args:
**kwargs: Overrides (ttl, namespace, etc.)
Expand Down
8 changes: 4 additions & 4 deletions uv.lock

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

Loading