From d43375bb98a0c7cab0201e774a25898798b15001 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 04:41:57 +1000 Subject: [PATCH 01/10] =?UTF-8?q?docs(encryption):=20.secure=20vs=20.io=20?= =?UTF-8?q?+=20CACHEKIT=5FMASTER=5FKEY=20=E2=80=94=20fail-closed=20vs=20fa?= =?UTF-8?q?il-open=20decision=20guide=20(LAB-749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers 'which of the two encrypted-SaaS paths do I use?' in one place: a decision table + rule of thumb in zero-knowledge-encryption.md, the contrast and fail-open caveat in the CachekitIO backend page, and corrected backend-resolution notes in the .secure/.io docstrings. Corrects the ticket's premise against verified runtime behaviour: the live resolution path is DefaultBackendProvider (DI), whose tier 1 IS CACHEKIT_API_KEY -> CachekitIOBackend, so .secure CAN reach the SaaS unaided — the real footgun is that it does not PIN the SaaS: with REDIS_URL set and CACHEKIT_API_KEY unset, encrypted values silently go to Redis, and resolution is lazy (first call, not decoration). (_resolve_backend in config/decorator.py, the ticket's evidence, is dead code only its unit tests call.) Also: missing-key fail-closed vs fail_closed-on-decrypt-failure (defaults open) documented as separate guarantees; missing-key error corrected to ValueError. --- .secrets.baseline | 4 +- docs/backends/cachekitio.md | 25 ++++++++ docs/features/zero-knowledge-encryption.md | 66 +++++++++++++++++++++- src/cachekit/config/decorator.py | 11 +++- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 809c294f..cf327538 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -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-08-07T16:45:43Z" + "generated_at": "2026-08-30T18:41:24Z" } diff --git a/docs/backends/cachekitio.md b/docs/backends/cachekitio.md index 98d6b249..24cf70b3 100644 --- a/docs/backends/cachekitio.md +++ b/docs/backends/cachekitio.md @@ -198,6 +198,31 @@ 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. Nothing plaintext can ever reach the backend. +- `@cache.io()` + `CACHEKIT_MASTER_KEY` — **fails open.** If the env var is absent, + the same code silently caches **plaintext** to the SaaS. Nothing raises; the only + difference is the missing env var. + +Use `.secure` + explicit backend when encryption is a security requirement (PII, +PHI, compliance claims — the "SaaS out of HIPAA/PCI scope" argument only holds on +this path). 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 d350aafb..47c8a9a3 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -36,6 +36,70 @@ 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 | +| 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` guaranteed (preset creates its own, ignores `backend=`; requires `CACHEKIT_API_KEY` at decoration time) | +| Tenant mode | `single_tenant_mode` handled automatically | Handled automatically (auto-detect path) | +| SWR | Off unless requested | On by default (`stale_ttl` sized from `ttl`) | + +**Rule of thumb**: encryption as a **security requirement** → `@cache.secure` + +explicit backend. The intent is auditable in code, and a missing key is a loud +deploy-time failure instead of silent plaintext. 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 claims — "the SaaS is out of +HIPAA/PCI scope because it 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. + +> [!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. + +> [!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 +> [Fail-Closed Read Path](#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 +``` + +--- + ## What It Does **Encryption pipeline** (works with ANY serializer): @@ -121,7 +185,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 diff --git a/src/cachekit/config/decorator.py b/src/cachekit/config/decorator.py index dd3cee5e..d64b3df1 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.) From 8b8ecd5f754ae941eaf20df287129a4c89635bd9 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 04:57:22 +1000 Subject: [PATCH 02/10] =?UTF-8?q?docs:=20apply=20expert-panel=20findings?= =?UTF-8?q?=20=E2=80=94=20plaintext-values=20precision,=20SWR=20row,=20fai?= =?UTF-8?q?l-closed=20vocabulary,=20compliance=20scoping=20(LAB-749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel findings applied: (1) 'nothing plaintext can ever reach the backend' narrowed to plaintext VALUES (cache keys and frame header are plaintext by design); (2) table SWR row scoped to backend SWR — the secure preset enables L1 SWR too; (3) the downgrade-guard rejection is no longer labelled 'fail closed' in the migration section and read-path diagram — it is unconditional and independent of the fail_closed setting, which the new Which Path callout defines strictly; (4) unqualified 'GDPR/HIPAA/PCI-DSS out of the box' and 'HIPAA-compliant' claims now attach to the fail-closed path only; (5) IMPORTANT callout link text matches its target section; (6) one fail-open restatement trimmed (panel cut list). --- docs/backends/cachekitio.md | 6 +++--- docs/features/zero-knowledge-encryption.md | 24 +++++++++++----------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/backends/cachekitio.md b/docs/backends/cachekitio.md index 24cf70b3..ef88436c 100644 --- a/docs/backends/cachekitio.md +++ b/docs/backends/cachekitio.md @@ -206,10 +206,10 @@ 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. Nothing plaintext can ever reach the backend. + `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. Nothing raises; the only - difference is the missing env var. + the same code silently caches **plaintext** to the SaaS. Use `.secure` + explicit backend when encryption is a security requirement (PII, PHI, compliance claims — the "SaaS out of HIPAA/PCI scope" argument only holds on diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index 47c8a9a3..ff94c89c 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -49,11 +49,10 @@ happens when it isn't, and which backend you actually reach. | 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` guaranteed (preset creates its own, ignores `backend=`; requires `CACHEKIT_API_KEY` at decoration time) | | Tenant mode | `single_tenant_mode` handled automatically | Handled automatically (auto-detect path) | -| SWR | Off unless requested | On by default (`stale_ttl` sized from `ttl`) | +| Backend SWR (`stale_ttl`) | Off unless requested (L1 SWR on in both) | On by default (`stale_ttl` sized from `ttl`) | **Rule of thumb**: encryption as a **security requirement** → `@cache.secure` + -explicit backend. The intent is auditable in code, and a missing key is a loud -deploy-time failure instead of silent plaintext. Encryption as a **fleet-wide +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 claims — "the SaaS is out of HIPAA/PCI scope because it only ever stores ciphertext" — should only be hung on @@ -80,7 +79,7 @@ plaintext on the backend. > 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 -> [Fail-Closed Read Path](#corruption-vs-tamper-telemetry-and-fail-closed-mode). +> [Corruption vs Tamper: Telemetry and Fail-Closed Mode](#corruption-vs-tamper-telemetry-and-fail-closed-mode). ```python notest from cachekit import cache @@ -224,12 +223,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 @@ -319,7 +320,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 @@ -445,7 +446,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 @@ -686,7 +687,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"); } @@ -696,7 +696,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) --- From 20622fe09216ef910a55f841a85642f16080e6f2 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 05:04:23 +1000 Subject: [PATCH 03/10] =?UTF-8?q?chore(deps):=20constrain=20pip>=3D26.2=20?= =?UTF-8?q?=E2=80=94=20PYSEC-2026-3721=20(doubly-encoded=20index=20URL=20a?= =?UTF-8?q?rbitrary=20file=20write)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pip-audit red on the PR: pip 26.1.2 (dev-only transitive dep via pip-audit -> pip-api) carries PYSEC-2026-3721, fixed in 26.2. Ecosystem CVE, unrelated to the docs diff, but the gate is right to enforce it. Local pip-audit now clean. --- pyproject.toml | 9 +++++---- uv.lock | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3860e791..cc5bbec0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", # 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). diff --git a/uv.lock b/uv.lock index 4f9df255..0281576a 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ resolution-markers = [ constraints = [ { name = "fonttools", specifier = ">=4.60.2" }, { name = "h2", specifier = ">=4.4.1" }, - { name = "pip", specifier = ">=26.1.2" }, + { name = "pip", specifier = ">=26.2" }, { name = "urllib3", specifier = ">=2.7.0" }, { name = "werkzeug", specifier = ">=3.1.4" }, ] @@ -1283,11 +1283,11 @@ wheels = [ [[package]] name = "pip" -version = "26.1.2" +version = "26.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" }, ] [[package]] From b40b1d9613642fde465678a48a15f80c312b037c Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 05:06:51 +1000 Subject: [PATCH 04/10] docs(encryption): scope HIPAA/PCI claims to reduction-subject-to-assessment; fix MD028 (LAB-2519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per HHS/PCI SSC guidance, encryption alone does not remove regulated data from HIPAA/PCI DSS scope — both docs now say 'may reduce scope, subject to assessment', still restricted to the fail-closed path. MD028 resolved by moving the code example between the WARNING and IMPORTANT alerts (they are deliberately separate alerts; merging would conflate the two fail-closed guarantees). --- docs/backends/cachekitio.md | 8 ++++--- docs/features/zero-knowledge-encryption.md | 28 ++++++++++++---------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/backends/cachekitio.md b/docs/backends/cachekitio.md index ef88436c..22685ef6 100644 --- a/docs/backends/cachekitio.md +++ b/docs/backends/cachekitio.md @@ -212,9 +212,11 @@ zero-knowledge bytes on the wire — **but the failure mode is inverted**: the same code silently caches **plaintext** to the SaaS. Use `.secure` + explicit backend when encryption is a security requirement (PII, -PHI, compliance claims — the "SaaS out of HIPAA/PCI scope" argument only holds on -this path). Use `.io()` + env when encryption is a fleet-wide opt-in convenience -and plaintext caching is an acceptable state. +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): diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index ff94c89c..9c1b1095 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -54,10 +54,12 @@ happens when it isn't, and which backend you actually reach. **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 claims — "the SaaS is out of -HIPAA/PCI scope because it 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. +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 @@ -72,15 +74,6 @@ plaintext on the backend. > When the SaaS is the requirement, pass `backend=CachekitIOBackend()` explicitly > — auditable in code and immune to environment drift. -> [!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). - ```python notest from cachekit import cache from cachekit.backends.cachekitio import CachekitIOBackend @@ -97,6 +90,15 @@ 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 From 5f709eee4a5ac06123e6c919764a5dc9f7a39d67 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 05:46:18 +1000 Subject: [PATCH 05/10] docs(encryption): correct @cache.io backend= contract; sync pip>=26.2 CI advisory comments (LAB-2519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The io preset always builds its own CachekitIOBackend: non-None backend= is discarded, backend=None flips the wrapper to L1-only (SaaS never contacted), and DecoratorConfig.io(backend=...) raises TypeError. The table cell claimed backend= was 'ignored' — now documented precisely. - security-fast.yml and ci.yml pip-audit comments still said pip>=26.1.2; synced to the pip>=26.2 constraint (PYSEC-2026-3721) in pyproject.toml. --- .github/workflows/ci.yml | 2 +- .github/workflows/security-fast.yml | 2 +- docs/features/zero-knowledge-encryption.md | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 927d2eca..cb69e0f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/.github/workflows/security-fast.yml b/.github/workflows/security-fast.yml index eaa91e0b..f249f321 100644 --- a/.github/workflows/security-fast.yml +++ b/.github/workflows/security-fast.yml @@ -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. diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index 9c1b1095..0c5ceeca 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -47,10 +47,18 @@ happens when it isn't, and which backend you actually reach. | 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` guaranteed (preset creates its own, ignores `backend=`; requires `CACHEKIT_API_KEY` at decoration time) | +| 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 From 43b73a5feec53a79abb0d60b6b11f450e1f7b678 Mon Sep 17 00:00:00 2001 From: Mark S Date: Mon, 7 Sep 2026 10:01:03 +1000 Subject: [PATCH 06/10] docs(encryption): scope compliance checkmarks to fail-closed path; document cleartext cache key (LAB-749) Applies the two "major" findings from the expert-panel sweep on #266: 1. The Compliance Implications table showed unqualified HIPAA/PCI-DSS checkmarks while the Which Path section (already accepted in b40b1d9) restricts those arguments to the fail-closed path. A reader landing on the table via TOC/deep link got the overclaim. Add an IMPORTANT alert that mirrors the accepted wording: fail-closed path only, scope *reduction* subject to assessment, never removal. 2. "Zero-knowledge on the wire" and the Accepted Exposure section omitted the cache key, which travels cleartext (percent-encoded) in the URL path: namespace + module.qualname, plus an unkeyed, unsalted blake2b-256 of the arguments (key_generator.py:144) that is offline-enumerable over a small ID space. The sibling backends/cachekitio.md already said this; the doc designated as the compliance authority did not. Docs-only. Executable doc blocks pass (pytest --markdown-docs); markdownlint delta is MD013 line-length at the 80-col default only, matching the file's existing ~90-col wrap (no repo lint config). --- docs/features/zero-knowledge-encryption.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index 0c5ceeca..8801da96 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -40,7 +40,9 @@ data = get_sensitive_data(123) # Encrypted in Redis 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. +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 | |---|---|---| @@ -482,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 @@ -562,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" From 6a97d9677d9a8ce808eb75d541a963eee2268010 Mon Sep 17 00:00:00 2001 From: Mark S Date: Fri, 11 Sep 2026 06:38:44 +1000 Subject: [PATCH 07/10] docs(cachekitio): qualify HIPAA/PCI scope claim; refresh detect-secrets baseline line numbers (LAB-749) CodeRabbit full review on 43b73a5, both findings valid: - docs/backends/cachekitio.md:189 stated that @cache.secure puts the SaaS "out of scope for HIPAA/PCI". That is the absolute claim this PR removes everywhere else (b40b1d9), and it contradicted the qualified wording eight lines below it. Now: ciphertext-only storage supports a scope-*reduction* argument subject to assessment and surrounding controls; it does not take regulated data out of scope on its own. Plaintext bullet unchanged. - .secrets.baseline recorded doc fixture findings at line numbers that no longer exist (the pre-commit hook excludes docs/*.md, so nothing local ever refreshed them). Regenerated with the pinned detect-secrets v1.5.0: five line-number updates across docs/configuration.md and docs/features/zero-knowledge-encryption.md plus generated_at, no new or removed findings. Docs-only + baseline metadata. Executable doc blocks pass. --- .secrets.baseline | 12 ++++++------ docs/backends/cachekitio.md | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index cf327538..75f448a1 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": 550 } ], "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": [ @@ -887,5 +887,5 @@ } ] }, - "generated_at": "2026-08-30T18:41:24Z" + "generated_at": "2026-09-10T20:37:27Z" } diff --git a/docs/backends/cachekitio.md b/docs/backends/cachekitio.md index 22685ef6..22b1cd4c 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**: From dafa9dfa314a8610860634a2619cbcbad88d23f8 Mon Sep 17 00:00:00 2001 From: Mark S Date: Sat, 19 Sep 2026 17:56:12 +1000 Subject: [PATCH 08/10] docs(encryption): backend-resolution timing, tenant-mode and compliance wording corrections (LAB-749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - set_default_backend() is read at decoration time, not first call; only an explicit backend= is order-independent - a provider error at first call is swallowed (logged as client_creation) and the function runs uncached — it does not raise - CACHEKIT_MASTER_KEY auto-detect is evaluated at decoration time; a key loaded later (dotenv in main(), vault hook) is never seen and plaintext ships to the SaaS - .io forces single-tenant; no per-tenant key isolation on that path - integrity_checking is forced only on the preset kwarg path, not via @cache(config=...) overrides (code fix tracked separately) - HKDF is keyed on tenant id, not namespace - compliance wording consolidated to one scope-reduction statement; SECURITY.md, serializers/encryption.md and backends/README.md aligned with it - headline backend=None examples no longer claim to reach Redis - residual "fail-closed" labels removed from the downgrade guard --- .secrets.baseline | 4 +- SECURITY.md | 2 +- docs/backends/README.md | 11 +-- docs/backends/cachekitio.md | 21 +++--- docs/features/zero-knowledge-encryption.md | 83 +++++++++++----------- docs/serializers/encryption.md | 3 +- src/cachekit/config/decorator.py | 28 +++++--- 7 files changed, 81 insertions(+), 71 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index c8c11857..42291e0b 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -231,7 +231,7 @@ "filename": "src/cachekit/config/decorator.py", "hashed_secret": "1a9a9d37d8305b0cd8353468065cf844259e1b1f", "is_verified": false, - "line_number": 576 + "line_number": 582 } ], "src/cachekit/serializers/interop_serializer.py": [ @@ -871,5 +871,5 @@ } ] }, - "generated_at": "2026-09-18T00:36:32Z" + "generated_at": "2026-09-19T07:56:12Z" } diff --git a/SECURITY.md b/SECURITY.md index 40a03602..378a614f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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)) |
🔐 Master Key Security diff --git a/docs/backends/README.md b/docs/backends/README.md index 949994a4..6efddac4 100644 --- a/docs/backends/README.md +++ b/docs/backends/README.md @@ -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_... ``` -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 diff --git a/docs/backends/cachekitio.md b/docs/backends/cachekitio.md index 22b1cd4c..743469c4 100644 --- a/docs/backends/cachekitio.md +++ b/docs/backends/cachekitio.md @@ -184,11 +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` + 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) +- 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**: @@ -210,15 +209,15 @@ zero-knowledge bytes on the wire — **but the failure mode is inverted**: 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. +- `@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, 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. +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): diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index 8801da96..630e6db7 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -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 ``` --- @@ -31,7 +31,7 @@ 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) ``` --- @@ -47,42 +47,51 @@ happens when it isn't, and which backend you actually reach. "Zero-knowledge" co | | `@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`) | +| **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). Calling `DecoratorConfig.io(backend=...)` directly raises -`TypeError` (duplicate keyword argument). To target any other backend, use a -different preset with an explicit `backend=`. +`.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 — "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)). +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()` → -> 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. +> 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 @@ -100,15 +109,6 @@ 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 @@ -249,7 +249,7 @@ There is deliberately **no opt-in flag** to let an encryption-enabled reader acc 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 @@ -294,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) @@ -441,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 @@ -489,7 +489,10 @@ it travels percent-encoded in the URL path (`/v1/cache/{key}`). The key carries 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 +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. diff --git a/docs/serializers/encryption.md b/docs/serializers/encryption.md index 988ea7ce..5c81eac6 100644 --- a/docs/serializers/encryption.md +++ b/docs/serializers/encryption.md @@ -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 diff --git a/src/cachekit/config/decorator.py b/src/cachekit/config/decorator.py index d64b3df1..9213a2b1 100644 --- a/src/cachekit/config/decorator.py +++ b/src/cachekit/config/decorator.py @@ -395,13 +395,17 @@ 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 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) + Note: Backend resolution is the same as every preset — explicit backend= kwarg (the only + order-independent tier), then set_default_backend() as read AT DECORATION TIME, 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). A provider error at first call is logged and the function runs + uncached — it does not raise. .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 on this preset path (the kwarg is discarded). + An override applied via @cache(config=DecoratorConfig.secure(...), integrity_checking=False) + is NOT re-forced. Args: master_key: Encryption master key (hex-encoded, minimum 32 bytes for AES-256) @@ -557,10 +561,12 @@ 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. + FAIL-OPEN caveat: CACHEKIT_MASTER_KEY is read when the decorator is applied + (import time). If it is absent then — including a key loaded later by dotenv + in main() or a vault startup hook — the same code silently caches plaintext to + the SaaS on every call. 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.) From 2e388d340b062f174ed9ed7b68befdb949a59c7d Mon Sep 17 00:00:00 2001 From: Mark S Date: Sun, 20 Sep 2026 14:14:12 +1000 Subject: [PATCH 09/10] docs(encryption): correct SaaS-selector precedence, SWR mechanism and L1-SWR claim; restore separated alerts (LAB-749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave-9 review findings, each verified against source first: - backends/README.md: the env block implied CACHEKIT_API_KEY takes precedence over the Redis selectors. It does not — two prefixed selectors raise ConfigurationError, which is then swallowed at first call, so a reader copying the block got a permanently uncached app. Block now shows them as exclusive. - zero-knowledge-encryption.md: the SWR caveat said "background thread"; that is the sync path only — async refresh is an asyncio task on the caller's loop. - zero-knowledge-encryption.md: dropped "(L1 SWR on in both)" — the object cache backing L1 SWR is built only in L1-only mode, so it is inert for both columns. - zero-knowledge-encryption.md: restored the WARNING and IMPORTANT alerts as two separate blocks with the example between them, reverting a merge that undid the deliberate separation b40b1d9 made; nothing enforces MD028 in this repo. - zero-knowledge-encryption.md: the swallowed-error path is now stated as uncached L1 included (the early return is upstream of the L1 lookup), and the last compliance pointer aimed at the path-selection table now targets the compliance section. - config/singleton.py: the self-heal comment named an import-time decorator as a case it fixes. It does not — the handler freezes encryption=False at decoration and no later settings re-read flips it. Comment now scopes itself. --- docs/backends/README.md | 16 +++++++++------ docs/features/zero-knowledge-encryption.md | 24 ++++++++++++---------- src/cachekit/config/singleton.py | 16 ++++++++++----- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/docs/backends/README.md b/docs/backends/README.md index 6efddac4..3b4a8edc 100644 --- a/docs/backends/README.md +++ b/docs/backends/README.md @@ -193,15 +193,19 @@ Call `set_default_backend(None)` to clear the default. Works with any backend (R ### 3. Environment Variable Auto-Detection (Lowest Priority) +Set **exactly one** of the prefixed selectors below — two or more is a +`ConfigurationError`, not a precedence order. Only the bare `REDIS_URL` fallback +may coexist with a prefixed selector. + ```bash -# Primary: CACHEKIT_REDIS_URL -CACHEKIT_REDIS_URL=redis://prod.example.com:6379 +# Pick ONE prefixed selector: +CACHEKIT_REDIS_URL=redis://prod.example.com:6379 # Redis +# CACHEKIT_API_KEY=ck_live_... # managed SaaS (CachekitIO) +# CACHEKIT_MEMCACHED_SERVERS=cache.example.com:11211 +# CACHEKIT_FILE_CACHE_DIR=/var/cache/cachekit -# Fallback: REDIS_URL +# Fallback when no prefixed selector is set — never conflicts: REDIS_URL=redis://localhost:6379 - -# Managed SaaS (takes precedence over the Redis selectors) -CACHEKIT_API_KEY=ck_live_... ``` If no explicit backend and no module-level default, cachekit auto-detects a backend from the environment at the function's **first call**. diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index 630e6db7..67390d03 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -51,7 +51,7 @@ happens when it isn't, and which backend you actually reach. "Zero-knowledge" co | 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 | +| Backend SWR (`stale_ttl`) | Off unless requested | On by default (`stale_ttl` sized from `ttl`); the refresh re-runs the function after the response has been served — on a daemon thread for sync functions, as an `asyncio` task on the caller's loop for async ones — 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 @@ -80,18 +80,11 @@ canonical statement. > 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**. +> `client_creation` failure and the function runs **uncached on every call**, +> L1 included — that early return sits upstream of the L1 lookup. > 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 @@ -109,6 +102,15 @@ 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 @@ -728,7 +730,7 @@ export default { **Benefits**: - ✅ Backend compromise doesn't expose user data - ✅ Multi-tenant isolation (per-tenant encryption keys) -- ✅ 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)) +- ✅ Supports GDPR/HIPAA/PCI-DSS arguments on the fail-closed path (`@cache.secure` + explicit backend — see [Compliance Implications](#compliance-implications)) - ✅ Works with any data type (JSON, MessagePack, DataFrames) --- diff --git a/src/cachekit/config/singleton.py b/src/cachekit/config/singleton.py index 49bf7291..6b66960c 100644 --- a/src/cachekit/config/singleton.py +++ b/src/cachekit/config/singleton.py @@ -50,11 +50,17 @@ def get_settings() -> CachekitConfig: instance = _settings_instance if instance is not None: # Self-heal the keyless-then-key-set ordering trap (#195): if the config was first built - # before CACHEKIT_MASTER_KEY entered the environment (e.g. an import-time cache decorator - # evaluated before the app loaded its secrets), it froze master_key=None — encryption would - # then silently never activate. Re-read once the key appears, so it turns on without an - # explicit reset_settings(). Idempotent: after the rebuild master_key is set, so this never - # fires again (no per-call churn once a key is present). + # before CACHEKIT_MASTER_KEY entered the environment, it froze master_key=None. Re-read + # once the key appears, so a LATER settings consumer sees it without an explicit + # reset_settings(). Idempotent: after the rebuild master_key is set, so this never fires + # again (no per-call churn once a key is present). + # + # SCOPE: this heals the settings singleton, NOT an already-decorated function. A cache + # decorator evaluated at import time builds its CacheSerializationHandler there, and that + # handler freezes encryption=False once (cache_handler.py, the `if encryption is None` + # auto-detect); no later settings re-read flips it. A key loaded after import (dotenv in + # main(), a vault startup hook) therefore leaves those functions caching PLAINTEXT — see + # docs/features/zero-knowledge-encryption.md, "Which Path". if instance.master_key is None and os.environ.get("CACHEKIT_MASTER_KEY"): with _settings_lock: # Re-read the global under the lock: a peer may have rebuilt it (key now set) or From 53b187445aec627e141ac68639ba50f800a30c11 Mon Sep 17 00:00:00 2001 From: Mark S Date: Sun, 20 Sep 2026 15:12:22 +1000 Subject: [PATCH 10/10] docs(encryption): remove false selector-precedence framing; correct SWR timing and L1 claim (LAB-749) Wave-10 review findings, each verified against source first: - The "A -> B; then C; else D" framing in the ZK callout and the .secure docstring taught a fallthrough between the four prefixed selectors that does not exist. Both now say auto-detect picks whichever SINGLE selector is set, two or more raises, none set falls back to REDIS_URL. The docstring keeps the consequence and points at the backend guide for the full rules. - Root cause of that paraphrase, fixed once: the DefaultBackendProvider docstring said "Priority order:" over a numbered 1-5 list while its first sentence called the signal single and unambiguous. Now an unnumbered mutually-exclusive list that states the conflict rule inline. - backends/README.md: the CACHEKIT_MEMCACHED_SERVERS example could not parse -- the field is list[str] and pydantic-settings JSON-decodes it, so the bare host:port form raises SettingsError, which is then swallowed at first call. Now the JSON-array form. Also says the conflict error is swallowed and logged rather than raised, and the REDIS_URL fallback line is commented out so the block is a valid .env as written. - The swallowed-error note claimed the early return sits upstream of the L1 lookup. True for sync only: on the non-interop async path the pre-L1 backend resolve is gated on interop, so L1 is checked first. Restated as the outcome -- uncached with L1 never populated -- which holds on both paths. - The SWR caveat said the refresh runs after the response has been served. It is scheduled five lines before the return, so it runs concurrently with the rest of the request; the hazard is a shared non-thread-safe session corrupting quietly, not a use-after-close that raises. - Compliance wording: the .secure docstring listed "GDPR compliance" as a use case, the serializer index promised "GDPR/HIPAA/PCI-DSS compliance", and the compliance section's own checkmarks said "satisfies" while its caveat said "may reduce, subject to assessment". All three now match the caveat. --- docs/backends/README.md | 13 ++++++------ docs/features/zero-knowledge-encryption.md | 23 ++++++++++++---------- docs/serializers/README.md | 2 +- src/cachekit/backends/provider.py | 15 ++++++++------ src/cachekit/config/decorator.py | 18 ++++++++--------- 5 files changed, 39 insertions(+), 32 deletions(-) diff --git a/docs/backends/README.md b/docs/backends/README.md index 3b4a8edc..1c66adec 100644 --- a/docs/backends/README.md +++ b/docs/backends/README.md @@ -193,19 +193,20 @@ Call `set_default_backend(None)` to clear the default. Works with any backend (R ### 3. Environment Variable Auto-Detection (Lowest Priority) -Set **exactly one** of the prefixed selectors below — two or more is a -`ConfigurationError`, not a precedence order. Only the bare `REDIS_URL` fallback -may coexist with a prefixed selector. +Set **exactly one** of the prefixed selectors below. Two or more produces a +`ConfigurationError` — but at first call that error is **swallowed and logged**, not +raised, so the symptom is a function that silently never caches. There is no precedence +between them. Only the bare `REDIS_URL` fallback may coexist with a prefixed selector. ```bash # Pick ONE prefixed selector: CACHEKIT_REDIS_URL=redis://prod.example.com:6379 # Redis # CACHEKIT_API_KEY=ck_live_... # managed SaaS (CachekitIO) -# CACHEKIT_MEMCACHED_SERVERS=cache.example.com:11211 +# CACHEKIT_MEMCACHED_SERVERS=["cache.example.com:11211"] # JSON array, not a bare string # CACHEKIT_FILE_CACHE_DIR=/var/cache/cachekit -# Fallback when no prefixed selector is set — never conflicts: -REDIS_URL=redis://localhost:6379 +# Fallback, used only when no prefixed selector above is set — never conflicts: +# REDIS_URL=redis://localhost:6379 ``` If no explicit backend and no module-level default, cachekit auto-detects a backend from the environment at the function's **first call**. diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index 67390d03..915e4486 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -51,7 +51,7 @@ happens when it isn't, and which backend you actually reach. "Zero-knowledge" co | 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 | On by default (`stale_ttl` sized from `ttl`); the refresh re-runs the function after the response has been served — on a daemon thread for sync functions, as an `asyncio` task on the caller's loop for async ones — so it must not depend on request-scoped resources (a per-request DB session). `stale_ttl=0` opts out | +| Backend SWR (`stale_ttl`) | Off unless requested | On by default (`stale_ttl` sized from `ttl`); the refresh re-runs the function **concurrently with the remainder of the request** (scheduled before the value is returned) — on a daemon thread for sync functions, as an `asyncio` task on the caller's loop for async ones — so it must not touch request-scoped **or non-thread-safe** resources: a per-request DB session shared with the still-running request is the common trap, and it corrupts quietly rather than raising. `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 @@ -71,17 +71,20 @@ 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 +> read at decoration time** → environment auto-detect at **first call**. Auto-detect +> picks whichever **single** prefixed selector is set (`CACHEKIT_API_KEY` → cachekit.io +> SaaS, `CACHEKIT_REDIS_URL` → Redis, `CACHEKIT_MEMCACHED_SERVERS`, `CACHEKIT_FILE_CACHE_DIR`) +> — two or more set at once raises `ConfigurationError`, there is no fallthrough between +> them; none set → `REDIS_URL` / localhost Redis. 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**, -> L1 included — that early return sits upstream of the L1 lookup. +> `client_creation` failure and the function runs **uncached on every call**, with +> **L1 never populated** — so a cold cache stays cold (an async function with an +> already-warm L1 can still serve those hits until they expire). > Alert on `client_creation` failures. When the SaaS is the requirement, pass > `backend=CachekitIOBackend()` explicitly — auditable in code and immune to > environment drift. @@ -587,17 +590,17 @@ didn't recently disable encryption for that function, investigate. > [Which Path](#which-path-cachesecure-vs-cacheio--cachekit_master_key). ### GDPR -- ✅ Encryption satisfies "processing security" requirement -- ✅ Client-side encryption satisfies "technical measures" +- ✅ Encryption supports the "processing security" requirement +- ✅ Client-side encryption supports the "technical measures" requirement - ⚠️ Key management still required (rotation, access control) ### HIPAA -- ✅ AES-256-GCM satisfies encryption requirement +- ✅ AES-256-GCM supports the encryption requirement - ⚠️ Audit logging required (access to decrypted data) - ⚠️ Key management plan required ### PCI-DSS -- ✅ Encryption satisfies "encryption at rest" requirement +- ✅ Encryption supports the "encryption at rest" requirement - ⚠️ Key management plan required - ⚠️ Regular key rotation required diff --git a/docs/serializers/README.md b/docs/serializers/README.md index ad5a6833..8c3ba0b3 100644 --- a/docs/serializers/README.md +++ b/docs/serializers/README.md @@ -16,7 +16,7 @@ Each serializer integrates transparently with the `@cache` decorator. You can co | [AutoSerializer](auto.md) | Fast | Python-only — preserves sets, frozensets, datetime, UUID, NumPy, pandas | | [OrjsonSerializer](orjson.md) | Very Fast (JSON) | JSON-heavy APIs, cross-language interop, human-readable | | [ArrowSerializer](arrow.md) | Very Fast (DataFrames) | Large pandas/polars DataFrames (10K+ rows) | -| [EncryptionWrapper](encryption.md) | Adds ~3-5 μs | Zero-knowledge caching, GDPR/HIPAA/PCI-DSS compliance | +| [EncryptionWrapper](encryption.md) | Adds ~3-5 μs | Zero-knowledge caching; may reduce GDPR/HIPAA/PCI DSS scope | | [Custom Serializers](custom.md) | Varies | Specialized data types not covered above | > **OrjsonSerializer** requires the `[json]` extra: `pip install 'cachekit[json]'` (or `uv add 'cachekit[json]'`). diff --git a/src/cachekit/backends/provider.py b/src/cachekit/backends/provider.py index c67fe336..15f54b64 100644 --- a/src/cachekit/backends/provider.py +++ b/src/cachekit/backends/provider.py @@ -154,12 +154,15 @@ async def get_async_client(self) -> redis_async.Redis: class DefaultBackendProvider(BackendProviderInterface): """Default backend provider with env-based auto-detection. - Selection is by a single, unambiguous environment signal. Priority order: - 1. CACHEKIT_API_KEY → CachekitIOBackend (SaaS) - 2. CACHEKIT_REDIS_URL → RedisBackend - 3. CACHEKIT_MEMCACHED_SERVERS → MemcachedBackend - 4. CACHEKIT_FILE_CACHE_DIR → FileBackend - 5. REDIS_URL, or nothing set → RedisBackend (12-factor / localhost default) + Selection is by a single, unambiguous environment signal. These are mutually + exclusive selectors, NOT a precedence chain — set exactly one: + - CACHEKIT_API_KEY → CachekitIOBackend (SaaS) + - CACHEKIT_REDIS_URL → RedisBackend + - CACHEKIT_MEMCACHED_SERVERS → MemcachedBackend + - CACHEKIT_FILE_CACHE_DIR → FileBackend + Setting two or more raises ConfigurationError (see below); it does not fall through + to the next one. With none of them set: REDIS_URL, or nothing → RedisBackend + (12-factor / localhost default). Setting more than one of the four prefixed selectors (1-4) raises ``ConfigurationError`` — auto-detection must be unambiguous; pass diff --git a/src/cachekit/config/decorator.py b/src/cachekit/config/decorator.py index 9213a2b1..4adfd102 100644 --- a/src/cachekit/config/decorator.py +++ b/src/cachekit/config/decorator.py @@ -392,17 +392,17 @@ def production(cls, **kwargs: Any) -> DecoratorConfig: def secure(cls, master_key: str, tenant_extractor: Callable[..., str] | None = None, **kwargs: Any) -> DecoratorConfig: """Security profile: Encryption REQUIRED, encrypted-at-rest everywhere, full audit trail, integrity NON-NEGOTIABLE. - Use cases: PII, medical data, financial records, GDPR compliance + Use cases: PII, medical data, financial records, and regulated-data caching where + encryption may reduce compliance scope (see docs/features/zero-knowledge-encryption.md) Architecture: Both L1 and L2 store encrypted bytes (encrypt-at-rest everywhere) - Note: Backend resolution is the same as every preset — explicit backend= kwarg (the only - order-independent tier), then set_default_backend() as read AT DECORATION TIME, 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). A provider error at first call is logged and the function runs - uncached — it does not raise. .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: .secure does NOT pin the SaaS. Backend resolution is the same as every preset: + explicit backend= kwarg (the only order-independent tier), then set_default_backend() + as read AT DECORATION TIME, then environment auto-detect at FIRST CALL. So with + REDIS_URL set and CACHEKIT_API_KEY unset, encrypted values silently go to Redis, and + a provider error at first call is logged, not raised — the function then runs uncached. + When the SaaS is a requirement, pass backend=CachekitIOBackend() explicitly. + Full selector rules (set exactly one): docs/backends/README.md. Note: integrity_checking is forced to True on this preset path (the kwarg is discarded). An override applied via @cache(config=DecoratorConfig.secure(...), integrity_checking=False) is NOT re-forced.