Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions src/cachekit/cache_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,9 @@ class CacheSerializationHandler:
- Encryption: Defines WHETHER to encrypt (security layer on top, orthogonal)
- Tenant extraction: For multi-tenant encryption key isolation (FAIL CLOSED)

Modes:
- encryption=False: Direct serialization (plaintext in cache)
Modes (encryption is tri-state: None=auto / True=force-on / False=hard opt-out):
- encryption=None: Auto-detect from CACHEKIT_MASTER_KEY (single-tenant if a key is present)
- encryption=False: Explicit opt-out — direct serialization (plaintext), even if a master key is set
- encryption=True, tenant_extractor=None: Single-tenant encrypted (nil UUID)
- encryption=True, tenant_extractor provided: Multi-tenant encrypted (FAIL CLOSED)

Expand Down Expand Up @@ -265,7 +266,7 @@ class CacheSerializationHandler:
def __init__(
self,
serializer_name: Union[str, SerializerProtocol] = "default", # type: ignore[name-defined]
encryption: bool = False,
encryption: bool | None = None,
tenant_extractor: Any | None = None,
single_tenant_mode: bool = False,
deployment_uuid: Optional[str] = None,
Expand All @@ -278,7 +279,12 @@ def __init__(
serializer_name: Serializer instance or name. Accepts either:
- String name: "default" (MessagePack), "arrow" (DataFrame zero-copy), "orjson" (JSON)
- SerializerProtocol instance: Custom serializer implementing the protocol
encryption: Enable encryption layer (wraps serializer with EncryptionWrapper)
encryption: Tri-state encryption control (wraps serializer with EncryptionWrapper):
- None (default): auto-detect from CACHEKIT_MASTER_KEY. Single-tenant mode
is auto-enabled when a master key is present.
- True: force encryption ON (requires a master key + explicit tenant mode).
- False: explicit hard opt-out. Never encrypts, even when CACHEKIT_MASTER_KEY
is set fleet-wide. This is the deliberate per-function escape hatch.
tenant_extractor: Optional TenantContextExtractor for multi-tenant encryption.
Only used if encryption=True.
If None: single-tenant mode (uses nil UUID).
Expand All @@ -305,16 +311,24 @@ def __init__(
self.enable_integrity_checking = enable_integrity_checking
self._deployment_uuid_value: Optional[str] = None

# Auto-detect encryption from CACHEKIT_MASTER_KEY when not explicitly configured.
# This is the single convergence point for ALL backends and presets.
if not encryption and master_key is None and tenant_extractor is None:
from cachekit.config.singleton import get_settings

settings = get_settings()
if settings.master_key:
encryption = True
master_key = settings.master_key.get_secret_value()
single_tenant_mode = True
# Tri-state encryption resolution. `encryption` is None/True/False:
# None -> auto-detect from CACHEKIT_MASTER_KEY (fleet-wide convergence point)
# True -> explicit force-on (validated below)
# False -> explicit hard opt-out; honored even when a master key is present
#
# Auto-detection is the ONLY path that may flip encryption on and auto-set
# single_tenant_mode. An explicit False MUST NOT be promoted to True just
# because CACHEKIT_MASTER_KEY exists (issue #128).
if encryption is None:
encryption = False
if master_key is None and tenant_extractor is None:
from cachekit.config.singleton import get_settings

settings = get_settings()
if settings.master_key:
encryption = True
master_key = settings.master_key.get_secret_value()
single_tenant_mode = True
Comment on lines +317 to +326

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve CACHEKIT_MASTER_KEY for the forced-on path as well.

This branch only materialises master_key from settings when encryption is None. In the explicit encryption=True path, EncryptionConfig.validate() can succeed on an env key, but create_cache_wrapper() still forwards config.encryption.master_key=None, so self.master_key stays unset and _get_cached_encryption_wrapper() later builds the EncryptionWrapper without a key. That breaks the advertised encryption=True + env-only behaviour.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cachekit/cache_handler.py` around lines 317 - 326, The forced-on
encryption path must also materialize the env master key: when encryption is
True (or config.encryption is set) and master_key is None and tenant_extractor
is None, read get_settings().master_key.get_secret_value() and set master_key
and single_tenant_mode just like the encryption is None branch; ensure this
change touches the same symbols used in creation flow (master_key, encryption,
tenant_extractor) so that create_cache_wrapper() and EncryptionConfig.validate()
end up forwarding a non-None master_key to
_get_cached_encryption_wrapper()/EncryptionWrapper.


self.encryption = encryption
self.tenant_extractor = tenant_extractor
Expand Down
53 changes: 45 additions & 8 deletions src/cachekit/config/nested.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,20 +290,35 @@ class EncryptionConfig:
single_tenant_mode automatically; if using EncryptionConfig directly (e.g. with
@cache.io), you must set it explicitly.

Tri-state ``enabled`` (issue #128): a plain bool cannot tell "user left it unset"
from "user explicitly disabled", so a deliberate opt-out was silently overridden by
fleet-wide CACHEKIT_MASTER_KEY auto-detection. ``enabled`` is therefore None/True/False:
- None (default): unset — defer to CACHEKIT_MASTER_KEY auto-detection downstream.
- True: force client-side encryption ON (requires master_key + tenant mode).
- False: explicit hard opt-out — never encrypt, even when a master key is present.

Attributes:
enabled: Enable client-side encryption (default: False)
master_key: Hex-encoded master key for key derivation (required if enabled)
enabled: Tri-state encryption flag (default: None = unset/auto-detect).
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)
single_tenant_mode: Explicitly enable single-tenant mode (default: False)
deployment_uuid: Optional deployment-specific UUID for single-tenant mode (default: None)

Examples:
Disabled by default (no encryption):
Unset by default (defers to auto-detection, no encryption forced):

>>> config = EncryptionConfig()
>>> config.enabled
>>> config.enabled is None
True
>>> config.validate() # No error when unset

Explicit opt-out (never encrypts, even with CACHEKIT_MASTER_KEY set):

>>> off = EncryptionConfig(enabled=False)
>>> off.enabled
False
>>> config.validate() # No error when disabled
>>> off.validate() # No error when explicitly disabled

Single-tenant encryption:

Expand All @@ -326,7 +341,7 @@ class EncryptionConfig:
cachekit.config.validation.ConfigurationError: Encryption requires explicit tenant mode...
"""

enabled: bool = False
enabled: bool | None = None
master_key: str | None = field(default=None, repr=False)
tenant_extractor: Callable[..., str] | None = None
single_tenant_mode: bool = False
Expand All @@ -335,10 +350,18 @@ class EncryptionConfig:
def validate(self) -> None:
"""Validate encryption configuration.

Only the explicit force-on state (enabled=True) requires a master key. The
unset (None) and explicit opt-out (False) states are both falsy and skip
validation — None defers to downstream auto-detection, False never encrypts.

The master key may be supplied inline or via the CACHEKIT_MASTER_KEY env var
(resolved here so force-on works fleet-wide without inlining the key, matching
the handler's own resolution).

Raises:
ConfigurationError: If encryption enabled but master_key not set
ConfigurationError: If encryption enabled but no master_key (inline or env)
"""
if self.enabled and not self.master_key:
if self.enabled and not self._resolve_master_key():
raise ConfigurationError(
"encryption.enabled=True requires encryption.master_key. "
"Set CACHEKIT_MASTER_KEY environment variable or pass master_key parameter."
Expand All @@ -357,3 +380,17 @@ def validate(self) -> None:
"Cannot use both tenant_extractor and single_tenant_mode. "
"Choose multi-tenant (tenant_extractor) OR single-tenant (single_tenant_mode=True)."
)

def _resolve_master_key(self) -> str | None:
"""Resolve the master key from the inline value, falling back to env settings.

Inline master_key takes precedence; otherwise read CACHEKIT_MASTER_KEY via the
settings singleton. Mirrors validate_encryption_config so the config-level and
handler-level views of "is a key available?" stay consistent.
"""
if self.master_key:
return self.master_key
from cachekit.config.singleton import get_settings

settings = get_settings()
return settings.master_key.get_secret_value() if settings.master_key else None
Comment on lines +384 to +396

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Audit master-key typing and unwrapping sites before changing the surface to SecretStr.
rg -n -C2 '\bmaster_key\b|SecretStr|get_secret_value\(' src/cachekit tests

Repository: cachekit-io/cachekit-py

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show EncryptionConfig type definitions + _resolve_master_key usage
sed -n '320,430p' src/cachekit/config/nested.py | cat -n

# Show validate_encryption_config master-key resolution
sed -n '1,140p' src/cachekit/config/validation.py | cat -n

# Show cache_handler master_key typing/autodetect and where it unwraps settings.master_key
sed -n '240,620p' src/cachekit/cache_handler.py | cat -n

Repository: cachekit-io/cachekit-py

Length of output: 30483


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "\. _resolve_master_key\(|_resolve_master_key\(" src/cachekit/config/nested.py src/cachekit | head -n 200

Repository: cachekit-io/cachekit-py

Length of output: 421


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show CachekitConfig.master_key definition + type
rg -n "class CachekitConfig|master_key: Optional\\[SecretStr\\]" -n src/cachekit/config/settings.py
sed -n '200,260p' src/cachekit/config/settings.py | cat -n

# Also inspect get_settings/singleton for master_key typing
rg -n "def get_settings|master_key" src/cachekit/config/singleton.py src/cachekit/config/__init__.py 2>/dev/null || true
ls src/cachekit/config | head

Repository: cachekit-io/cachekit-py

Length of output: 2719


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect singleton typing for master_key
sed -n '1,120p' src/cachekit/config/singleton.py | cat -n

# Inspect EncryptionConfig definition in nested.py (imports + field types)
sed -n '1,120p' src/cachekit/config/nested.py | cat -n
sed -n '240,430p' src/cachekit/config/nested.py | cat -n

Repository: cachekit-io/cachekit-py

Length of output: 15812


Avoid unwrapping settings.master_key to plaintext in _resolve_master_key()

src/cachekit/config/nested.py currently converts settings.master_key from SecretStr to str via get_secret_value() in _resolve_master_key() (used by EncryptionConfig.validate() for a presence check). This breaks the secret-handling rule by spreading plaintext key material across config/runtime layers. Change _resolve_master_key() (and validate()) to keep the resolved value as SecretStr (or avoid get_secret_value() entirely for the “is a key available?” check), and only unwrap to plaintext at the crypto boundary where bytes.fromhex(...) is actually required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cachekit/config/nested.py` around lines 384 - 396, The helper
_resolve_master_key currently calls settings.master_key.get_secret_value() and
returns plaintext; change _resolve_master_key to return the SecretStr (or None)
instead of unwrapping it (i.e., return settings.master_key directly) and update
EncryptionConfig.validate to test presence/emptiness using the SecretStr API (or
truthiness) without calling get_secret_value(); leave any bytes.fromhex(...) or
other plaintext conversion to the crypto boundary code that actually performs
encryption/decryption so only those functions unwrap the SecretStr. Use
get_settings(), settings.master_key, _resolve_master_key, and
EncryptionConfig.validate to locate and update the logic accordingly.

Source: Coding guidelines

5 changes: 3 additions & 2 deletions src/cachekit/config/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,15 @@ class ConfigurationError(Exception):
pass


def validate_encryption_config(encryption: bool = False, master_key: str | None = None) -> None:
def validate_encryption_config(encryption: bool | None = False, master_key: str | None = None) -> None:
"""Validate encryption configuration when encryption is enabled.

Checks for a master key: first from the explicit parameter, then from
CACHEKIT_MASTER_KEY env var via pydantic-settings.

Args:
encryption: Whether encryption is enabled. If False, no validation.
encryption: Tri-state encryption flag. Falsy (None unset / False opt-out) skips
validation; only an explicit True requires a resolvable master key.
master_key: Explicit master key (hex string). Takes precedence over env var.

Raises:
Expand Down
22 changes: 22 additions & 0 deletions src/cachekit/decorators/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,28 @@ def decorator(f: F) -> F:
existing_l1 = manual_overrides.pop("l1", L1CacheConfig())
manual_overrides["l1"] = replace(existing_l1, enabled=l1_enabled)

# Map flattened tri-state encryption flag + related kwargs to nested EncryptionConfig.
# Tri-state (issue #128): @cache(encryption=False) is a DELIBERATE opt-out that must
# survive fleet-wide CACHEKIT_MASTER_KEY auto-detection. None=auto, True=force, False=off.
#
# Scope: ONLY the bare/default decorator path (no config=, no _intent). Intent presets
# (.secure, .io, ...) own their encryption-param handling, and config= is the RORO form.
# An already-constructed EncryptionConfig passes through untouched — wrapping it again
# would nest EncryptionConfig inside EncryptionConfig.enabled.
if config is None and _intent is None:
from cachekit.config.nested import EncryptionConfig

_enc_passthrough = isinstance(manual_overrides.get("encryption"), EncryptionConfig)
_enc_keys = {"encryption", "master_key", "tenant_extractor", "single_tenant_mode", "deployment_uuid"}
if not _enc_passthrough and (_enc_keys & manual_overrides.keys()):
enc_overrides: dict[str, Any] = {}
if "encryption" in manual_overrides:
enc_overrides["enabled"] = manual_overrides.pop("encryption")
for _k in ("master_key", "tenant_extractor", "single_tenant_mode", "deployment_uuid"):
if _k in manual_overrides:
enc_overrides[_k] = manual_overrides.pop(_k)
manual_overrides["encryption"] = replace(EncryptionConfig(), **enc_overrides)

# RORO config takes highest precedence
if config is not None:
# DecoratorConfig instance provided - use it directly with overrides
Expand Down
11 changes: 8 additions & 3 deletions src/cachekit/decorators/wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ def create_cache_wrapper(
# Serialization & Security
serializer: Union[str, SerializerProtocol] = "default", # type: ignore[name-defined]
integrity_checking: bool = True,
encryption: bool = False,
encryption: bool | None = None,
tenant_extractor: TenantContextExtractor | None = None,
single_tenant_mode: bool = False,
deployment_uuid: str | None = None,
Expand Down Expand Up @@ -318,8 +318,13 @@ def create_cache_wrapper(
serializer: Serializer instance or name. Accepts either:
- String name: "default" (MessagePack), "arrow" (DataFrame zero-copy)
- SerializerProtocol instance: Custom serializer implementing the protocol
encryption: Enable zero-knowledge encryption layer (AES-256-GCM).
Orthogonal to serializer - wraps ANY serializer with encryption.
encryption: Tri-state zero-knowledge encryption control (AES-256-GCM), orthogonal
to serializer - wraps ANY serializer with encryption.
- None (default): auto-detect from CACHEKIT_MASTER_KEY (fleet-wide opt-in).
- True: force encryption ON.
- False: explicit per-function opt-out — never encrypts, even when
CACHEKIT_MASTER_KEY is set. Use to exclude a single function from
fleet-wide encryption (issue #128).
tenant_extractor: Optional tenant ID extractor for multi-tenant encryption.
Only used if encryption=True.
If None: single-tenant mode (uses nil UUID for encryption).
Expand Down
8 changes: 6 additions & 2 deletions tests/unit/config/test_nested_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,13 @@ class TestEncryptionConfig:
"""Test EncryptionConfig validation (master_key + tenant mode)."""

def test_defaults(self) -> None:
"""Test default values."""
"""Test default values.

enabled defaults to None (tri-state, issue #128): unset means "defer to
CACHEKIT_MASTER_KEY auto-detection", distinct from an explicit False opt-out.
"""
config = EncryptionConfig()
assert config.enabled is False
assert config.enabled is None
assert config.master_key is None
assert config.tenant_extractor is None
assert config.single_tenant_mode is False
Expand Down
Loading
Loading