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..27cef85b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -94,6 +94,11 @@ 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 setting +# (`deployment_uuid must be a valid UUID`), 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..5ca296b5 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 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 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..6188bcee 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,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: 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..91b07ad3 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 @@ -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 @@ -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."""