From d43375bb98a0c7cab0201e774a25898798b15001 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 04:41:57 +1000 Subject: [PATCH 1/5] =?UTF-8?q?docs(encryption):=20.secure=20vs=20.io=20+?= =?UTF-8?q?=20CACHEKIT=5FMASTER=5FKEY=20=E2=80=94=20fail-closed=20vs=20fai?= =?UTF-8?q?l-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 809c294..cf32753 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 98d6b24..24cf70b 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 d350aaf..47c8a9a 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 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.) From 8b8ecd5f754ae941eaf20df287129a4c89635bd9 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 04:57:22 +1000 Subject: [PATCH 2/5] =?UTF-8?q?docs:=20apply=20expert-panel=20findings=20?= =?UTF-8?q?=E2=80=94=20plaintext-values=20precision,=20SWR=20row,=20fail-c?= =?UTF-8?q?losed=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 24cf70b..ef88436 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 47c8a9a..ff94c89 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 3/5] =?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 3860e79..cc5bbec 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 4f9df25..0281576 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 4/5] 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 ef88436..22685ef 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 ff94c89..9c1b109 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 5/5] 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 927d2ec..cb69e0f 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 eaa91e0..f249f32 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 9c1b109..0c5ceec 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