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
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ When enabled via `@cache.secure`, client-side AES-256-GCM encryption ensures the
| Activation | `@cache.secure`, or an explicit encryption option (`encryption=True` + tenant mode). The env var is a key source only, never a switch: a master key present on a cache that states no encryption intent raises `ConfigurationError` at decoration, on every preset except `@cache.secure` and `@cache.local` ([rules](docs/features/zero-knowledge-encryption.md#activation-the-master-key-is-a-source-not-a-switch)) |
| Logging | Never exposed in logs/errors |
| Derivation | HKDF with unique tenant salts |
| Single-tenant `tenant_id` | Literal `"default"` (protocol cross-SDK default) unless `deployment_uuid` / `CACHEKIT_DEPLOYMENT_UUID` is set; the same value binds HKDF and AAD |
| Single-tenant `tenant_id` | Literal `"default"` (protocol cross-SDK default) unless `deployment_uuid` / `CACHEKIT_DEPLOYMENT_UUID` is set; the same value binds HKDF and AAD. A value that is not a UUID is refused, and the error never quotes it |

</details>

Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ CACHEKIT_PREVIOUS_MASTER_KEYS=<old-key-hex>,<older-key-hex>
# DecryptionAuthenticationError to the caller instead of silently recomputing.
# Per-decorator fail_closed=True/False overrides this fleet-wide default.
CACHEKIT_ENCRYPTION_FAIL_CLOSED=false
# Single-tenant encryption tenant (default: unset = the protocol literal "default").
# Must parse as a UUID, or `import cachekit` fails, as for any malformed CACHEKIT_ variable,
# even in a process that never encrypts. The error names the setting
# (`deployment_uuid must be a valid UUID`), never the value.
CACHEKIT_DEPLOYMENT_UUID=<uuid>

# Fallback: REDIS_URL also supported (lower priority)
REDIS_URL=redis://localhost:6379/0
Expand Down
15 changes: 15 additions & 0 deletions docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,21 @@ export CACHEKIT_REDIS_URL=redis://localhost:6379

---

### Invalid deployment UUID

**Exception**: `pydantic.ValidationError` (a `ValueError`) for `CACHEKIT_DEPLOYMENT_UUID`, raised when cachekit loads its settings, which `import cachekit` does, so it fails in any process, even one that never encrypts, as any other malformed `CACHEKIT_` variable does. Assigning a bad value to `get_settings().deployment_uuid` raises the same and leaves the setting unchanged. The `deployment_uuid=` parameter raises `ConfigurationError` when an encrypting single-tenant cache is built. Earlier releases accepted any `CACHEKIT_DEPLOYMENT_UUID` at load and refused it only when an encrypting cache was built.

**Cause**: the value does not parse as a UUID. Every form Python's `uuid.UUID()` parses loads as given: upper case, braces, `urn:uuid:`, no hyphens. Interop mode also requires the canonical lowercase-hyphenated form ([interop mode](features/interop-mode.md)).

The message names the source (`deployment_uuid must be a valid UUID`, `Invalid deployment_uuid parameter (must be valid UUID)`) and never the value, so a refused value is not written to logs. Earlier releases quoted it. A cachekit master key is too long to parse, so one pasted into the wrong variable is refused. A value that parses is a tenant name, not a secret: it shows in the settings' `repr`, and an encrypting single-tenant cache logs its canonical form as `tenant_id` in the INFO record `Single-tenant mode initialized`. Any 32 hex digits parse, so never set a secret here, such as a 128-bit token.

**Solution**: set a UUID, or unset the variable to use the protocol default tenant, `"default"`.
```bash
export CACHEKIT_DEPLOYMENT_UUID=$(python -c 'import uuid; print(uuid.uuid4())')
```

---

### Unsupported keyword argument

**Message**: `The <preset> preset does not accept ...`, `@cache does not accept ...` or `@cache(config=...) does not accept ...`, naming each keyword
Expand Down
8 changes: 6 additions & 2 deletions src/cachekit/cache_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,8 +936,12 @@ def _resolve_single_tenant_id(self, provided_uuid: Optional[str]) -> tuple[str,
continue
try:
validated_uuid = str(uuid.UUID(raw))
except ValueError as e:
raise ConfigurationError(f"Invalid {source} (must be valid UUID): {raw}. Error: {e}") from e
except ValueError:
validated_uuid = None
if validated_uuid is None:
# Names the source, never the value: a key pasted in its place would ship in the message (CWE-532).
# Raised outside the except, so the parse error, which can quote the value, is not its __context__.
raise ConfigurationError(f"Invalid {source} (must be valid UUID)")
self._require_canonical_tenant_form(raw, validated_uuid, source=source)
return validated_uuid, source

Expand Down
22 changes: 22 additions & 0 deletions src/cachekit/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

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

from pydantic import (
Expand Down Expand Up @@ -46,6 +47,15 @@
_ASSIGNMENT_LOCK = threading.RLock()


def _parses_as_uuid(value: str) -> bool:
# A bool, so the caller raises outside this except: the parse error can quote the value (CWE-532).
try:
uuid.UUID(value)
except ValueError:
return False
return True


class CachekitConfig(RedactingSettings):
"""Backend-agnostic cache configuration.

Expand Down Expand Up @@ -240,6 +250,18 @@ class CachekitConfig(RedactingSettings):
),
)

@field_validator("deployment_uuid")
@classmethod
def _refuse_malformed_deployment_uuid(cls, value: Optional[str]) -> Optional[str]:
"""Refuse what ``uuid.UUID()`` refuses, at load and on assignment, and return the rest unchanged.

A key-derivation input, so no accepted form is narrowed or rewritten; ``None`` and ``""`` stay unset. The message
names the field, never the value: a pasted master key would otherwise ship in it (CWE-532).
"""
if value and not _parses_as_uuid(value):
raise ValueError("deployment_uuid must be a valid UUID")
return value

@field_validator("previous_master_keys", mode="before")
@classmethod
def _split_previous_master_keys(cls, value: Any) -> Any:
Expand Down
48 changes: 45 additions & 3 deletions tests/unit/config/test_redacting_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"field-level": ("previous_master_keys", {_DISTINCT_KEY_HEX: 1}),
"model-level": ("previous_master_keys", [_DISTINCT_KEY_HEX, "01" * 32, "02" * 32, "03" * 32]),
"mistyped-field": ("previous_master_key", _DISTINCT_KEY_HEX),
"deployment-uuid": ("deployment_uuid", _DISTINCT_KEY_HEX),
}

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


def _assert_no_fragment_of_key(rendered: str) -> None:
"""No 16-character window of ``_DISTINCT_KEY_HEX`` appears in ``rendered``: a truncated key is still a leak."""
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.unit
class TestDeploymentUuidRedaction:
"""A key pasted where the deployment UUID goes is refused without quoting it (CWE-532). The assignment route
is a row of ``_SETTINGS_ASSIGNMENT_REFUSALS``."""

@pytest.mark.parametrize(
"build",
[CachekitConfig, CachekitConfig.from_env, singleton.get_settings],
ids=["constructor", "from_env", "get_settings"],
)
def test_env_refusal_at_load_redacts_the_key(self, monkeypatch: pytest.MonkeyPatch, build: Callable[[], object]) -> None:
monkeypatch.setattr(singleton, "_settings_instance", None)
monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", _DISTINCT_KEY_HEX)
with pytest.raises(ValidationError) as exc_info:
build()

_assert_no_route_to(exc_info.value, _DISTINCT_KEY_HEX)
_assert_no_fragment_of_key(str(exc_info.value) + repr(exc_info.value.errors()) + exc_info.value.json())
[err] = exc_info.value.errors()
assert (err["loc"], err["msg"]) == (("deployment_uuid",), "Value error, deployment_uuid must be a valid UUID")
assert _cachekit_locals_holding(exc_info.value, _DISTINCT_KEY_HEX, below_caller=True) == []

def test_parameter_refusal_names_only_its_source(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The parameter never reaches the settings validator: the handler refuses it, and nothing chained to that
refusal quotes it either. Python's own UUID parse error does."""
monkeypatch.delenv("CACHEKIT_DEPLOYMENT_UUID", raising=False)
singleton.reset_settings()
with pytest.raises(ConfigurationError) as exc_info:
CacheSerializationHandler(
encryption=True, single_tenant_mode=True, master_key=_KEY_HEX, deployment_uuid=_DISTINCT_KEY_HEX
)

assert str(exc_info.value) == "Invalid deployment_uuid parameter (must be valid UUID)"
assert (exc_info.value.__cause__, exc_info.value.__context__) == (None, None)
_assert_no_fragment_of_key(str(exc_info.value))


@pytest.mark.unit
class TestSettingsAssignmentRedaction:
"""A refused assignment to the loaded settings carries no route to the key it was given, whole or truncated."""
Expand All @@ -655,9 +699,7 @@ def test_refused_assignment_redacts_the_key(self, field: str, value: object) ->
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] == []
_assert_no_fragment_of_key(str(exc_info.value) + repr(exc_info.value.errors()) + exc_info.value.json())

@pytest.mark.parametrize(
("field", "value"), _SETTINGS_ASSIGNMENT_REFUSALS.values(), ids=_SETTINGS_ASSIGNMENT_REFUSALS.keys()
Expand Down
94 changes: 83 additions & 11 deletions tests/unit/test_single_tenant_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
"""

import os
import uuid
from pathlib import Path
from unittest.mock import patch

import pytest
from pydantic import ValidationError

from cachekit.cache_handler import CacheSerializationHandler
from cachekit.config import ConfigurationError, reset_settings
from cachekit.config import ConfigurationError, get_settings, reset_settings
from cachekit.serializers.base import SerializationError
from cachekit.serializers.encryption_wrapper import KeyringConfigurationError

Expand Down Expand Up @@ -98,16 +100,6 @@ def test_env_var_used_when_no_provided_uuid(self):

assert handler._single_tenant_id == env_uuid

def test_invalid_env_var_raises_error(self):
"""Invalid UUID in environment variable should raise ConfigurationError."""
with patch.dict(os.environ, {"CACHEKIT_DEPLOYMENT_UUID": "invalid-uuid"}):
reset_settings() # Clear cached settings to pick up new env var
with pytest.raises(ConfigurationError, match="Invalid CACHEKIT_DEPLOYMENT_UUID"):
CacheSerializationHandler(
encryption=True,
single_tenant_mode=True,
)

def test_default_is_protocol_literal_and_never_machine_local(self, tmp_path, monkeypatch):
"""No explicit tenant → literal "default" (spec rule 5); the legacy persisted
``~/.cachekit/deployment_uuid`` is neither read nor written — a per-host value
Expand All @@ -126,6 +118,86 @@ def test_default_is_protocol_literal_and_never_machine_local(self, tmp_path, mon
assert legacy_file.read_text() == "770fa622-041d-63f6-c938-668877662222" # untouched, ignored


_UUID = "550e8400-e29b-41d4-a716-446655440000"
# Every form uuid.UUID() parses, as the handler did before the settings validated the value.
_NON_CANONICAL_FORMS = {
"upper-case": _UUID.upper(),
"braces": "{" + _UUID + "}",
"urn": "urn:uuid:" + _UUID,
"no-hyphens": _UUID.replace("-", ""),
}
_ACCEPTED_FORMS = {"canonical": _UUID, **_NON_CANONICAL_FORMS}
_REFUSED_FORMS = {
"not-a-uuid": "not-a-uuid",
"padded": f" {_UUID} ",
"one-digit-long": _UUID + "0",
"64-hex-key": "ab" * 32,
}


class TestDeploymentUuidSetting:
"""The setting refuses what the handler refuses, and rewrites nothing: the value is a key-derivation input."""

@pytest.mark.parametrize("form", _ACCEPTED_FORMS.values(), ids=_ACCEPTED_FORMS.keys())
def test_accepted_form_loads_unchanged_and_derives_the_same_tenant(self, monkeypatch, form):
monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", form)
reset_settings()

assert get_settings().deployment_uuid == form
handler = CacheSerializationHandler(encryption=True, single_tenant_mode=True, master_key="a" * 64)
assert handler._single_tenant_id == str(uuid.UUID(form)) == _UUID

@pytest.mark.parametrize("form", _ACCEPTED_FORMS.values(), ids=_ACCEPTED_FORMS.keys())
def test_accepted_form_assigns_unchanged(self, monkeypatch, form):
monkeypatch.delenv("CACHEKIT_DEPLOYMENT_UUID", raising=False)
reset_settings()

get_settings().deployment_uuid = form

assert get_settings().deployment_uuid == form

@pytest.mark.parametrize("form", _NON_CANONICAL_FORMS.values(), ids=_NON_CANONICAL_FORMS.keys())
def test_interop_mode_still_refuses_a_non_canonical_form(self, monkeypatch, form):
"""Other SDKs derive from the raw string, so interop mode takes only the canonical form."""
monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", form)
reset_settings()

with pytest.raises(ConfigurationError, match="canonical lowercase-hyphenated form"):
CacheSerializationHandler(encryption=True, single_tenant_mode=True, master_key="a" * 64, interop_mode=True)

@pytest.mark.parametrize("unset", [None, ""], ids=["absent", "empty"])
def test_unset_means_the_default_tenant(self, monkeypatch, unset):
if unset is None:
monkeypatch.delenv("CACHEKIT_DEPLOYMENT_UUID", raising=False)
else:
monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", unset)
reset_settings()

assert get_settings().deployment_uuid == unset
handler = CacheSerializationHandler(encryption=True, single_tenant_mode=True, master_key="a" * 64)
assert handler._single_tenant_id == CacheSerializationHandler.DEFAULT_TENANT_ID

@pytest.mark.parametrize("form", _REFUSED_FORMS.values(), ids=_REFUSED_FORMS.keys())
def test_refused_form_fails_at_load(self, monkeypatch, form):
with pytest.raises(ValueError):
uuid.UUID(form) # the handler refused it too
monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", form)
reset_settings()

with pytest.raises(ValidationError, match="deployment_uuid must be a valid UUID"):
get_settings()

@pytest.mark.parametrize("form", _REFUSED_FORMS.values(), ids=_REFUSED_FORMS.keys())
def test_refused_assignment_writes_nothing(self, monkeypatch, form):
monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", _UUID)
reset_settings()

with pytest.raises(ValidationError, match="deployment_uuid must be a valid UUID"):
get_settings().deployment_uuid = form

assert get_settings().deployment_uuid == _UUID


class TestTenantIDUsage:
"""The resolved tenant_id is what HKDF derives from AND what the AAD binds."""

Expand Down
Loading