Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ CACHEKIT_MASTER_KEY=<64-hex-char-key; use exactly 32 bytes>
# same per-key requirements as CACHEKIT_MASTER_KEY). Entries written under a
# listed key stay readable through the rotation window; writes always use
# CACHEKIT_MASTER_KEY. More than 3 keys, or the current master key re-appearing
# in this list, is rejected at load (a master_key= argument: when its cache is built).
# in this list, is rejected at load (a master_key= on an encrypting cache: when it is built).
CACHEKIT_PREVIOUS_MASTER_KEYS=<old-key-hex>,<older-key-hex>
# Fail closed on decrypt authentication failures (default: false = fail open/recompute).
# When true, AES-GCM auth failures and key-fingerprint mismatches raise
Expand Down
15 changes: 11 additions & 4 deletions docs/features/zero-knowledge-encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ export CACHEKIT_MASTER_KEY=<new-key-hex>
export CACHEKIT_PREVIOUS_MASTER_KEYS=<old-key-hex>
```

Rules enforced at config load — rejected, never truncated or silently fixed:
Rules enforced at config load or cache build — rejected, never truncated or silently fixed:

- **Cap**: at most 3 decrypt-only keys.
- **Per-key validation**: identical to `CACHEKIT_MASTER_KEY` (hex-encoded, at least 32 bytes; use exactly 32).
Expand Down Expand Up @@ -680,9 +680,16 @@ config = EncryptionConfig(enabled=True, master_key=secret_key,
raises `KeyringConfigurationError` (a `ValueError` subclass, exported from
`cachekit.serializers`) when the decrypt-only keyring is unusable: a previous master key
passed directly that is not exactly 32 bytes, more than three previous keys, or the current
key repeated among them. Settings check all three at load, and an encrypting cache checks its
current key against the previous keys when it is built, so behind the decorators this surfaces
only when settings are assigned after that, or on a config-drift read (below). Outside config-drift reads, the fault never
key repeated among them. Settings check all three at load and refuse an assignment that breaks
them, leaving the settings as they were (`previous_master_keys` is a tuple, so a change has to
be an assignment), and an encrypting cache checks its current key against
the previous keys when it is built, so behind the decorators this surfaces only on a config-drift
read (below). Each encryption wrapper keeps the keyring it built. A cache builds one wrapper per
tenant, on that tenant's first use and again after the wrapper is evicted from the cache's LRU, and
each build reads the settings as they stand then. So a key change reaches every wrapper built after
it, and one multi-tenant cache can run the old keyring for some tenants and the new one for others.
Restart the process to apply a key change everywhere at once.
Outside config-drift reads, the fault never
evicts and is not counted on `cachekit_decrypt_failures_total`. Direct `EncryptionWrapper` users
and callers of the `CacheOperationHandler` read and write methods receive it in both fail modes. Behind
the `@cache` decorators, a read of an existing encrypted entry raises it too, from L1 or L2 and
Expand Down
14 changes: 7 additions & 7 deletions src/cachekit/cache_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,8 +698,9 @@ def __init__(
None: no prefix, for a handler with no backend behind it.

Raises:
ConfigurationError: If encryption config is invalid (missing mode or both modes), or a
master key is present while encryption is None.
ConfigurationError: If encryption config is invalid (missing mode or both modes), a
master key is present while encryption is None, or an encrypting handler's current key
(master_key= or CACHEKIT_MASTER_KEY) is also among the previous master keys.
TypeError: If serializer_name is not a string or SerializerProtocol instance, or master_key is
bytes (pass ``key.hex()``).

Expand Down Expand Up @@ -774,11 +775,10 @@ def __init__(
encryption = False

# The keyring's forward-only rule, refused here at decoration before any backend call, whichever route the
# current key took. Settings run the same check at load, but never see master_key= and take later assignments
# unvalidated; without this, the native keyring refuses only when the first read or write builds it. An
# encryption-disabled handler never encrypts, so it spends no nonce budget: its keyring is built only for a
# config-drift read, whose fault is a miss by design. Nor does it load settings here: a plaintext handler must
# not fail on a malformed keyring setting it never uses.
# current key took. Settings run the same check at load and on assignment, but never see master_key=; without
# this, the native keyring refuses only when the first read or write builds it. An encryption-disabled handler
# never encrypts, so it spends no nonce budget: its keyring is built only for a config-drift read, whose fault
# is a miss by design. This check loads no settings for a plaintext handler.
if encryption:
settings = get_settings()
try:
Expand Down
42 changes: 38 additions & 4 deletions src/cachekit/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

from __future__ import annotations

import functools
import threading
from typing import Annotated, Any, Literal, Optional

from pydantic import (
Expand All @@ -25,16 +27,21 @@
field_validator,
model_validator,
)
from pydantic_settings import NoDecode, SettingsConfigDict
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict

from .validation import RedactingSettings, refuse_current_key_in_previous_keys
from .validation import RedactingSettings, _redacting, refuse_current_key_in_previous_keys

# Keyring cap from the protocol spec (spec/encryption.md → "Key Rotation (Keyring)"):
# at most 3 decrypt-only previous keys. Exceeding the cap is a configuration error,
# rejected at load — never silently truncated. Mirrors cachekit-core's
# MAX_DECRYPT_ONLY_KEYS, which re-validates behind the FFI boundary.
MAX_PREVIOUS_MASTER_KEYS = 3

# Serializes CachekitConfig assignments: each validates the whole state it would leave, so two
# assignments that are each valid alone (a new master_key, and that key added to previous_master_keys)
# cannot land together unchecked.
_ASSIGNMENT_LOCK = threading.Lock()


class CachekitConfig(RedactingSettings):
"""Backend-agnostic cache configuration.
Expand Down Expand Up @@ -122,6 +129,8 @@ class CachekitConfig(RedactingSettings):
# logs. errors()/json() ignore this flag; RedactingSettings sanitizes
# those surfaces.
hide_input_in_errors=True,
# Assignment after load runs every field and model validator; __setattr__ makes a refusal write nothing.
validate_assignment=True,
)

# Generic cache configuration (backend-agnostic)
Expand Down Expand Up @@ -205,8 +214,9 @@ class CachekitConfig(RedactingSettings):
default=None,
description="Master encryption key (hex-encoded; use exactly 32 bytes, 64 hex characters)",
)
previous_master_keys: Annotated[list[SecretStr], NoDecode] = Field(
default_factory=list,
# A tuple, so the keyring cannot be edited in place, where no validator runs: a change is an assignment.
previous_master_keys: Annotated[tuple[SecretStr, ...], NoDecode] = Field(
default_factory=tuple,
description=(
"Decrypt-only previous master keys for key rotation (env: "
"CACHEKIT_PREVIOUS_MASTER_KEYS, comma-separated hex). Entries written "
Expand Down Expand Up @@ -277,6 +287,30 @@ def validate_previous_master_keys(self) -> CachekitConfig:

return self

def __setattr__(self, name: str, value: Any) -> None:
"""Validate an assignment on a copy first, and write it only if the copy passes.

validate_assignment alone writes the new value before the model validator runs, so a refused keyring
assignment would stay on the instance, readable by other threads until the error surfaces. The value
is dropped in a finally: the raised error's traceback holds this frame (CWE-532).
"""
# Not a field (a subclass's private attribute, say): nothing to validate, and a validator that sets one
# must not re-enter the lock below.
if name not in type(self).model_fields:
super().__setattr__(name, value)
return
candidate = None
try:
with _ASSIGNMENT_LOCK:
candidate = self.model_copy()
# BaseSettings.__setattr__, not this override: pydantic's validated assignment, on the copy only.
_redacting(functools.partial(BaseSettings.__setattr__, candidate, name, value), type(self).__name__)
# The value the copy validated, never `value` again: a generator is spent by now, and an iterable
# may yield something else on a second pass.
super().__setattr__(name, getattr(candidate, name))
Comment thread
27Bslash6 marked this conversation as resolved.
Outdated
finally:
del value, candidate # the copy holds the refused value

def __repr__(self) -> str:
"""Return string representation with sensitive information masked.

Expand Down
16 changes: 10 additions & 6 deletions src/cachekit/serializers/encryption_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,10 +311,14 @@ def _setup_encryption(self, master_key: SecretBytes | None, previous_master_keys
# Decrypt-only previous keys from settings if not provided (key rotation,
# spec/encryption.md → "Key Rotation (Keyring)"). Settings enforce the cap
# of 3, per-key hex/length validation, and the forward-only subset check
# at load, and CacheSerializationHandler repeats the subset check for an
# encrypting cache's master_key= when it is built; the Rust Keyring
# re-validates all three behind the FFI boundary for wrappers constructed
# with explicit parameters and for settings assigned after those checks.
# at load and on every assignment, and CacheSerializationHandler repeats the
# subset check for an encrypting cache's master_key= when it is built; the
# Rust Keyring re-validates all three behind the FFI boundary for wrappers
# constructed with explicit parameters, and as defence in depth for keys
# read from settings. This wrapper keeps the keyring it builds: a later
# change to the settings reaches only wrappers built after it, and
# CacheSerializationHandler builds one per tenant, on first use and after
# LRU eviction.
# Keyring config errors below raise KeyringConfigurationError, NEVER
# EncryptionError: EncryptionError is a SerializationError, which the
# read-path policy (handle_decrypt_failure) classifies as corruption →
Expand All @@ -331,8 +335,8 @@ def _setup_encryption(self, master_key: SecretBytes | None, previous_master_keys
settings = get_settings()
previous_master_keys = [SecretBytes(bytes.fromhex(key.get_secret_value())) for key in settings.previous_master_keys]

# The hex rule again, for keys read from settings: settings validate them at load, but the settings
# object takes later assignments unvalidated, and the Rust Keyring below checks only 16 bytes.
# The hex rule again, for keys read from settings: settings validate them at load and on assignment, and
# this stays as defence in depth because the Rust Keyring below checks only 16 bytes.
for position, previous_key in enumerate(previous_master_keys):
if len(previous_key) < 32:
raise KeyringConfigurationError(
Expand Down
45 changes: 45 additions & 0 deletions tests/unit/config/test_redacting_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@
from cachekit.serializers.encryption_wrapper import EncryptionError, EncryptionWrapper

_KEY_HEX = "ab" * 32
# No two 16-character windows of it are alike, so a truncated rendering of it is still found.
_DISTINCT_KEY_HEX = bytes(range(32)).hex()

# Post-load assignments to the settings that are refused: one per field, one by the model validator.
_SETTINGS_ASSIGNMENT_REFUSALS: dict[str, tuple[str, object]] = {
"field-level": ("previous_master_keys", {_DISTINCT_KEY_HEX: 1}),
"model-level": ("previous_master_keys", [_DISTINCT_KEY_HEX, "01" * 32, "02" * 32, "03" * 32]),
}

BACKEND_CONFIGS: list[type[BaseBackendConfig]] = [
RedisBackendConfig,
Expand Down Expand Up @@ -633,6 +641,34 @@ def _cachekit_locals_holding(exc: BaseException, secret: str | bytes, *, below_c
return found


@pytest.mark.unit
class TestSettingsAssignmentRedaction:
"""A refused assignment to the loaded settings carries no route to the key it was given, whole or truncated."""

@pytest.mark.parametrize(
("field", "value"), _SETTINGS_ASSIGNMENT_REFUSALS.values(), ids=_SETTINGS_ASSIGNMENT_REFUSALS.keys()
)
def test_refused_assignment_redacts_the_key(self, field: str, value: object) -> None:
with pytest.raises(ValidationError) as exc_info:
setattr(singleton.get_settings(), field, value)

_assert_no_route_to(exc_info.value, _DISTINCT_KEY_HEX)
rendered = str(exc_info.value) + repr(exc_info.value.errors()) + exc_info.value.json()
fragments = {_DISTINCT_KEY_HEX[i : i + 16] for i in range(len(_DISTINCT_KEY_HEX) - 15)}
assert [fragment for fragment in fragments if fragment in rendered] == []

@pytest.mark.parametrize(
("field", "value"), _SETTINGS_ASSIGNMENT_REFUSALS.values(), ids=_SETTINGS_ASSIGNMENT_REFUSALS.keys()
)
def test_refused_assignment_leaves_no_frame_local(self, field: str, value: object) -> None:
"""Pydantic's own assignment frames hold the raw value too, so the guard must re-raise from above them: the
cachekit-only walk of TestEntryPointFrameLocals would pass without that."""
with pytest.raises(ValidationError) as exc_info:
setattr(singleton.get_settings(), field, value)

assert _cachekit_locals_holding(exc_info.value, _DISTINCT_KEY_HEX, below_caller=True) == []


@pytest.mark.unit
class TestRedactingSettingsFrameLocals:
"""No cachekit frame on a raised config error's traceback keeps the raw input (CWE-532)."""
Expand Down Expand Up @@ -1073,6 +1109,15 @@ def _api_key_rows(form: Callable[[str], Any]) -> dict[str, _EntryPointRow]:
ConfigurationError,
_SHORT_KEY_HEX,
),
**{
f"settings-assignment-{name}": (
{},
lambda field=field, value=value: setattr(singleton.get_settings(), field, value),
ValidationError,
_DISTINCT_KEY_HEX,
)
for name, (field, value) in _SETTINGS_ASSIGNMENT_REFUSALS.items()
},
}


Expand Down
5 changes: 3 additions & 2 deletions tests/unit/protocol/test_encryption_master_key_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@
# One refusal each: a non-hex string, or one that decodes short. Anything else (a missing key) fails the match.
DECORATOR_REFUSAL = r"CACHEKIT_MASTER_KEY must be (hex-encoded|at least 32 bytes)"
WRAPPER_REFUSAL = r"Invalid master key format|Master key must be at least 32 bytes"
# The refusal each keyring reject row gets at decoration, from the settings validator, on both current-key routes.
# The refusal each keyring reject row gets at decoration from the settings validator; _keyring_refusal names the one
# exception, a repeat row on master_key_argument.
_SETTINGS_REFUSAL = r"(?s)^1 validation error for CachekitConfig\n.*Value error, "
_REPEAT_REFUSAL = "master_key must not appear in previous_master_keys"
KEYRING_REFUSALS = {
Expand All @@ -88,7 +89,7 @@


def _keyring_refusal(name: str, route: str) -> tuple[type[Exception], str]:
"""The settings never see a master_key=, so the decorator's handler refuses its repeat, as it does a bad key."""
"""The settings never see a master_key=, so the decorator's handler refuses its repeat when it is built."""
if name in REPEAT_ROWS and route == "master_key_argument":
return ConfigurationError, f"^{_REPEAT_REFUSAL}: "
return ValidationError, KEYRING_REFUSALS[name]
Expand Down
Loading
Loading