From 09a0b27d8fa92f70e6fd5da7f5b2ae6facf7a4a9 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 11 Oct 2026 06:45:28 +1100 Subject: [PATCH 1/3] fix(config)!: refuse a malformed deployment UUID at load without quoting it CACHEKIT_DEPLOYMENT_UUID had no validator, so any string loaded, printed in the settings' repr/str/get_safe_repr(), and was quoted, with Python's own parse error, in the ConfigurationError raised when an encrypting cache was built. A master key pasted into the wrong variable ended up in logs (CWE-532). The setting now refuses what uuid.UUID() refuses, at load and on assignment, and returns every other value unchanged, so each deployment that encrypts today derives the same tenant. The handler's message for the deployment_uuid= parameter names its source only and has no exception chained to it. BREAKING CHANGE: a CACHEKIT_DEPLOYMENT_UUID that does not parse as a UUID now fails when cachekit loads its settings (at import), in any process, including one that never encrypts. It used to fail only when an encrypting cache was built. --- SECURITY.md | 2 +- docs/configuration.md | 4 + docs/error-codes.md | 15 +++ src/cachekit/cache_handler.py | 8 +- src/cachekit/config/settings.py | 24 +++++ tests/unit/config/test_redacting_settings.py | 48 +++++++++- tests/unit/test_single_tenant_mode.py | 96 +++++++++++++++++++- 7 files changed, 188 insertions(+), 9 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 3217b9a6..aaea3ca1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 | diff --git a/docs/configuration.md b/docs/configuration.md index 32b65e07..4edf3272 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -94,6 +94,10 @@ CACHEKIT_PREVIOUS_MASTER_KEYS=, # 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 variable, never the value. +CACHEKIT_DEPLOYMENT_UUID= # Fallback: REDIS_URL also supported (lower priority) REDIS_URL=redis://localhost:6379/0 diff --git a/docs/error-codes.md b/docs/error-codes.md index 3ec6573e..6d619499 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -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 key pasted into the wrong variable is not written to logs. Earlier releases quoted it. + +**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 does not accept ...`, `@cache does not accept ...` or `@cache(config=...) does not accept ...`, naming each keyword diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index 7fff596a..17b1f52e 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -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 diff --git a/src/cachekit/config/settings.py b/src/cachekit/config/settings.py index bc9b17fd..e02225ee 100644 --- a/src/cachekit/config/settings.py +++ b/src/cachekit/config/settings.py @@ -19,6 +19,7 @@ import functools import threading +import uuid from typing import Annotated, Any, Literal, Optional from pydantic import ( @@ -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. @@ -240,6 +250,20 @@ 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. + + The value is a key-derivation input: the handler normalizes it in auto mode and, in interop mode, refuses a + non-canonical form other SDKs would derive from as given. So this accepts every form those paths accept and + rewrites none. ``None`` and ``""`` stay unset (the protocol literal "default"). 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: diff --git a/tests/unit/config/test_redacting_settings.py b/tests/unit/config/test_redacting_settings.py index b6f2a484..5ac23696 100644 --- a/tests/unit/config/test_redacting_settings.py +++ b/tests/unit/config/test_redacting_settings.py @@ -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]] = [ @@ -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.""" @@ -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() diff --git a/tests/unit/test_single_tenant_mode.py b/tests/unit/test_single_tenant_mode.py index c40934b1..1499eebf 100644 --- a/tests/unit/test_single_tenant_mode.py +++ b/tests/unit/test_single_tenant_mode.py @@ -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 @@ -99,10 +101,10 @@ 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.""" + """An invalid CACHEKIT_DEPLOYMENT_UUID is refused when the settings load, before any cache is built.""" 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"): + with pytest.raises(ValidationError, match="deployment_uuid must be a valid UUID"): CacheSerializationHandler( encryption=True, single_tenant_mode=True, @@ -126,6 +128,94 @@ 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) + + def test_interop_mode_accepts_the_canonical_form(self, monkeypatch): + monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", _UUID) + reset_settings() + + handler = CacheSerializationHandler(encryption=True, single_tenant_mode=True, master_key="a" * 64, interop_mode=True) + + assert handler._single_tenant_id == _UUID + + @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.""" From 5e6375dff37e936576888e158420e3fcc1a9577a Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 11 Oct 2026 06:57:12 +1100 Subject: [PATCH 2/3] docs(config): the deployment UUID error names the setting, not the variable Also trims the validator docstring and drops two tests that duplicate existing coverage. --- docs/configuration.md | 3 ++- src/cachekit/config/settings.py | 6 ++---- tests/unit/test_single_tenant_mode.py | 18 ------------------ 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 4edf3272..27cef85b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -96,7 +96,8 @@ CACHEKIT_PREVIOUS_MASTER_KEYS=, 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 variable, never the value. +# 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= # Fallback: REDIS_URL also supported (lower priority) diff --git a/src/cachekit/config/settings.py b/src/cachekit/config/settings.py index e02225ee..6188bcee 100644 --- a/src/cachekit/config/settings.py +++ b/src/cachekit/config/settings.py @@ -255,10 +255,8 @@ class CachekitConfig(RedactingSettings): 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. - The value is a key-derivation input: the handler normalizes it in auto mode and, in interop mode, refuses a - non-canonical form other SDKs would derive from as given. So this accepts every form those paths accept and - rewrites none. ``None`` and ``""`` stay unset (the protocol literal "default"). The message names the field, - never the value: a pasted master key would otherwise ship in it (CWE-532). + 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") diff --git a/tests/unit/test_single_tenant_mode.py b/tests/unit/test_single_tenant_mode.py index 1499eebf..91b07ad3 100644 --- a/tests/unit/test_single_tenant_mode.py +++ b/tests/unit/test_single_tenant_mode.py @@ -100,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): - """An invalid CACHEKIT_DEPLOYMENT_UUID is refused when the settings load, before any cache is built.""" - with patch.dict(os.environ, {"CACHEKIT_DEPLOYMENT_UUID": "invalid-uuid"}): - reset_settings() # Clear cached settings to pick up new env var - with pytest.raises(ValidationError, match="deployment_uuid must be a valid 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 @@ -175,14 +165,6 @@ def test_interop_mode_still_refuses_a_non_canonical_form(self, monkeypatch, form with pytest.raises(ConfigurationError, match="canonical lowercase-hyphenated form"): CacheSerializationHandler(encryption=True, single_tenant_mode=True, master_key="a" * 64, interop_mode=True) - def test_interop_mode_accepts_the_canonical_form(self, monkeypatch): - monkeypatch.setenv("CACHEKIT_DEPLOYMENT_UUID", _UUID) - reset_settings() - - handler = CacheSerializationHandler(encryption=True, single_tenant_mode=True, master_key="a" * 64, interop_mode=True) - - assert handler._single_tenant_id == _UUID - @pytest.mark.parametrize("unset", [None, ""], ids=["absent", "empty"]) def test_unset_means_the_default_tenant(self, monkeypatch, unset): if unset is None: From e702a5b9a99708d45f8947c3dbc152072d993722 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 11 Oct 2026 08:57:05 +1100 Subject: [PATCH 3/3] docs(errors): limit the deployment UUID log assurance to refused values A value that parses as a UUID is a tenant name, not a secret: it shows in the settings repr and in the single-tenant INFO log. Any 32 hex digits parse, so say so and warn against setting a secret there. --- docs/error-codes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/error-codes.md b/docs/error-codes.md index 6d619499..5ca296b5 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -663,7 +663,7 @@ export CACHEKIT_REDIS_URL=redis://localhost:6379 **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 key pasted into the wrong variable is not written to logs. Earlier releases quoted it. +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