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

2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ When enabled via `@cache.secure`, client-side AES-256-GCM encryption ensures the
| Server visibility | Opaque ciphertext only |
| Key derivation | HKDF with per-tenant salts |
| Authentication | GCM tags prevent tampering |
| Compliance | GDPR/HIPAA/PCI-DSS ready |
| Compliance | May *reduce* GDPR/HIPAA/PCI DSS scope, subject to assessment — not a compliance guarantee ([details](docs/features/zero-knowledge-encryption.md#compliance-implications)) |

<details>
<summary><strong>🔐 Master Key Security</strong></summary>
Expand Down
11 changes: 7 additions & 4 deletions docs/backends/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,17 @@ CACHEKIT_REDIS_URL=redis://prod.example.com:6379

# Fallback: REDIS_URL
REDIS_URL=redis://localhost:6379

# Managed SaaS (takes precedence over the Redis selectors)
CACHEKIT_API_KEY=ck_live_...
Comment thread
27Bslash6 marked this conversation as resolved.
```

If no explicit backend and no module-level default, cachekit creates a RedisBackend from environment variables.
If no explicit backend and no module-level default, cachekit auto-detects a backend from the environment at the function's **first call**.

**Resolution order**:
1. Check for explicit `backend` parameter in `@cache(backend=...)`
2. Check for module-level default via `set_default_backend()`
3. Create RedisBackend from environment variables (CACHEKIT_REDIS_URL > REDIS_URL)
1. Explicit `backend` parameter in `@cache(backend=...)` — the only order-independent tier
2. Module-level default via `set_default_backend()` — read once, **at decoration time**; a default set after the decorated module is imported is ignored
3. Environment auto-detection at first call: `CACHEKIT_API_KEY` → CachekitIO, `CACHEKIT_REDIS_URL` → Redis, `CACHEKIT_MEMCACHED_SERVERS` → Memcached, `CACHEKIT_FILE_CACHE_DIR` → File; more than one of these set at once is a `ConfigurationError`; none set → Redis from `REDIS_URL` (localhost default). A provider error here is **logged and the function runs uncached** — it does not raise.

## Performance Considerations

Expand Down
32 changes: 30 additions & 2 deletions docs/backends/cachekitio.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,10 @@ def get_user_profile(user_id: str) -> dict:

**Why this matters**:
- `@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
- Per-tenant key derivation via HKDF keyed on the tenant id — namespaces within a tenant share one derived key; cross-namespace separation comes from the AAD cache-key binding, not from key derivation
- 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 — a scope-*reduction*
argument, not a guarantee; see [Compliance Implications](../features/zero-knowledge-encryption.md#compliance-implications)
- Without `@cache.secure`: SaaS stores plaintext, may be in compliance scope

**Requirements**:
Expand All @@ -198,6 +199,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
**at decoration time**, the same code silently caches **plaintext** to the SaaS —
and a key loaded later (dotenv in `main()`, a vault startup hook) is never seen.

Use `.secure` + explicit backend when encryption is a security requirement (PII,
PHI, any compliance argument — see
[Compliance Implications](../features/zero-knowledge-encryption.md#compliance-implications)
for the canonical statement). 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
126 changes: 111 additions & 15 deletions docs/features/zero-knowledge-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@

## TL;DR

Zero-knowledge encryption (AES-256-GCM) encrypts cached data client-side. Redis never sees plaintext. Perfect for sensitive data (PII, credentials, health info).
Zero-knowledge encryption (AES-256-GCM) encrypts cached data client-side. The backend never sees plaintext values. Perfect for sensitive data (PII, credentials, health info).

```python notest
@cache.secure(ttl=300, master_key="a" * 64, backend=None) # AES-256-GCM encryption
def get_user_ssn(user_id):
return db.get_ssn(user_id) # Encrypted in Redis, decrypted in-app (illustrative)
return db.get_ssn(user_id) # stored encrypted; L1-only here (backend=None) — pass backend= for Redis/SaaS
```

---
Expand All @@ -31,7 +31,82 @@ os.environ["CACHEKIT_MASTER_KEY"] = "a" * 64 # 32 bytes
def get_sensitive_data(user_id):
return db.query(SensitiveData).filter_by(id=user_id).first() # illustrative - db not defined

data = get_sensitive_data(123) # Encrypted in Redis
data = get_sensitive_data(123) # stored encrypted (L1-only — backend=None)
```

---

## 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 (the `CACHEKIT_MASTER_KEY` fallback is read then, at import) | **Fails open** — silently caches plaintext to the SaaS. The key is read **at decoration time**: one loaded later (dotenv in `main()`, a startup vault hook) is never seen, and every call ships plaintext |
| Integrity checking | Forced `True` on the preset path; **not** re-forced when you pass `integrity_checking=` alongside `@cache(config=DecoratorConfig.secure(...))` | On by preset default |
| Backend | Pinned **only** by the explicit `backend=` shown — omit it and resolution falls to env auto-detect (footgun below) | `CachekitIOBackend` created by the preset — `backend=` is unsupported, see note below; requires `CACHEKIT_API_KEY` at decoration time |
| Tenant mode | `single_tenant_mode` derived from `tenant_extractor`; per-tenant HKDF keys available | **Forced single-tenant** — `tenant_extractor` is not accepted; every entry is encrypted under one deployment-wide derived key, no per-tenant isolation |
| Backend SWR (`stale_ttl`) | Off unless requested (L1 SWR on in both) | On by default (`stale_ttl` sized from `ttl`); the refresh runs the function on a background thread after the response has been served, so it must not depend on request-scoped resources (a per-request DB session). `stale_ttl=0` opts out |

**`@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). 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 belong on the
fail-closed path only, and even there they are scope-*reduction* arguments, not
guarantees — see [Compliance Implications](#compliance-implications) for the one
canonical statement.

> [!WARNING]
> **`@cache.secure` does NOT pin the SaaS backend.** Backend resolution is the
> same lookup as every preset: explicit `backend=` → `set_default_backend()` **as
> read at decoration time** → 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). Only an
> explicit `backend=` is order-independent: a `set_default_backend()` that runs
> after the decorated module has been imported is silently ignored. 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) a backend misconfiguration at first call (e.g. two auto-detect selectors set
> at once) is **swallowed** — the `ConfigurationError` is logged at WARNING as a
> `client_creation` failure and the function runs **uncached on every call**.
> Alert on `client_creation` failures. When the SaaS is the requirement, pass
> `backend=CachekitIOBackend()` explicitly — auditable in code and immune to
> environment drift.
>
> **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).

```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
```

---
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,19 +235,21 @@ 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
plaintext entries. The frame header is not authenticated, so a plaintext entry forged by
an attacker with backend write access is indistinguishable from a legacy one — any
"accept plaintext" escape hatch would reintroduce the encryption-downgrade attack the
fail-closed read path exists to prevent. If you need to read plaintext entries, use a
downgrade-protected read path exists to prevent. If you need to read plaintext entries, use a
handler with `encryption=False` (which never had keys to protect).

For large caches, choose between lazy migration and eager eviction based on your
Expand Down Expand Up @@ -217,7 +294,7 @@ def get_user_profile(user_id):
return db.get_profile(user_id) # illustrative - db not defined

profile = get_user_profile(123)
# Data encrypted in Redis, decrypted in-app
# stored encrypted, decrypted in-app (L1-only here — backend=None; pass backend= for Redis/SaaS)
```

### Encrypted JSON (Zero-Knowledge API Caching)
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 @@ -364,7 +441,7 @@ Nonce = [counter_high_64bits][counter_low_32bits][random_32bits]
Prevents nonce reuse even across reboots
```

### Fail-Closed Read Path (Encryption Downgrade Protection)
### Encryption Downgrade Protection (Read Path)

The CK frame header — the JSON envelope carrying `encrypted`, `tenant_id`, `format`,
and the serializer name — is plaintext and is **not** covered by the AES-GCM
Expand All @@ -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,18 @@ 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. Because the key
travels in the URL path it also lands in every access log on the request path — load
balancer, CDN, TLS terminator — and persists for those retention windows, long after
the cache TTL; ciphertext length leaks approximate plaintext size too. 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 +576,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 +719,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 +728,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
3 changes: 1 addition & 2 deletions docs/serializers/encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,9 @@ def get_secrets(tenant_id: str):
return {"api_key": "sk_live_...", "secret": "..."}

# Backend receives encrypted blob, never sees plaintext
# GDPR/HIPAA/PCI-DSS compliant out of the box
```

When using `EncryptionWrapper` with a remote backend (e.g., cachekit.io), the SaaS backend stores only opaque ciphertext. It has no access to keys and cannot decrypt data. This makes the backend out-of-scope for HIPAA/PCI-DSS compliance requirements.
When using `EncryptionWrapper` with a remote backend (e.g., cachekit.io), the SaaS backend stores only opaque ciphertext. It has no access to keys and cannot decrypt data. 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 [Compliance Implications](../features/zero-knowledge-encryption.md#compliance-implications)).

## Performance

Expand Down
Loading
Loading