diff --git a/.secrets.baseline b/.secrets.baseline index 82424789..3cb0495a 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": 585 } ], "src/cachekit/serializers/interop_serializer.py": [ @@ -871,5 +871,5 @@ } ] }, - "generated_at": "2026-09-17T05:12:11Z" + "generated_at": "2026-09-20T09:37:13Z" } 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..40e32923 100644 --- a/docs/backends/README.md +++ b/docs/backends/README.md @@ -193,20 +193,36 @@ Call `set_default_backend(None)` to clear the default. Works with any backend (R ### 3. Environment Variable Auto-Detection (Lowest Priority) -```bash -# Primary: CACHEKIT_REDIS_URL -CACHEKIT_REDIS_URL=redis://prod.example.com:6379 +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. + +That log line is the only signal you get, so watch for it at `WARNING` on the +`cachekit.decorators.orchestrator` logger — it repeats on every call, and the +misconfiguration never self-heals: -# Fallback: REDIS_URL -REDIS_URL=redis://localhost:6379 +```text +Cache operation 'client_creation' failed for key '': ConfigurationError +``` + +```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"]' # JSON array; keep the outer single quotes +# CACHEKIT_FILE_CACHE_DIR=/var/cache/cachekit + +# 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 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 98d6b249..743469c4 100644 --- a/docs/backends/cachekitio.md +++ b/docs/backends/cachekitio.md @@ -184,9 +184,10 @@ def get_user_profile(user_id: str) -> dict: **Why this matters**: - `@cache.secure` applies AES-256-GCM client-side encryption before any data leaves the process -- Per-tenant key derivation via HKDF — cryptographic isolation between namespaces +- Per-tenant key derivation via HKDF keyed on the tenant id — namespaces within a tenant share one derived key; cross-namespace separation comes from the AAD cache-key binding, not from key derivation - The SaaS backend is a zero-knowledge conduit: it stores whatever bytes arrive -- With `@cache.secure`: SaaS is out of scope for HIPAA/PCI (stores only ciphertext) +- With `@cache.secure` + explicit backend: the SaaS holds only ciphertext — a scope-*reduction* + argument, not a guarantee; see [Compliance Implications](../features/zero-knowledge-encryption.md#compliance-implications) - Without `@cache.secure`: SaaS stores plaintext, may be in compliance scope **Requirements**: @@ -198,6 +199,33 @@ CACHEKIT_API_KEY=ck_live_... See [Zero-Knowledge Encryption](../features/zero-knowledge-encryption.md) for full details on key derivation and serialization format implications. +### `.secure` + explicit backend vs `.io()` + env var — which one? + +There is a second path to encrypted SaaS caching: `@cache.io()` with +`CACHEKIT_MASTER_KEY` set. Encryption is then auto-detected downstream — the same +zero-knowledge bytes on the wire — **but the failure mode is inverted**: + +- `@cache.secure(backend=CachekitIOBackend())` — **fails closed.** Encryption is + forced on in code; a missing master key (param or `CACHEKIT_MASTER_KEY`) raises + `ValueError` at decoration time. No plaintext **values** can ever reach the + backend (cache keys and the frame header stay plaintext by design). +- `@cache.io()` + `CACHEKIT_MASTER_KEY` — **fails open.** If the env var is absent + **at decoration time**, the same code silently caches **plaintext** to the SaaS — + and a key loaded later (dotenv in `main()`, a vault startup hook) is never seen. + +Use `.secure` + explicit backend when encryption is a security requirement (PII, +PHI, any compliance argument — see +[Compliance Implications](../features/zero-knowledge-encryption.md#compliance-implications) +for the canonical statement). Use `.io()` + env when encryption is a fleet-wide +opt-in convenience and plaintext caching is an acceptable state. + +Two caveats, covered in depth in +[Which Path](../features/zero-knowledge-encryption.md#which-path-cachesecure-vs-cacheio--cachekit_master_key): +`.secure` does **not** pin the SaaS backend (env auto-detect can silently route +encrypted values to Redis — pass `backend=` explicitly, as above), and fail-closed +on a *missing key* is separate from `fail_closed` on a *decrypt failure*, which +defaults to off. + ## See Also - [Backend Guide](README.md) — Backend comparison and resolution priority diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index d350aafb..50d45523 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -6,14 +6,29 @@ ## 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 +import os +from cachekit.backends.redis import RedisBackend + +@cache.secure(ttl=300, master_key=os.environ["CACHEKIT_MASTER_KEY"], backend=RedisBackend("redis://localhost:6379")) 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) # AES-256-GCM before it leaves the process ``` +> [!WARNING] +> **Encryption requires a backend. `backend=None` does not encrypt anything.** +> `backend=None` selects L1-only mode, which stores **live Python object +> references** in process memory — it never serializes, so the encryption layer is +> never reached and the master key is accepted, validated, and then never used. +> `@cache.secure(master_key=..., backend=None)` raises nothing and encrypts nothing: +> the values stay readable in a heap or core dump. The same object is also handed to +> every caller, so mutating a returned value corrupts the cached entry for everyone +> else. Use L1-only mode for non-sensitive data; for encrypted caching pass a real +> backend (`RedisBackend`, `CachekitIOBackend`, â€Ķ), where L1 then holds ciphertext +> like L2 does. + --- ## Quick Start @@ -22,20 +37,105 @@ Enable encryption with single decorator: ```python notest from cachekit import cache +from cachekit.backends.redis import RedisBackend -# Set master key (hex-encoded) import os -os.environ["CACHEKIT_MASTER_KEY"] = "a" * 64 # 32 bytes -@cache.secure(ttl=300, master_key="a" * 64, backend=None) # AES-256-GCM enabled +# The key comes from the environment, never from source. +# Generate once and store it in your secret manager: +# export CACHEKIT_MASTER_KEY=$(openssl rand -hex 32) + +# A backend is required for encryption — see the warning above on backend=None +@cache.secure(ttl=300, master_key=os.environ["CACHEKIT_MASTER_KEY"], backend=RedisBackend("redis://localhost:6379")) 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 in both L1 and L2 ``` --- +## Which Path: `@cache.secure` vs `@cache.io` + `CACHEKIT_MASTER_KEY` + +There are two real, shipped paths to encrypted caching on the cachekit.io SaaS. Both are +zero-knowledge on the wire **when a master key is present** — the difference is what +happens when it isn't, and which backend you actually reach. "Zero-knowledge" covers cached +**values**: the cache key and frame header stay cleartext on both paths (see +[Accepted Exposure](#cleartext-frame-header-fields-accepted-exposure)). + +| | `@cache.secure(backend=CachekitIOBackend())` | `@cache.io()` + `CACHEKIT_MASTER_KEY` env | +|---|---|---| +| Encryption | Forced ON in code (`EncryptionConfig.enabled=True`) | Auto-detected from the env var (tri-state `enabled=None`) | +| **No master key present** | **Fails closed** — raises `ValueError` at decoration time (the `CACHEKIT_MASTER_KEY` fallback is read then, at import) | **Fails open** — silently caches plaintext to the SaaS. The key is read **at decoration time**: one loaded later (dotenv in `main()`, a startup vault hook) is never seen, and every call ships plaintext | +| Integrity checking | Forced `True` on the preset path; **not** re-forced when you pass `integrity_checking=` alongside `@cache(config=DecoratorConfig.secure(...))` | On by preset default | +| Backend | Pinned **only** by the explicit `backend=` shown — omit it and resolution falls to env auto-detect (footgun below) | `CachekitIOBackend` created by the preset — `backend=` is unsupported, see note below; requires `CACHEKIT_API_KEY` at decoration time | +| Tenant mode | `single_tenant_mode` derived from `tenant_extractor`; per-tenant HKDF derivation exists but is **not a tenancy boundary** — see Multi-Tenant Isolation | **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 **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. Arguments are deep-copied before scheduling, so a session passed *as an argument* is never shared — but a non-copyable argument silently skips the refresh entirely (logged at DEBUG) — on an instance method `self` is that argument, so a service class holding a lock, a client or an open connection disables SWR permanently and quietly. The sharing route that does bite is a `ContextVar`-bound session, which the context snapshot deliberately carries into the refresh. `stale_ttl=0` opts out | + +**`@cache.io()` does not take a `backend=` argument.** The preset always +constructs its own `CachekitIOBackend`: a non-`None` `backend=` passed to the +decorator is silently discarded, and `backend=None` flips the wrapper into +L1-only mode (in-process memory — the SaaS is never contacted, despite the +`.io` name). To target any other backend, use a different preset with an +explicit `backend=`. + +**Rule of thumb**: encryption as a **security requirement** → `@cache.secure` + +explicit backend. The intent is auditable in code. Encryption as a **fleet-wide +opt-in convenience** → set `CACHEKIT_MASTER_KEY` and let auto-detect do it (this +applies to every preset, not just `.io`). Compliance arguments belong on the +fail-closed path only, and even there they are scope-*reduction* arguments, not +guarantees — see [Compliance Implications](#compliance-implications) for the one +canonical statement. + +> [!WARNING] +> **`@cache.secure` does NOT pin the SaaS backend.** Backend resolution is the +> same lookup as every preset: explicit `backend=` → `set_default_backend()` **as +> read at decoration time** → environment auto-detect at **first call**. 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) for a provider-resolved decorator — `.secure` without `backend=`, `.production`, +> `.minimal`, bare `@cache`; **never `.io`**, which constructs its own backend at +> decoration and never consults the provider — 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**, with +> **L1 never populated** — so a cold cache stays cold. +> Alert on `client_creation_failed`. 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): @@ -65,7 +165,7 @@ Python object (plaintext, in-app only) - **AES-256-GCM**: Authenticated encryption, 256-bit key - **Client-side**: Encryption happens in Python, before Redis - **Master key**: CACHEKIT_MASTER_KEY environment variable -- **Per-tenant isolation**: Optional key derivation for multi-tenant +- **Per-tenant key derivation**: Optional, and *not* a tenancy boundary on its own (see Multi-Tenant Isolation) - **Nonce uniqueness**: Counter-based, prevents nonce reuse - **Authentication**: GCM mode prevents tampering @@ -106,11 +206,13 @@ Python object (plaintext, in-app only) **Mitigation**: Use standard @cache for non-sensitive data: ```python notest +from cachekit.backends.redis import RedisBackend +import os @cache(ttl=300, backend=None) # No encryption, faster def get_public_prices(item_id): return db.get_price(item_id) # illustrative - db not defined -@cache.secure(ttl=300, master_key="a" * 64, backend=None) # Encryption, slower, for sensitive data +@cache.secure(ttl=300, master_key=os.environ["CACHEKIT_MASTER_KEY"], backend=RedisBackend("redis://localhost:6379")) # Encryption, slower def get_user_ssn(user_id): return db.get_ssn(user_id) # illustrative - db not defined ``` @@ -121,7 +223,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,19 +262,21 @@ export CACHEKIT_PREVIOUS_MASTER_KEYS=old_key # decrypt-only (comma-separat ### Enabling Encryption on an Existing (Plaintext) Cache When you turn encryption on over a cache that already holds plaintext entries, those -entries are **rejected, never read**. The read path fails closed: the entry raises a -`SerializationError`, the caller treats it as a miss, evicts the stale entry, recomputes, -and re-stores the value encrypted. Migration is therefore lazy and self-healing: +entries are **rejected, never read**: the entry raises a `SerializationError` +internally, the caller treats it as a miss, evicts the stale entry, recomputes, +and re-stores the value encrypted. (This rejection is unconditional — it is not +governed by the `fail_closed` setting, which applies only to authenticated-decrypt +failures.) Migration is therefore lazy and self-healing: ```text -read plaintext entry → SerializationError (fail closed) → evict → recompute → re-store encrypted +read plaintext entry → SerializationError (rejected, never deserialized) → evict → recompute → re-store encrypted ``` There is deliberately **no opt-in flag** to let an encryption-enabled reader accept plaintext entries. The frame header is not authenticated, so a plaintext entry forged by an attacker with backend write access is indistinguishable from a legacy one — any "accept plaintext" escape hatch would reintroduce the encryption-downgrade attack the -fail-closed read path exists to prevent. If you need to read plaintext entries, use a +downgrade-protected read path exists to prevent. If you need to read plaintext entries, use a handler with `encryption=False` (which never had keys to protect). For large caches, choose between lazy migration and eager eviction based on your @@ -191,11 +295,14 @@ redis-cli --scan --pattern 'ns::*' | xargs -r redis-cli DEL ### L1 Cache Conflict ```python notest -@cache.secure(ttl=300, master_key="a" * 64, backend=None) # Encryption + L1 cache (stores encrypted bytes) +from cachekit.backends.redis import RedisBackend +import os +@cache.secure(ttl=300, master_key=os.environ["CACHEKIT_MASTER_KEY"], backend=RedisBackend("redis://localhost:6379")) def get_sensitive_data(): - # L1 cache enabled: stores encrypted bytes (~50ns hits vs 2-7ms Redis) + # WITH a backend, L1 stores encrypted bytes (~50ns hits vs 2-7ms Redis) # Encryption is orthogonal: wraps any serializer, applies to both L1 and L2 # Both layers store encrypted bytes (encrypt-at-rest everywhere) + # With backend=None instead, NONE of the above holds — see the warning at the top return fetch_sensitive_data() # illustrative - fetch_sensitive_data not defined ``` @@ -210,14 +317,16 @@ export CACHEKIT_MASTER_KEY=$(openssl rand -hex 32) ``` ```python notest +import os from cachekit import cache +from cachekit.backends.redis import RedisBackend -@cache.secure(ttl=3600, master_key="a" * 64, backend=None) # AES-256-GCM with MessagePack +@cache.secure(ttl=3600, master_key=os.environ["CACHEKIT_MASTER_KEY"], backend=RedisBackend("redis://localhost:6379")) 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 ``` ### Encrypted JSON (Zero-Knowledge API Caching) @@ -255,34 +364,33 @@ 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 -```python notest -from cachekit import cache -from contextvars import ContextVar - -tenant_context = ContextVar("tenant_id") - -@cache.secure( - ttl=3600, - master_key="a" * 64, - tenant_extractor=lambda user_id: tenant_context.get(), - backend=None -) -def get_user_data(user_id): - tenant_id = tenant_context.get() - return db.get_user_data(tenant_id, user_id) # illustrative - db not defined - -# Each tenant gets separate encryption key -# Tenant A can't decrypt Tenant B's data -tenant_context.set("tenant_1") -data_a = get_user_data(123) -tenant_context.set("tenant_2") -data_b = get_user_data(123) # Same user_id, different tenant, different encryption -``` +> [!CAUTION] +> **The per-tenant example previously shown here did not isolate tenants, and has +> been removed rather than corrected.** Run against this version, the documented +> form returned tenant A's cached value to tenant B. Two causes, and the first is +> enough on its own: +> +> - it passed `backend=None`, so nothing was serialized, no key was derived and no +> encryption ran at all (see the warning at the top of this page); and +> - the cache key carries **no tenant component** — the key is +> `ns:{ns}:func:{mod.fn}:args:{hash}:{flags}` — so both tenants address the same +> entry, and separation depends entirely on the decrypt step failing. +> +> Supplying a real backend is **not** by itself a sufficient correction: with a +> backend and the supported `ContextVarExtractor`, the same call still returned the +> first tenant's value in our check. Until that is root-caused, this page will not +> show a pattern it cannot demonstrate. `tenant_extractor` also requires an object +> implementing `.extract(args, kwargs)` — a bare `lambda` raises `AttributeError` — +> and tenant ids must be valid UUIDs. +> +> **Do not rely on `tenant_extractor` as a tenancy boundary.** Give each tenant its +> own `namespace`, or its own deployment, and treat per-tenant key derivation as +> defence in depth rather than the control that separates them. ### Key Rotation Pattern @@ -347,11 +455,15 @@ Tenant ID: tenant_context.get() Per-tenant key = HKDF(master_key, tenant_id) [Key Derivation Function, cryptographically secure] -Properties: +Properties of the derivation itself: - Tenant A's key ≠ Tenant B's key - Derived keys are unique per tenant -- Tenant A can't decrypt Tenant B's data -- Enables secure multi-tenant with single master key + +What that does NOT give you: the cache key carries no tenant component, so both +tenants address the same entry and separation rests entirely on the decrypt step +rejecting the other tenant's ciphertext. That is a fail-closed behaviour, not an +isolation boundary, and it does not hold at all when nothing is encrypted +(backend=None). See Multi-Tenant Isolation above before relying on this. ``` ### Nonce Generation (Uniqueness) @@ -364,7 +476,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 @@ -381,7 +493,7 @@ path when encryption is configured: ```text Handler configured with encryption: entry header claims encrypted → authenticated decrypt (AAD + GCM tag verified) - entry header claims plaintext → SerializationError (fail closed, entry evicted) + entry header claims plaintext → SerializationError (plaintext never returned; miss + evict, independent of `fail_closed`) ``` The plaintext deserializer is unreachable on an encryption-enabled handler, regardless @@ -407,6 +519,18 @@ Relocating these fields would be a cross-SDK wire-format change owned by the [protocol spec](https://github.com/cachekit-io/protocol); the Python SDK documents the exposure rather than diverging from the shared frame format. +Beyond the frame header, the **cache key itself is cleartext** — on the CachekitIO backend +it travels percent-encoded in the URL path (`/v1/cache/{key}`). The key carries the +namespace and the function's `module.qualname` plus an unkeyed, unsalted blake2b-256 of +the arguments (`ns:{ns}:func:{mod.fn}:args:{64-hex}:{flags}`), so over a small or known +argument space the hash is offline-enumerable: a backend operator can learn *which* record +was accessed, when, and how often, without decrypting anything. Because the key +travels in the URL path it also lands in every access log on the request path — load +balancer, CDN, TLS terminator — and persists for those retention windows, long after +the cache TTL; ciphertext length leaks approximate plaintext size too. Encryption protects +values, not access patterns — keep secrets out of namespaces and function names, and +count argument-identifiable access as metadata exposure in your threat model. + ### Corruption vs Tamper: Telemetry and Fail-Closed Mode Three failure classes surface on the decrypt read path, and cachekit distinguishes @@ -448,13 +572,14 @@ export CACHEKIT_ENCRYPTION_FAIL_CLOSED=1 ``` ```python notest +import os # Per-function (overrides the env setting in either direction) -@cache.secure(master_key="a" * 64, fail_closed=True) +@cache.secure(master_key=os.environ["CACHEKIT_MASTER_KEY"], fail_closed=True) def get_payment_token(user_id: int): ... # Or via explicit EncryptionConfig from cachekit.config.nested import EncryptionConfig -config = EncryptionConfig(enabled=True, master_key="a" * 64, +config = EncryptionConfig(enabled=True, master_key=os.environ["CACHEKIT_MASTER_KEY"], single_tenant_mode=True, fail_closed=True) ``` @@ -487,18 +612,33 @@ 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). +> +> ⚠ïļ Encryption covers cached **values** only. The cache key travels cleartext in the +> request URL and lands in backend access logs — and it carries the function's +> `module.qualname` plus an enumerable hash of its arguments, so a key like +> `get_patient_record(patient_id)` leaks who was looked up and when, on the log's +> retention window rather than the cache TTL. Assess that alongside the ciphertext. +> See [Accepted Exposure](#cleartext-frame-header-fields-accepted-exposure). + ### 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 @@ -548,7 +688,9 @@ Cached after first use: No additional overhead **Encryption + Circuit Breaker**: ```python notest -@cache.secure(ttl=300, master_key="a" * 64, backend=None) # Both enabled +from cachekit.backends.redis import RedisBackend +import os +@cache.secure(ttl=300, master_key=os.environ["CACHEKIT_MASTER_KEY"], backend=RedisBackend("redis://localhost:6379")) # Both enabled def get_data(): # Decryption error → Circuit breaker catches # Encryption happens before circuit breaker (at write time) @@ -557,11 +699,13 @@ def get_data(): **Encryption + L1 Cache**: ```python notest -@cache.secure(ttl=300, master_key="a" * 64, backend=None) +from cachekit.backends.redis import RedisBackend +import os +@cache.secure(ttl=300, master_key=os.environ["CACHEKIT_MASTER_KEY"], backend=RedisBackend("redis://localhost:6379")) def get_data(): # L1 cache enabled: stores encrypted bytes (security + performance) - # No plaintext in memory: encryption at rest in both L1 and L2 - # Decryption only at read time (< 1ms exposure) + # No plaintext at rest in L1 or L2 — decryption only at read time (< 1ms exposure). + # This holds only because a backend is configured; backend=None stores raw objects. return fetch_data() # illustrative - fetch_data not defined ``` @@ -622,7 +766,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 +775,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 [Compliance Implications](#compliance-implications)) - ✅ Works with any data type (JSON, MessagePack, DataFrames) --- diff --git a/docs/serializers/README.md b/docs/serializers/README.md index ad5a6833..aabef171 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 support a HIPAA/PCI DSS scope-reduction argument ([details](../features/zero-knowledge-encryption.md#compliance-implications)) | | [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/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/backends/provider.py b/src/cachekit/backends/provider.py index c67fe336..1db5c9ae 100644 --- a/src/cachekit/backends/provider.py +++ b/src/cachekit/backends/provider.py @@ -154,17 +154,17 @@ 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) - - Setting more than one of the four prefixed selectors (1-4) raises - ``ConfigurationError`` — auto-detection must be unambiguous; pass - ``backend=`` explicitly to override. The non-prefixed ``REDIS_URL`` is only a - fallback and never counts as a conflict (12-factor convention). + 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 of them raises ``ConfigurationError`` — auto-detection must be + unambiguous — rather than falling through to the next; pass ``backend=`` explicitly to + override. With none of them set: ``REDIS_URL``, or nothing → RedisBackend (12-factor / + localhost default). The non-prefixed ``REDIS_URL`` is only a fallback and never counts + as a conflict (12-factor convention). CachekitIO/Memcached/File backends are stateless singletons (cached). Redis backends are per-request tenant-scoped wrappers (not cached — @@ -172,8 +172,9 @@ class DefaultBackendProvider(BackendProviderInterface): single-tenant deployments (default), tenant_context is set to "default". """ - # Prefixed selectors in priority order. REDIS_URL is the implicit fallback - # and intentionally excluded so it never triggers a conflict. + # Prefixed selectors. Tuple order is NOT precedence — setting two or more raises, + # and exactly one returns that one, so order affects nothing. REDIS_URL is the implicit + # fallback and intentionally excluded so it never triggers a conflict. _SELECTORS = ( ("CACHEKIT_API_KEY", "cachekitio"), ("CACHEKIT_REDIS_URL", "redis"), diff --git a/src/cachekit/config/decorator.py b/src/cachekit/config/decorator.py index dd3cee5e..e74c84e4 100644 --- a/src/cachekit/config/decorator.py +++ b/src/cachekit/config/decorator.py @@ -392,11 +392,23 @@ 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 - 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: integrity_checking is forced to True (non-negotiable for security) + 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: With a backend configured, both L1 and L2 store encrypted bytes + (encrypt-at-rest everywhere). NOT so with backend=None: L1-only mode + stores raw Python objects and never serializes, so nothing is encrypted + and master_key is unused. Encryption requires a backend. + + 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. Args: master_key: Encryption master key (hex-encoded, minimum 32 bytes for AES-256) @@ -552,6 +564,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: 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.) 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 diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index 0b3e476a..5b451d5c 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -593,9 +593,14 @@ def create_cache_wrapper( validate_encryption_config(encryption, master_key=master_key) - # Note: L1 cache + encryption is supported. - # L1 stores encrypted bytes (not plaintext), decryption happens at read time only. - # This maintains security while enabling sub-microsecond cache hits. + # Note: L1 cache + encryption is supported WHEN A BACKEND IS CONFIGURED. + # L1 then stores encrypted bytes (not plaintext), decryption happens at read time + # only, which maintains security while enabling sub-microsecond cache hits. + # NOT so under _l1_only_mode (backend=None): that path stores raw Python object + # references via ObjectCache and never serializes, so the encryption layer is never + # reached and master_key goes unused. Documented in + # docs/features/zero-knowledge-encryption.md; a decoration-time guard is tracked + # separately (compare the interop guard above, which rejects the same combination). # Initialize feature orchestrator using EXISTING reliability/monitoring modules # Convert CircuitBreakerConfig to dict if provided