diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 057c611e..fba3ea2d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -314,7 +314,7 @@ Used for security properties in `tests/unit/test_security_properties.py`: |:---------|:----------| | Encryption roundtrip | `decrypt(encrypt(data)) == data` | | Compression integrity | `decompress(compress(data)) == data` | -| Tenant isolation | Different keys for different tenants | +| Per-tenant key derivation | Different derived keys for different tenants | ```python from hypothesis import given, strategies as st diff --git a/README.md b/README.md index 5cbeb7a2..1fd20fde 100644 --- a/README.md +++ b/README.md @@ -452,16 +452,22 @@ info = expensive_func.cache_info() ### Environment Variables ```bash +# Backend selection: set exactly ONE of CACHEKIT_REDIS_URL, CACHEKIT_API_KEY, +# CACHEKIT_MEMCACHED_SERVERS, CACHEKIT_FILE_CACHE_DIR. Two or more is a ConfigurationError at +# first call, and decorators relying on env auto-detection run uncached (docs/backends/README.md). +# REDIS_URL is only a fallback and never conflicts. + # Redis Connection (priority: CACHEKIT_REDIS_URL > REDIS_URL) CACHEKIT_REDIS_URL="redis://localhost:6379" # Primary (preferred) REDIS_URL="redis://localhost:6379" # Fallback # CachekitIO SaaS Backend (closed beta — request access at cachekit.io) -CACHEKIT_API_KEY="your-api-key" # For @cache.io() — or pass api_key= directly # pragma: allowlist secret +# For @cache.io() next to Redis, pass api_key= from your secret store instead. +# CACHEKIT_API_KEY="your-api-key" # pragma: allowlist secret CACHEKIT_API_URL="https://api.cachekit.io" # Default SaaS endpoint # Memcached Backend (optional: pip install cachekit[memcached]) -CACHEKIT_MEMCACHED_SERVERS='["mc1:11211", "mc2:11211"]' # Default: 127.0.0.1:11211 +# CACHEKIT_MEMCACHED_SERVERS='["mc1:11211", "mc2:11211"]' # Default: 127.0.0.1:11211 CACHEKIT_MEMCACHED_CONNECT_TIMEOUT=2.0 # Default: 2.0 seconds CACHEKIT_MEMCACHED_TIMEOUT=1.0 # Default: 1.0 seconds CACHEKIT_MEMCACHED_KEY_PREFIX="myapp:" # Default: "" (none) @@ -471,9 +477,6 @@ CACHEKIT_MAX_VALUE_SIZE=104857600 CACHEKIT_ARROW_COMPRESSION=zstd ``` -> [!NOTE] -> If both `CACHEKIT_REDIS_URL` and `REDIS_URL` are set, `CACHEKIT_REDIS_URL` takes precedence. - --- ## Development diff --git a/SECURITY.md b/SECURITY.md index e1d6498d..0f3224ab 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -145,15 +145,15 @@ downstream of that bound. ### Zero-Knowledge Encryption -When enabled via `@cache.secure`, client-side AES-256-GCM encryption ensures the server never sees plaintext: +When enabled via `@cache.secure`, client-side AES-256-GCM encryption ensures the server never sees plaintext values. The cache key is not encrypted ([details](docs/features/zero-knowledge-encryption.md#cleartext-cache-key-accepted-exposure)): | Property | Guarantee | |:---------|:----------| | Encryption timing | **Before** data touches Redis | -| Server visibility | Opaque ciphertext only | +| Server visibility | Opaque ciphertext values; the cache key stays cleartext | | 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/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 7f6c14ac..6014497b 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -65,6 +65,9 @@ These functions are provided for documentation examples and return mock data: `CACHEKIT_MASTER_KEY` in the environment because an ambient key can still auto-activate encryption (deprecated) and breaks plain `@cache` fences that use `serializer="auto"` or custom serializers. +- `master_key_env` - opt-in pytest fixture for a fence that binds its own key the way an + application does (`secret_key = os.environ["CACHEKIT_MASTER_KEY"]`). Request it in the info + string, `` ```python fixture:master_key_env ``; it sets `CACHEKIT_MASTER_KEY` for that fence only. ## Skipping Examples with `notest` diff --git a/docs/api-reference.md b/docs/api-reference.md index 6ddc20d8..3ecf5d3c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -664,13 +664,7 @@ For comprehensive backend guide with examples and implementation patterns, see * ### Backend Resolution Priority -When `@cache` is used without an explicit `backend` parameter, resolution follows this priority: - -1. **Explicit backend** — `@cache(backend=...)`, then a backend inside `config=` -2. **Module-level default** — `set_default_backend(...)` -3. **Environment auto-detection** — `CACHEKIT_API_KEY`, `CACHEKIT_REDIS_URL`, `CACHEKIT_MEMCACHED_SERVERS` or `CACHEKIT_FILE_CACHE_DIR`, with `REDIS_URL` as a fallback - -Examples and the auto-detection table: **[Backend Resolution Priority](backends/README.md#backend-resolution-priority)**. +Resolution order, examples and the auto-detection table: **[Backend Resolution Priority](backends/README.md#backend-resolution-priority)**. ### L1-Only Mode (No Backend) diff --git a/docs/backends/README.md b/docs/backends/README.md index 561ae1d3..0770e08a 100644 --- a/docs/backends/README.md +++ b/docs/backends/README.md @@ -211,26 +211,40 @@ without `backend=` pins the default when it is first seen — at decoration if already set, otherwise at first call — so the usual layout (business modules imported at the top of the file, `set_default_backend()` in `main()`) works. Later `set_default_backend()` calls do not re-point already-pinned functions. -Exception: `stale_ttl` and `@cache.io`'s default stale window validate SWR -capability at decoration, so set a CachekitIO default *before* importing modules -that use them. +Exception: an explicit `stale_ttl` validates SWR capability at decoration, so set a +CachekitIO default *before* importing modules that use it. `@cache.io` is not affected: +it builds its own `CachekitIOBackend` and never consults `set_default_backend()`. ### 3. Environment Variable Auto-Detection (Lowest Priority) If no explicit backend and no module-level default, `DefaultBackendProvider` -picks a backend from exactly one environment selector, in this order: - -| Priority | Environment variable | Backend | -|----------|-----------------------------|--------------------| -| 1 | `CACHEKIT_API_KEY` | `CachekitIOBackend` (SaaS) | -| 2 | `CACHEKIT_REDIS_URL` | Redis (tenant-scoped, keys prefixed `t:{tenant}:`) | -| 3 | `CACHEKIT_MEMCACHED_SERVERS`| `MemcachedBackend` | -| 4 | `CACHEKIT_FILE_CACHE_DIR` | `FileBackend` | -| 5 | `REDIS_URL`, or nothing set | Redis, as 2 (localhost fallback) | +picks a backend at the function's first call from the one environment selector that is +set. The four prefixed selectors are mutually exclusive, with no precedence between them: +set exactly one. + +| Environment variable | Backend | +|-----------------------------|--------------------| +| `CACHEKIT_API_KEY` | `CachekitIOBackend` (SaaS) | +| `CACHEKIT_REDIS_URL` | Redis (tenant-scoped, keys prefixed `t:{tenant}:`) | +| `CACHEKIT_MEMCACHED_SERVERS`| `MemcachedBackend` | +| `CACHEKIT_FILE_CACHE_DIR` | `FileBackend` | +| none of the above: `REDIS_URL`, or nothing set | Redis, as above (localhost fallback) | Setting more than one of the four `CACHEKIT_*` selectors is ambiguous and raises `ConfigurationError` at first call. The decorator catches it, logs a WARNING on -the `cachekit.decorators.orchestrator` logger, and runs the function uncached. +the `cachekit.decorators.orchestrator` logger, and runs the function uncached: + +```text +Cache operation 'client_creation' failed for key '': ConfigurationError +``` + +The misconfiguration never heals on its own. After 5 consecutive failures (the default) the +function's circuit breaker opens and logs one `transitioned to OPEN` WARNING on +`cachekit.reliability.circuit_breaker`. Calls keep running uncached, but no longer log each +failure at WARNING: the breaker re-probes every `recovery_timeout` (30 s by default), and each +probe logs one more `client_creation` failure and one more OPEN WARNING. The function's +`get_health_status()` reports the breaker as `open` and `check_health()` as unhealthy. + `REDIS_URL` is a 12-factor fallback and never counts as a conflict. The Redis prefix scopes L2 only. L1 is shared by every tenant in the process; see diff --git a/docs/backends/cachekitio.md b/docs/backends/cachekitio.md index ac7cf4ba..c0e69628 100644 --- a/docs/backends/cachekitio.md +++ b/docs/backends/cachekitio.md @@ -189,7 +189,7 @@ CACHEKIT_TIMEOUT=5.0 # Optional — request timeout in seconds > *cachekit.io is in closed beta — [request access](https://cachekit.io)* -Compose `@cache.secure` with `CachekitIOBackend` for end-to-end zero-knowledge encryption over managed SaaS storage. The backend stores opaque ciphertext — it never sees plaintext data or your master key. +Compose `@cache.secure` with `CachekitIOBackend` for end-to-end zero-knowledge encryption over managed SaaS storage. The backend stores opaque ciphertext values — it never sees plaintext values or your master key. The cache key stays cleartext ([details](../features/zero-knowledge-encryption.md#cleartext-cache-key-accepted-exposure)). ```python notest from cachekit import cache @@ -206,7 +206,7 @@ def get_user_profile(user_id: str) -> dict: serialize(result) -> encrypt(HKDF-derived key) -> PUT /v1/cache/{key} GET /v1/cache/{key} -> decrypt() -> deserialize() -> return result - The cachekit.io API sees only encrypted bytes. Zero-knowledge. + The cachekit.io API sees only encrypted values; the cache key in the URL path is cleartext. """ return fetch_user_from_db(user_id) ``` @@ -214,9 +214,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 — not a tenancy boundary; see [Multi-Tenant Isolation](../features/zero-knowledge-encryption.md#multi-tenant-isolation) -- 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) +- The SaaS backend stores whatever bytes arrive and never holds a key to decrypt the values +- With `@cache.secure`: the SaaS holds only ciphertext values, which supports a HIPAA/PCI DSS scope-*reduction* argument — not a guarantee, and the cache key stays cleartext; see [Compliance Implications](../features/zero-knowledge-encryption.md#compliance-implications) - Without encryption: SaaS stores plaintext, may be in compliance scope +- Pass `backend=` explicitly, as above: `@cache.secure` does not pin the SaaS on its own ([why](../features/zero-knowledge-encryption.md#cachesecure-does-not-pin-a-backend)) **Requirements**: diff --git a/docs/backends/none.md b/docs/backends/none.md index 28d1f7b8..f0c449e0 100644 --- a/docs/backends/none.md +++ b/docs/backends/none.md @@ -50,7 +50,7 @@ No network calls. No serialization to bytes. No backend initialization. ## With Intent Presets -Every preset except `secure` works with `backend=None`: +`@cache.minimal`, `@cache.production`, `@cache.dev` and `@cache.test` accept `backend=None`: ```python notest from cachekit import cache @@ -61,9 +61,11 @@ def fast_lookup(key: str) -> dict: return fetch_data(key) ``` -`@cache.secure(backend=None)` is refused at decoration time with `ConfigurationError`: -L1-only stores raw Python objects, which cannot be ciphertext. The same applies to -`encryption=True` and to an `EncryptionWrapper` serializer. +Three presets do not. `@cache.secure(master_key=â€Ķ, backend=None)` is refused at decoration +time with `ConfigurationError`: L1-only stores raw Python objects, which cannot be ciphertext. +The same applies to `encryption=True` and to an `EncryptionWrapper` serializer. `@cache.io` +builds its own backend and raises `ConfigurationError` on any `backend=`, `None` included. +`@cache.local` is always in-process and raises `TypeError` on a `backend=` argument. ## Upgrade Path @@ -90,7 +92,7 @@ No API changes. No code rewrite. Same decorator, same function signature. - Shared across processes: No (per-process only) - Persistence: No (lost on restart) - TTL support: Yes -- Encryption: No — `@cache.secure` / `encryption=True` / `EncryptionWrapper` with `backend=None` raise `ConfigurationError` (raw objects cannot be ciphertext). A fleet-wide `CACHEKIT_MASTER_KEY` does not encrypt L1-only caches either. +- Encryption: No — `@cache.secure(master_key=â€Ķ)` / `encryption=True` / `EncryptionWrapper` with `backend=None` raise `ConfigurationError` (raw objects cannot be ciphertext). A fleet-wide `CACHEKIT_MASTER_KEY` does not encrypt L1-only caches either. - Metrics: Yes (if monitoring configured) --- diff --git a/docs/comparison.md b/docs/comparison.md index f976f5f5..474ef215 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -105,7 +105,7 @@ def expensive_computation(x: int) -> dict: > - **L1+L2 caching**: L1 hits ~50ns (local memory), L1 miss → L2 Redis (~2-7ms) > - **Circuit breaker**: Redis down? Cache gracefully, don't cascade failures > - **Distributed locking**: Prevents cache stampedes across pods -> - **Encryption**: Client-side AES-256-GCM, Redis never sees plaintext +> - **Encryption**: with `@cache.secure`, client-side AES-256-GCM means Redis sees only ciphertext values (plain `@cache` does not encrypt) > - **Metrics**: Prometheus counters for hits/misses/errors ```python @@ -265,7 +265,7 @@ def get_user(id): **Why cachekit wins**: - **CachekitIOBackend**: Drop-in L2 backend backed by `api.cachekit.io` — no Redis cluster to provision, patch, or scale -- **Zero-knowledge compatible**: Pair with `@cache.secure` and the managed backend stores only ciphertext, never your data +- **Zero-knowledge compatible**: Pair with `@cache.secure` and the managed backend stores only ciphertext values, never plaintext values; the cache key stays cleartext ([details](features/zero-knowledge-encryption.md#cleartext-cache-key-accepted-exposure)) - **Same decorator API**: Swap backend by setting `CACHEKIT_API_KEY` — zero code changes ```python notest diff --git a/docs/configuration.md b/docs/configuration.md index 8fe8e58a..d89a91ee 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -369,6 +369,8 @@ def secure_function(): > [!IMPORTANT] > When multiple environment variables could apply, cachekit follows this priority order. +> The four backend selectors are the exception: they have no order, so set exactly one +> ([details](backends/README.md#3-environment-variable-auto-detection-lowest-priority)). ### Redis URL Priority @@ -393,7 +395,12 @@ export REDIS_URL=redis://localhost:6379/0 # Ignored - won't be used | `@cache`, `@cache.production()`, etc. | `CachekitConfig` | `CACHEKIT_REDIS_URL` / `REDIS_URL` | | `@cache.io()` | `CachekitIOBackendConfig` | `CACHEKIT_API_KEY` | -Setting `REDIS_URL` has no effect on `@cache.io()`, and setting `CACHEKIT_API_KEY` has no effect on Redis-backed decorators. +Setting `REDIS_URL` has no effect on `@cache.io()`. `CACHEKIT_API_KEY` is different: it is also +one of the backend selectors, so a decorator without `backend=` resolves to cachekit.io from it, +and with `CACHEKIT_REDIS_URL` also set it hits a selector conflict and runs uncached. To keep +Redis-backed decorators beside `@cache.io()`, pass `backend=` to them, or pass `@cache.io()` its +key as `api_key=`, read from your secret store (never a literal in source), instead of setting +`CACHEKIT_API_KEY`. ## Common Configuration Patterns diff --git a/docs/conftest.py b/docs/conftest.py index 43188071..1059b9ed 100644 --- a/docs/conftest.py +++ b/docs/conftest.py @@ -15,8 +15,24 @@ import numpy as np import pandas as pd +import pytest from cachekit import cache +from cachekit.config.singleton import reset_settings + + +@pytest.fixture +def master_key_env(monkeypatch): + """Set CACHEKIT_MASTER_KEY for one fence that reads its key from the environment. + + Opt-in per fence (```python fixture:master_key_env) so the fence can bind the key the + way an application does instead of relying on the injected secret_key. Not global: + an ambient key still auto-activates encryption (deprecated) — see secret_key below. + """ + monkeypatch.setenv("CACHEKIT_MASTER_KEY", "a" * 64) + reset_settings() + yield + reset_settings() def pytest_markdown_docs_globals(): diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index b3346ac1..546c496a 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -6,7 +6,7 @@ ## 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=secret_key) # AES-256-GCM encryption @@ -53,7 +53,7 @@ AES-256-GCM encryption ↓ Derive per-tenant key (optional) ↓ -Storage backend (ciphertext only - Redis/HTTP/Custom) +Storage backend (ciphertext values; the key stays cleartext - Redis/HTTP/Custom) ↓ On cache hit: ↓ @@ -93,9 +93,10 @@ Python object (plaintext, in-app only) # Network intercept → attacker reads plaintext credentials # With @cache.secure: -# Redis memory dump → attacker sees ciphertext only -# Redis backup → attacker sees ciphertext only -# Network intercept → attacker sees ciphertext only +# Redis memory dump → attacker sees ciphertext values +# Redis backup → attacker sees ciphertext values +# Network intercept → attacker sees ciphertext values +# (cache keys stay cleartext in all three) # Encryption key in environment → separate from data ``` @@ -147,6 +148,22 @@ on a missing key (`.secure` → `ValueError`, the encryption option → `Configu every other row can store plaintext, and the compliance argument below holds only on an explicit path. +**The key is read when the decorator is applied** — at import, for a module-level function — +not at call time. A key that arrives later (`load_dotenv()` in `main()`, a startup hook that +fetches it from a vault) is never seen by a function decorated before it: `@cache.secure` +without `master_key=` has already raised `ValueError`, and a cache with no `encryption=` stays +plaintext. It works the other way too: unsetting the variable later does not turn a +`@cache.secure` cache off, because it keeps the key it read. Load the key before the modules +that define cached functions are imported. + +> [!IMPORTANT] +> **Failing closed on a missing key is not failing closed on a bad entry.** A decrypt +> failure at read time — an AES-GCM tag mismatch from a tampered entry or the wrong key — is +> governed by a separate setting, `fail_closed`. It defers to `CACHEKIT_ENCRYPTION_FAIL_CLOSED`, +> which defaults to off, so even under `@cache.secure` such an entry is evicted and the function +> recomputes unless you opt in. See +> [Corruption vs Tamper](#corruption-vs-tamper-telemetry-and-fail-closed-mode). + ### Turning Encryption Off in an Interop Cache An interop cache (`interop=`) never decrypts stale ciphertext after `encryption=False`. Its entries @@ -190,6 +207,26 @@ export CACHEKIT_MASTER_KEY="not_hex" # Invalid export CACHEKIT_MASTER_KEY=$(openssl rand -hex 32) ``` +### `@cache.secure` Does Not Pin a Backend + +`@cache.secure` resolves its backend the way every preset does ([Backend Resolution +Priority](../backends/README.md#backend-resolution-priority)), so without an explicit backend or a +`set_default_backend()` default, the environment decides where the ciphertext goes. With `REDIS_URL` set and `CACHEKIT_API_KEY` unset, +`@cache.secure` encrypts to Redis, not to the SaaS. The values are still ciphertext; what changes +is which system holds them. + +When a particular backend is a requirement, pass it explicitly: + +```python notest +# notest: CachekitIOBackend needs the network and CACHEKIT_API_KEY +from cachekit import cache +from cachekit.backends.cachekitio import CachekitIOBackend + +@cache.secure(master_key=secret_key, backend=CachekitIOBackend(), ttl=3600) +def get_patient_record(patient_id: str): + return fetch_phi(patient_id) # illustrative - fetch_phi not defined +``` + ### Key Rotation Keeping a retiring key decrypt-only makes its entries readable; it does **not** @@ -202,19 +239,25 @@ checks — for the rotation itself. ### 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, whatever `fail_closed` says. 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 ``` +That holds for CK-framed entries. An [interop cache](interop-mode.md) stores no header, so a +plaintext entry there reaches the decrypt step and fails authentication: a miss that recomputes +by default, but with `fail_closed=True` every read of it raises `DecryptionAuthenticationError` +until it expires. Turn encryption on in an interop cache by moving the operation to a new +`namespace`, as in [Turning Encryption Off in an Interop Cache](#turning-encryption-off-in-an-interop-cache). + There is deliberately **no opt-in flag** to let an encryption-enabled reader accept plaintext entries. The frame header's `encrypted` flag 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 +encryption-downgrade attack the 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). @@ -273,10 +316,10 @@ profile = get_user_profile(123) ### Encrypted JSON (Zero-Knowledge API Caching) ```python notest from cachekit import cache -from cachekit.serializers import EncryptionWrapper, OrjsonSerializer +from cachekit.serializers import OrjsonSerializer # Encrypt JSON API responses (webhooks, sessions, API keys) -@cache(serializer=EncryptionWrapper(serializer=OrjsonSerializer())) +@cache.secure(master_key=secret_key, serializer=OrjsonSerializer()) def get_api_keys(tenant_id: str): return { "api_key": "sk_live_abcdef123456", @@ -285,17 +328,17 @@ def get_api_keys(tenant_id: str): } keys = get_api_keys("customer-123") -# JSON encrypted client-side, backend never sees plaintext (illustrative) +# JSON encrypted client-side, backend never sees plaintext values (illustrative) ``` ### Encrypted DataFrames (Zero-Knowledge ML Caching) ```python notest from cachekit import cache -from cachekit.serializers import EncryptionWrapper, ArrowSerializer +from cachekit.serializers import ArrowSerializer import pandas as pd # Encrypt DataFrames with patient data, ML features, analytics -@cache(serializer=EncryptionWrapper(serializer=ArrowSerializer())) +@cache.secure(master_key=secret_key, serializer=ArrowSerializer()) def get_patient_records(hospital_id: int): # illustrative - conn not defined return pd.read_sql( @@ -305,7 +348,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 @@ -429,7 +472,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, so a reader can parse it before it has a key. @@ -448,7 +491,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, whatever `fail_closed` says) ``` The plaintext deserializer is unreachable on an encryption-enabled handler, regardless @@ -476,6 +519,25 @@ 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. +### Cleartext Cache Key (Accepted Exposure) + +The cache key is cleartext too. By default it carries the namespace (when set), the function's +`module.qualname` and an unkeyed, unsalted blake2b-256 hash of the arguments +(`[ns:{ns}:]func:{mod.fn}:args:{64-hex}:{flags}`), so over a small or guessable argument space the +hash can be enumerated offline. A custom `key=` function is not hashed: its return value becomes +the key verbatim, after the namespace (`{namespace}:{value}`, with `default` when none is set), so +never return raw identifiers or personal data from it — derive them with an HMAC whose key never +reaches the backend. + +Whoever operates the backend can therefore learn which record was read or written, when and how +often, without decrypting anything. On the CachekitIO backend the key travels percent-encoded in +the URL path (`/v1/cache/{key}`), so it also lands in access logs along the request path and stays +there for their retention period, not the cache TTL. Ciphertext length reveals the approximate +plaintext size, and because the default serializer compresses before encrypting, it also tracks +how compressible the content is. Encryption protects values, not access patterns: keep secrets +out of namespaces, function names and `key=` return values, and count argument-identifiable +access as metadata exposure in your threat model. + ### Corruption vs Tamper: Telemetry and Fail-Closed Mode Four failure classes surface on the decrypt read path, and cachekit distinguishes @@ -589,18 +651,25 @@ didn't recently disable encryption for that function, investigate. ## Compliance Implications +> [!IMPORTANT] +> Client-side encryption may *reduce* GDPR, HIPAA or PCI DSS scope, subject to assessment and +> your other controls, and only on an explicit path (see +> [Activation](#activation-the-master-key-is-a-source-not-a-switch)); it is not a compliance +> guarantee. Encryption covers values only: the cache key is cleartext (and, on CachekitIO, lands +> in access logs; see [Cleartext Cache Key](#cleartext-cache-key-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 @@ -698,19 +767,17 @@ A: Expected 100-500Ξs overhead. Profile to confirm acceptable. ## Zero-Knowledge Architecture -**Use case**: Building a caching system where the backend never sees user data. +**Use case**: Building a caching system where the backend never sees plaintext values. ### Client-Side Encryption Flow ```python notest # Client application (user's infrastructure) from cachekit import cache -from cachekit.serializers import EncryptionWrapper, OrjsonSerializer +from cachekit.backends.cachekitio import CachekitIOBackend +from cachekit.serializers import OrjsonSerializer -# Configure for HTTP API backend -@cache( - backend="https://cache.example.com/api", - serializer=EncryptionWrapper(serializer=OrjsonSerializer()) -) +# An HTTP API backend: CachekitIOBackend talks to cachekit.io over HTTPS +@cache.secure(master_key=secret_key, serializer=OrjsonSerializer(), backend=CachekitIOBackend()) def get_api_secrets(tenant_id: str): return {"api_key": "sk_live_...", "secret": "..."} # illustrative @@ -731,19 +798,17 @@ export default { const { key, value } = await request.json(); // Backend receives encrypted blob - // NEVER sees plaintext (no decryption key) + // NEVER sees plaintext values (no decryption key); the key is cleartext await KV.put(key, value); - // Compliance: GDPR, HIPAA, PCI-DSS satisfied - // Backend cannot read user data even if compromised return new Response("OK"); } } ``` **Benefits**: -- ✅ Backend compromise doesn't expose user data -- ✅ GDPR/HIPAA/PCI-DSS compliance out of the box +- ✅ Backend compromise doesn't expose cached values +- ✅ Supports a compliance scope-reduction argument on an explicit path (see [Compliance Implications](#compliance-implications)) - ✅ Works with any data type (JSON, MessagePack, DataFrames) --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 78a1d246..f73243f7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -236,8 +236,10 @@ export REDIS_URL="redis://localhost:6379" export CACHEKIT_REDIS_URL="redis://localhost:6379" export CACHEKIT_CONNECTION_POOL_SIZE=20 -# CachekitIO Cloud (closed beta) -export CACHEKIT_API_KEY=ck_your_api_key +# CachekitIO Cloud (closed beta). CACHEKIT_API_KEY is also a backend selector: set it +# INSTEAD of CACHEKIT_REDIS_URL, never both (two selectors leave caches that rely on env +# auto-detection uncached). +# export CACHEKIT_API_KEY=ck_your_api_key ``` --- diff --git a/docs/serializers/README.md b/docs/serializers/README.md index e591b018..53f39108 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 cc690955..cd0f1caa 100644 --- a/docs/serializers/encryption.md +++ b/docs/serializers/encryption.md @@ -15,27 +15,48 @@ serialize(data) → inner.serialize(data) → encrypt(bytes) → stored bytes retrieve(bytes) → decrypt(bytes) → inner.deserialize(bytes) → data ``` -The backend stores opaque ciphertext only. The master key never leaves the client. +The backend stores opaque ciphertext values only; the cache key stays cleartext (see [Cleartext Cache Key](../features/zero-knowledge-encryption.md#cleartext-cache-key-accepted-exposure)). The master key never leaves the client. ## Basic Usage -```python +On a decorated function, `@cache.secure` applies `EncryptionWrapper` for you: pass the inner +serializer as `serializer=`. Passing an `EncryptionWrapper` instance to `@cache(serializer=...)` +is not supported: that decorator never stores an entry. + +```python fixture:master_key_env +import os +import tempfile + from cachekit import cache -from cachekit.serializers import EncryptionWrapper, OrjsonSerializer +from cachekit.backends.file import FileBackend, FileBackendConfig +from cachekit.serializers import OrjsonSerializer + +# 64 hex chars from your secret store, e.g. generated once with: openssl rand -hex 32 +secret_key = os.environ["CACHEKIT_MASTER_KEY"] + +# A file backend keeps this example self-contained; production uses Redis or cachekit.io. +# Encryption needs a backend: backend=None (L1-only) stores raw objects and is refused. +backend = FileBackend(FileBackendConfig(cache_dir=tempfile.mkdtemp())) + +calls = 0 # Encrypted JSON (API responses, webhooks, session data) -# Note: EncryptionWrapper requires CACHEKIT_MASTER_KEY env var or master_key param. -# Encrypting serializers need a backend: backend=None (L1-only) stores raw objects and is refused. -@cache(serializer=EncryptionWrapper(serializer=OrjsonSerializer(), master_key=bytes.fromhex(secret_key))) +@cache.secure(master_key=secret_key, serializer=OrjsonSerializer(), backend=backend) def get_api_keys(tenant_id: str): + global calls + calls += 1 return { "api_key": "sk_live_...", "webhook_secret": "whsec_...", "tenant_id": tenant_id } -# Encrypted MessagePack (default - use @cache.secure preset) -@cache.secure(master_key=secret_key) +get_api_keys("acme") +get_api_keys("acme") # second call is served from the encrypted cache +assert calls == 1 + +# Encrypted MessagePack (the @cache.secure default serializer) +@cache.secure(master_key=secret_key, backend=backend) def get_user_ssn(user_id: int): return {"ssn": "123-45-6789", "dob": "1990-01-01"} ``` @@ -44,10 +65,10 @@ Encryption works with any serializer — including DataFrames: ```python notest from cachekit import cache -from cachekit.serializers import EncryptionWrapper, ArrowSerializer +from cachekit.serializers import ArrowSerializer # Encrypted DataFrames (patient data, ML features) -@cache(serializer=EncryptionWrapper(serializer=ArrowSerializer(), master_key=bytes.fromhex(secret_key))) +@cache.secure(master_key=secret_key, serializer=ArrowSerializer()) def get_patient_records(hospital_id: int): return pd.read_sql("SELECT * FROM patients WHERE hospital_id = ?", conn, params=[hospital_id]) ``` @@ -68,22 +89,20 @@ EncryptionWrapper defaults to StandardSerializer, which uses MessagePack for cro ## Zero-Knowledge Caching ```python notest +# notest: CachekitIOBackend needs the network and CACHEKIT_API_KEY from cachekit import cache -from cachekit.serializers import EncryptionWrapper, OrjsonSerializer +from cachekit.backends.cachekitio import CachekitIOBackend +from cachekit.serializers import OrjsonSerializer -# Client-side: Encrypt before sending to remote backend -@cache( - backend="https://cache.example.com/api", - serializer=EncryptionWrapper(serializer=OrjsonSerializer(), master_key=bytes.fromhex(secret_key)) -) +# Client-side: encrypted before it is sent to the remote backend +@cache.secure(master_key=secret_key, serializer=OrjsonSerializer(), backend=CachekitIOBackend()) 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 +# Backend receives encrypted blob, never sees plaintext values ``` -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. +With a remote backend such as cachekit.io, the backend stores only opaque ciphertext. It has no access to keys and cannot decrypt values. That supports a HIPAA/PCI DSS scope-*reduction* argument, subject to assessment and your other controls. It does not take regulated data out of scope on its own, and the cache key still travels in cleartext (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 a5f5434d..c02aee4f 100644 --- a/src/cachekit/backends/provider.py +++ b/src/cachekit/backends/provider.py @@ -154,14 +154,16 @@ 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 → Redis (tenant-scoped PerRequestRedisBackend) - 3. CACHEKIT_MEMCACHED_SERVERS → MemcachedBackend - 4. CACHEKIT_FILE_CACHE_DIR → FileBackend - 5. REDIS_URL, or nothing set → Redis, as 2 (12-factor / localhost default) - - Setting more than one of the four prefixed selectors (1-4) raises + Selection is by a single, unambiguous environment signal. The prefixed selectors + are mutually exclusive, not a precedence chain — set exactly one: + - CACHEKIT_API_KEY → CachekitIOBackend (SaaS) + - CACHEKIT_REDIS_URL → Redis (tenant-scoped PerRequestRedisBackend) + - CACHEKIT_MEMCACHED_SERVERS → MemcachedBackend + - CACHEKIT_FILE_CACHE_DIR → FileBackend + With none of them set: REDIS_URL, or nothing → Redis, as above (12-factor / + localhost default). + + Setting more than one of the four prefixed selectors 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). @@ -173,8 +175,9 @@ class DefaultBackendProvider(BackendProviderInterface): (single-tenant mode) is scoped to "default" (LAB-4773). """ - # 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: two or more set raises, so + # order never picks a winner. 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/cache_handler.py b/src/cachekit/cache_handler.py index 71915a87..a596d9a3 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -514,7 +514,7 @@ class CacheSerializationHandler: Architecture: - Serializer: Defines HOW to serialize (default/msgpack, future: pickle, json) - Encryption: Defines WHETHER to encrypt (security layer on top, orthogonal) - - Tenant extraction: For multi-tenant encryption key isolation (FAIL CLOSED) + - Tenant extraction: selects the per-tenant derived key (not a tenancy boundary; no shared-key fallback) Modes (encryption is tri-state: None=auto / True=force-on / False=hard opt-out): - encryption=None: no intent stated — plaintext. DEPRECATED: while CACHEKIT_MASTER_KEY is set and neither @@ -523,7 +523,7 @@ class CacheSerializationHandler: - encryption=False: Explicit opt-out — direct serialization (plaintext), even if a master key is set - encryption=True, tenant_extractor=None: Single-tenant encrypted (tenant_id "default" unless deployment_uuid / CACHEKIT_DEPLOYMENT_UUID is set) - - encryption=True, tenant_extractor provided: Multi-tenant encrypted (FAIL CLOSED) + - encryption=True, tenant_extractor provided: Multi-tenant encrypted (no shared-key fallback) Examples: Basic usage without encryption: @@ -589,7 +589,7 @@ def __init__( tenant_extractor: Optional TenantContextExtractor for multi-tenant encryption. Only used if encryption=True. If None: single-tenant mode (tenant_id "default" unless overridden). - If provided: multi-tenant mode (extracts tenant_id, FAIL CLOSED). + If provided: multi-tenant mode (extracts tenant_id; no shared-key fallback). single_tenant_mode: Explicitly enable single-tenant mode (requires encryption=True). Mutually exclusive with tenant_extractor. deployment_uuid: Optional explicit tenant_id override for single-tenant mode @@ -616,7 +616,7 @@ def __init__( TypeError: If serializer_name is not a string or SerializerProtocol instance. Note: - FAIL CLOSED security policy: If encryption=True and tenant_extractor provided + No shared-key fallback: If encryption=True and tenant_extractor provided but extraction fails, ValueError propagates to caller (no fallback to shared key). """ self.serializer_name = serializer_name @@ -867,7 +867,7 @@ def _get_cached_encryption_wrapper(self, tenant_id: str) -> Any: instances (which internally cache derived keys). Args: - tenant_id: Tenant identifier for key isolation + tenant_id: Tenant identifier for per-tenant key derivation Returns: Cached or newly created EncryptionWrapper instance @@ -937,14 +937,14 @@ def serialize_data( Serialized data wrapped for cache storage Raises: - ValueError: If tenant extraction fails in multi-tenant mode (FAIL CLOSED) + ValueError: If tenant extraction fails in multi-tenant mode (no shared-key fallback) ValueError: If cache_key is empty when encryption is enabled ValueError: If the serialized envelope exceeds max_value_size (CACHEKIT_MAX_VALUE_SIZE) — the L2 oversized-entry ceiling SerializationError: If serialization fails Note: - Tenant extraction uses FAIL CLOSED security policy: + Tenant extraction has no shared-key fallback: - If tenant_extractor provided: extracts tenant_id from args/kwargs or raises ValueError - If single_tenant_mode=True: uses the tenant_id resolved in __init__ (explicit UUID, else "default") @@ -981,10 +981,11 @@ def serialize_data( try: # Wrap with encryption layer if requested (defines WHETHER to encrypt) if self.encryption: - # Extract tenant_id based on configuration (FAIL CLOSED) + # Extract tenant_id based on configuration (no shared-key fallback) if self.tenant_extractor: # Multi-tenant mode: MUST extract tenant_id - # If extraction fails, ValueError bubbles up (FAIL CLOSED - no fallback) + # A failed extraction raises out of serialize_data (no shared-key fallback); the + # decorator's store path catches it, logs it and skips the write tenant_id = self.tenant_extractor.extract(args, kwargs) else: # Single-tenant mode: tenant_id resolved once in __init__ diff --git a/src/cachekit/config/decorator.py b/src/cachekit/config/decorator.py index 7e6c54f2..68d5f32b 100644 --- a/src/cachekit/config/decorator.py +++ b/src/cachekit/config/decorator.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: from cachekit.backends.base import BaseBackend + from cachekit.decorators.tenant_context import TenantContextExtractor from cachekit.serializers.base import SerializerProtocol @@ -360,18 +361,24 @@ def production(cls, **kwargs: Any) -> DecoratorConfig: return cls(**(defaults | kwargs)) @classmethod - def secure(cls, master_key: str, tenant_extractor: Callable[..., str] | None = None, **kwargs: Any) -> DecoratorConfig: + def secure(cls, master_key: str, tenant_extractor: TenantContextExtractor | 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 other regulated data (encryption can support + a compliance scope-reduction argument; it is not a compliance guarantee) 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: .secure does not pin a backend; it resolves like every preset (docs/backends/README.md, + "Backend Resolution Priority"). With REDIS_URL set and CACHEKIT_API_KEY unset, the + encrypted values go to Redis. Pass backend= when a particular backend is required. + Here backend=None is the unset default; the L1-only refusal applies to + @cache.secure(backend=None) and @cache(config=..., backend=None). Note: integrity_checking is forced to True (non-negotiable for security) Args: master_key: Encryption master key (hex-encoded, minimum 32 bytes for AES-256) - tenant_extractor: Optional tenant ID extractor for multi-tenant encryption + tenant_extractor: Optional tenant ID extractor (an object with .extract(args, kwargs)) for + per-tenant key derivation. Not a tenancy boundary: see docs/features/zero-knowledge-encryption.md **kwargs: Overrides (ttl, namespace, backend, l1, circuit_breaker, backpressure, monitoring, etc.) - integrity_checking=False is rejected; encryption= is not an override (TypeError). Default ttl=600 (protocol/spec/intent-presets.md); ttl=None = never expire. diff --git a/src/cachekit/config/nested.py b/src/cachekit/config/nested.py index bc1ecbc3..28ea5ef4 100644 --- a/src/cachekit/config/nested.py +++ b/src/cachekit/config/nested.py @@ -8,11 +8,14 @@ from __future__ import annotations import math -from collections.abc import Callable from dataclasses import dataclass, field +from typing import TYPE_CHECKING from .validation import ConfigurationError +if TYPE_CHECKING: + from cachekit.decorators.tenant_context import TenantContextExtractor + @dataclass(frozen=True) class L1CacheConfig: @@ -266,7 +269,8 @@ class EncryptionConfig: L1 can be enabled with encryption (stores encrypted bytes, not plaintext). Tenant mode is required: set single_tenant_mode=True for single-tenant or provide - a tenant_extractor callable for multi-tenant key isolation. @cache.secure() sets + a tenant_extractor (an object with .extract(args, kwargs)) for per-tenant key derivation, + which is not a tenancy boundary (docs/features/zero-knowledge-encryption.md). @cache.secure() sets single_tenant_mode automatically; if using EncryptionConfig directly (e.g. with @cache.io), you must set it explicitly. @@ -289,7 +293,7 @@ class EncryptionConfig: enabled: Tri-state encryption flag (default: None = unset). True = force-on, False = explicit opt-out. master_key: Hex-encoded master key for key derivation (required if enabled=True) - tenant_extractor: Optional callable for per-tenant key derivation (default: None) + tenant_extractor: Optional extractor with .extract(args, kwargs) for per-tenant key derivation (default: None) single_tenant_mode: Explicitly enable single-tenant mode (default: False) deployment_uuid: Optional explicit tenant_id override for single-tenant mode (default: None → CACHEKIT_DEPLOYMENT_UUID, else the protocol literal "default") @@ -339,7 +343,7 @@ class EncryptionConfig: enabled: bool | None = None master_key: str | None = field(default=None, repr=False) - tenant_extractor: Callable[..., str] | None = None + tenant_extractor: TenantContextExtractor | None = None single_tenant_mode: bool = False deployment_uuid: str | None = None fail_closed: bool | None = None diff --git a/src/cachekit/serializers/encryption_wrapper.py b/src/cachekit/serializers/encryption_wrapper.py index 24630a66..3490870d 100644 --- a/src/cachekit/serializers/encryption_wrapper.py +++ b/src/cachekit/serializers/encryption_wrapper.py @@ -1,13 +1,13 @@ """Encryption Wrapper for Zero-Knowledge Encryption Provides client-side encryption on top of any SerializerProtocol implementation. -Uses AES-256-GCM for authenticated encryption with per-tenant key isolation. +Uses AES-256-GCM for authenticated encryption with per-tenant key derivation. Architectural Note: EncryptionWrapper is a Decorator pattern implementation, not a serialization format. It wraps any SerializerProtocol (StandardSerializer, OrjsonSerializer, ArrowSerializer) and adds an encryption layer. This enables zero-knowledge caching where the backend - never sees plaintext, regardless of data type (JSON, DataFrames, MessagePack, etc.). + never sees plaintext values, regardless of data type (JSON, DataFrames, MessagePack, etc.). """ import logging @@ -61,12 +61,12 @@ class EncryptionWrapper: Features: - Client-side AES-256-GCM encryption (zero-knowledge) - Hardware-accelerated via ring library - - Per-tenant cryptographic isolation + - Per-tenant key derivation (not a tenancy boundary) - Domain separation for security - Works with ANY serializer (StandardSerializer, OrjsonSerializer, ArrowSerializer) Security Model: - - Storage backend never sees plaintext + - Storage backend never sees plaintext values - Each tenant gets different derived keys - Domain separation prevents key confusion attacks - Authentication tags prevent tampering @@ -160,7 +160,7 @@ def __init__( serializer: Any SerializerProtocol implementation to wrap with encryption. Defaults to StandardSerializer (cross-language MessagePack). master_key: 256-bit master key for encryption. If None, reads from environment. - tenant_id: Tenant identifier for key isolation + tenant_id: Tenant identifier for per-tenant key derivation fail_closed: Treat key-fingerprint mismatch as a hard authentication failure (raise DecryptionAuthenticationError before attempting decryption) instead of warn-and-attempt. This flag gates ONLY the