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
14 changes: 7 additions & 7 deletions .secrets.baseline

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

31 changes: 30 additions & 1 deletion docs/backends/cachekitio.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ def get_user_profile(user_id: str) -> dict:
- `@cache.secure` applies AES-256-GCM client-side encryption before any data leaves the process
- Per-tenant key derivation via HKDF — cryptographic isolation between namespaces
- The SaaS backend is a zero-knowledge conduit: it stores whatever bytes arrive
- With `@cache.secure`: SaaS is out of scope for HIPAA/PCI (stores only ciphertext)
- With `@cache.secure` + explicit backend: the SaaS holds only ciphertext — this supports a
HIPAA/PCI DSS scope-*reduction* argument, subject to assessment and your surrounding
controls; it does not take regulated data out of scope on its own (see below)
- Without `@cache.secure`: SaaS stores plaintext, may be in compliance scope

**Requirements**:
Expand All @@ -198,6 +200,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
111 changes: 102 additions & 9 deletions docs/features/zero-knowledge-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,81 @@ 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. "Zero-knowledge" covers cached
**values**: the cache key and frame header stay cleartext on both paths (see
[Accepted Exposure](#cleartext-frame-header-fields-accepted-exposure)).

| | `@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 +196,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).
Comment thread
27Bslash6 marked this conversation as resolved.
Comment thread
27Bslash6 marked this conversation as resolved.

```python notest
# Forget to set master_key parameter
Expand Down Expand Up @@ -160,12 +235,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 +332,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 +458,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 All @@ -407,6 +484,15 @@ Relocating these fields would be a cross-SDK wire-format change owned by the
[protocol spec](https://github.com/cachekit-io/protocol); the Python SDK documents the
exposure rather than diverging from the shared frame format.

Beyond the frame header, the **cache key itself is cleartext** — on the CachekitIO backend
it travels percent-encoded in the URL path (`/v1/cache/{key}`). The key carries the
namespace and the function's `module.qualname` plus an unkeyed, unsalted blake2b-256 of
the arguments (`ns:{ns}:func:{mod.fn}:args:{64-hex}:{flags}`), so over a small or known
argument space the hash is offline-enumerable: a backend operator can learn *which* record
was accessed, when, and how often, without decrypting anything. Encryption protects
values, not access patterns — keep secrets out of namespaces and function names, and
count argument-identifiable access as metadata exposure in your threat model.

### Corruption vs Tamper: Telemetry and Fail-Closed Mode

Three failure classes surface on the decrypt read path, and cachekit distinguishes
Expand Down Expand Up @@ -487,6 +573,14 @@ didn't recently disable encryption for that function, investigate.

## Compliance Implications

> [!IMPORTANT]
> The arguments below hold only on the **fail-closed path** (`@cache.secure` + explicit
> backend). On the env auto-detect path one missing `CACHEKIT_MASTER_KEY` silently puts
> plaintext on the backend and none of these checkmarks apply. Even fail-closed,
> 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
> [Which Path](#which-path-cachesecure-vs-cacheio--cachekit_master_key).

### GDPR
- ✅ Encryption satisfies "processing security" requirement
- ✅ Client-side encryption satisfies "technical measures"
Expand Down Expand Up @@ -622,7 +716,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 +725,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
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
Loading