diff --git a/.secrets.baseline b/.secrets.baseline index ddd47d1..c26a970 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -142,14 +142,14 @@ "filename": "docs/configuration.md", "hashed_secret": "e45470554d7d1790539cc3091db4699ea780fb8f", "is_verified": false, - "line_number": 153 + "line_number": 159 }, { "type": "Hex High Entropy String", "filename": "docs/configuration.md", "hashed_secret": "d88aec4de8c76ada9c0172733e4300bb82abcfc7", "is_verified": false, - "line_number": 543 + "line_number": 551 } ], "docs/features/interop-mode.md": [ @@ -183,21 +183,21 @@ "filename": "docs/features/zero-knowledge-encryption.md", "hashed_secret": "d8eab3976a5dca3e6c91149eff8311b31399ecc7", "is_verified": false, - "line_number": 228 + "line_number": 309 }, { "type": "Secret Keyword", "filename": "docs/features/zero-knowledge-encryption.md", "hashed_secret": "b1aa66c32f3e9119bb6d52d55f9e94b1cb8d6cbe", "is_verified": false, - "line_number": 229 + "line_number": 310 }, { "type": "Secret Keyword", "filename": "docs/features/zero-knowledge-encryption.md", "hashed_secret": "4084cee9e75572b8e2e055e114ea7458e3978b5b", "is_verified": false, - "line_number": 572 + "line_number": 697 } ], "docs/serializers/encryption.md": [ @@ -231,7 +231,7 @@ "filename": "src/cachekit/config/decorator.py", "hashed_secret": "1a9a9d37d8305b0cd8353468065cf844259e1b1f", "is_verified": false, - "line_number": 567 + "line_number": 576 } ], "src/cachekit/serializers/interop_serializer.py": [ @@ -887,5 +887,5 @@ } ] }, - "generated_at": "2026-09-13T12:55:39Z" + "generated_at": "2026-09-14T09:15:58Z" } diff --git a/docs/backends/cachekitio.md b/docs/backends/cachekitio.md index 98d6b24..22b1cd4 100644 --- a/docs/backends/cachekitio.md +++ b/docs/backends/cachekitio.md @@ -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**: @@ -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 diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index d350aaf..8801da9 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -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 | +| 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. + +```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): @@ -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). ```python notest # Forget to set master_key parameter @@ -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 @@ -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 @@ -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 @@ -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 @@ -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" @@ -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"); } @@ -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) --- diff --git a/src/cachekit/config/decorator.py b/src/cachekit/config/decorator.py index dd3cee5..d64b3df 100644 --- a/src/cachekit/config/decorator.py +++ b/src/cachekit/config/decorator.py @@ -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: @@ -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.)