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/.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..22685ef 100644 --- a/docs/backends/cachekitio.md +++ b/docs/backends/cachekitio.md @@ -198,6 +198,33 @@ CACHEKIT_API_KEY=ck_live_... See [Zero-Knowledge Encryption](../features/zero-knowledge-encryption.md) for full details on key derivation and serialization format implications. +### `.secure` + explicit backend vs `.io()` + env var — which one? + +There is a second path to encrypted SaaS caching: `@cache.io()` with +`CACHEKIT_MASTER_KEY` set. Encryption is then auto-detected downstream — the same +zero-knowledge bytes on the wire — **but the failure mode is inverted**: + +- `@cache.secure(backend=CachekitIOBackend())` — **fails closed.** Encryption is + forced on in code; a missing master key (param or `CACHEKIT_MASTER_KEY`) raises + `ValueError` at decoration time. No plaintext **values** can ever reach the + backend (cache keys and the frame header stay plaintext by design). +- `@cache.io()` + `CACHEKIT_MASTER_KEY` — **fails open.** If the env var is absent, + the same code silently caches **plaintext** to the SaaS. + +Use `.secure` + explicit backend when encryption is a security requirement (PII, +PHI, compliance arguments — a HIPAA/PCI DSS scope-*reduction* argument can only be +made on this path, and even then is subject to assessment and your surrounding +controls; encryption alone does not remove regulated data from scope). Use `.io()` ++ env when encryption is a fleet-wide opt-in convenience and plaintext caching is +an acceptable state. + +Two caveats, covered in depth in +[Which Path](../features/zero-knowledge-encryption.md#which-path-cachesecure-vs-cacheio--cachekit_master_key): +`.secure` does **not** pin the SaaS backend (env auto-detect can silently route +encrypted values to Redis — pass `backend=` explicitly, as above), and fail-closed +on a *missing key* is separate from `fail_closed` on a *decrypt failure*, which +defaults to off. + ## See Also - [Backend Guide](README.md) — Backend comparison and resolution priority diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index d350aaf..0c5ceec 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -36,6 +36,79 @@ 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` created by the preset — `backend=` is unsupported, see note below; requires `CACHEKIT_API_KEY` at decoration time | +| Tenant mode | `single_tenant_mode` handled automatically | Handled automatically (auto-detect path) | +| Backend SWR (`stale_ttl`) | Off unless requested (L1 SWR on in both) | On by default (`stale_ttl` sized from `ttl`) | + +**`@cache.io()` does not take a `backend=` argument.** The preset always +constructs its own `CachekitIOBackend`: a non-`None` `backend=` passed to the +decorator is silently discarded, and `backend=None` flips the wrapper into +L1-only mode (in-process memory — the SaaS is never contacted, despite the +`.io` name). Calling `DecoratorConfig.io(backend=...)` directly raises +`TypeError` (duplicate keyword argument). To target any other backend, use a +different preset with an explicit `backend=`. + +**Rule of thumb**: encryption as a **security requirement** → `@cache.secure` + +explicit backend. The intent is auditable in code. Encryption as a **fleet-wide +opt-in convenience** → set `CACHEKIT_MASTER_KEY` and let auto-detect do it (this +applies to every preset, not just `.io`). Compliance arguments — "the SaaS only +ever stores ciphertext" — should only be hung on the fail-closed path: on the +auto-detect path, one missing env var quietly puts plaintext on the backend. Even +on the fail-closed path, client-side encryption may *reduce* HIPAA/PCI DSS scope +subject to assessment and your surrounding controls — it does not remove regulated +data from scope on its own (see [Compliance Implications](#compliance-implications)). + +> [!WARNING] +> **`@cache.secure` does NOT pin the SaaS backend.** Backend resolution is the +> same lookup as every preset: explicit `backend=` → `set_default_backend()` → +> environment auto-detect at **first call** (`CACHEKIT_API_KEY` → cachekit.io SaaS; +> `CACHEKIT_REDIS_URL` → Redis; then the Memcached/File selectors; else +> `REDIS_URL` / localhost Redis fallback). Two consequences: (1) in a 12-factor +> environment where `REDIS_URL` is set and `CACHEKIT_API_KEY` is not, +> `@cache.secure` **silently encrypts to Redis instead of the SaaS**; (2) because +> resolution is lazy, a backend misconfiguration (e.g. two auto-detect selectors +> set at once) surfaces as a `ConfigurationError` at first call, not at import. +> When the SaaS is the requirement, pass `backend=CachekitIOBackend()` explicitly +> — auditable in code and immune to environment drift. + +```python notest +from cachekit import cache +from cachekit.backends.cachekitio import CachekitIOBackend + +# Security requirement: fail-closed, auditable, explicitly targets the SaaS +@cache.secure(backend=CachekitIOBackend(), ttl=3600) +def get_patient_record(patient_id: str): + return fetch_phi(patient_id) # illustrative + +# Fleet-wide convenience: encrypts iff CACHEKIT_MASTER_KEY is set, +# silently plaintext if it is not +@cache.io(ttl=300) +def get_dashboard_stats(org_id: str): + return compute_stats(org_id) # illustrative +``` + +> [!IMPORTANT] +> **Two separate fail-closed guarantees — don't conflate them.** `.secure` is +> fail-closed on a *missing key* (decoration-time `ValueError`). But `fail_closed` +> on a *decrypt failure* (e.g. an AES-GCM auth-tag mismatch at read time) is a +> separate tri-state setting that defers to `CACHEKIT_ENCRYPTION_FAIL_CLOSED`, +> which **defaults to `False`** — so even `.secure` fails *open* on tampered or +> key-mismatched entries (miss + recompute) unless you opt in. See +> [Corruption vs Tamper: Telemetry and Fail-Closed Mode](#corruption-vs-tamper-telemetry-and-fail-closed-mode). + +--- + ## What It Does **Encryption pipeline** (works with ANY serializer): @@ -121,7 +194,7 @@ def get_user_ssn(user_id): ### Missing Master Key > [!WARNING] -> `cache.secure` requires a master key. Omitting it raises a `ConfigurationError` at decoration time, not at call time. +> `cache.secure` requires a master key. Omitting it raises a `ValueError` at decoration time, not at call time — this is the fail-closed guarantee that distinguishes `.secure` from env-var auto-detection (see [Which Path](#which-path-cachesecure-vs-cacheio--cachekit_master_key) above). ```python notest # Forget to set master_key parameter @@ -160,12 +233,14 @@ export CACHEKIT_PREVIOUS_MASTER_KEYS=old_key # decrypt-only (comma-separat ### Enabling Encryption on an Existing (Plaintext) Cache When you turn encryption on over a cache that already holds plaintext entries, those -entries are **rejected, never read**. The read path fails closed: the entry raises a -`SerializationError`, the caller treats it as a miss, evicts the stale entry, recomputes, -and re-stores the value encrypted. Migration is therefore lazy and self-healing: +entries are **rejected, never read**: the entry raises a `SerializationError` +internally, the caller treats it as a miss, evicts the stale entry, recomputes, +and re-stores the value encrypted. (This rejection is unconditional — it is not +governed by the `fail_closed` setting, which applies only to authenticated-decrypt +failures.) Migration is therefore lazy and self-healing: ```text -read plaintext entry → SerializationError (fail closed) → evict → recompute → re-store encrypted +read plaintext entry → SerializationError (rejected, never deserialized) → evict → recompute → re-store encrypted ``` There is deliberately **no opt-in flag** to let an encryption-enabled reader accept @@ -255,7 +330,7 @@ def get_patient_records(hospital_id: int): ) df = get_patient_records(42) -# DataFrame encrypted client-side, HIPAA-compliant zero-knowledge storage +# DataFrame encrypted client-side — zero-knowledge storage ``` ### Multi-Tenant Isolation @@ -381,7 +456,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 @@ -622,7 +697,6 @@ export default { // NEVER sees plaintext (no decryption key) await KV.put(key, value); - // Compliance: GDPR, HIPAA, PCI-DSS satisfied // Backend cannot read user data even if compromised return new Response("OK"); } @@ -632,7 +706,7 @@ export default { **Benefits**: - ✅ Backend compromise doesn't expose user data - ✅ Multi-tenant isolation (per-tenant encryption keys) -- ✅ GDPR/HIPAA/PCI-DSS compliance out of the box +- ✅ Supports GDPR/HIPAA/PCI-DSS arguments on the fail-closed path (`@cache.secure` + explicit backend — see [Which Path](#which-path-cachesecure-vs-cacheio--cachekit_master_key)) - ✅ Works with any data type (JSON, MessagePack, DataFrames) --- diff --git a/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/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.) 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]]