diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfaecb..c74b083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). immutable policy with a context that disables hostname or certificate verification; private trust, mTLS, and explicit TLS 1.2 compatibility remain available through the documented declarative fields. +- Seal TLS trust-path, inline CA, and direct private-key-password scalars before + trusted configuration is retained. Path-like inputs must yield exact built-in + text, `ca_data` must be exact built-in text or bytes, direct passwords must be + exact built-in text/bytes/bytearray, and mutable bytearrays are copied to + immutable bytes. Callers that relied on scalar subclasses must migrate to the + supported built-in values or the explicit zero-argument password callback. - Reject non-exact integer subclasses in shared policy integer fields before retaining trusted configuration state. Exact built-in integers and existing ASCII decimal strings remain supported, preserving defaults and ranges while diff --git a/docs/research/tls-configuration.md b/docs/research/tls-configuration.md index e9cd100..5a536f8 100644 --- a/docs/research/tls-configuration.md +++ b/docs/research/tls-configuration.md @@ -40,15 +40,25 @@ that mutable object would make the transport's security properties depend on external object lifetime and mutation order. `TLSConfiguration` instead stores declarative inputs in a frozen value object. -Path-like values are normalized to deterministic text without expanding or -resolving them, and filesystem or certificate parsing is deferred to context -construction. Secret-bearing client-key passwords are excluded from -representations and equality comparisons. Mutable password bytearrays are copied -to immutable bytes at construction so later caller mutation cannot change the -identity used to build a transport. A zero-argument callback remains an explicit -trusted integration point for deferred secret retrieval. Every transport owns -the fresh context that results, eliminating post-validation caller mutation as -an authority channel. +Path-like values are observed through `os.fspath()` exactly once and accepted +only when the result is the exact built-in `str` type; ordinary `pathlib.Path` +values therefore remain supported without retaining a polymorphic text object. +Inline `ca_data` is likewise restricted to exact built-in PEM text or DER bytes +before emptiness checks or retention. Direct private-key passwords must be exact +built-in text, bytes, or bytearray values; an exact bytearray is copied to bytes +before retention. These scalar requirements prevent subclass-defined text +normalization, byte-length, or conversion behavior from participating in trusted +TLS state while deliberately avoiding path expansion, resolution, or filesystem +access during construction. + +Secret-bearing client-key passwords are excluded from representations and +equality comparisons. Built-in subclasses are rejected from the direct password +path, and an exact mutable password bytearray is copied to immutable bytes at +construction so later caller mutation cannot change the identity used to build a +transport. A zero-argument callback remains an explicit trusted integration +point for deferred secret retrieval and may execute only when Python's TLS +loader requests the password. Every transport owns the fresh context that +results, eliminating post-validation caller mutation as an authority channel. The public context helper accepts only the exact `TLSConfiguration` type before it invokes `create_ssl_context()`. Subclassing this security value object is not @@ -71,10 +81,13 @@ ignoring environment-controlled certificate configuration: `ssl.create_default_context(cafile=..., capath=..., cadata=...)` path with only the explicit custom CA source and requires at least one such source. -Empty, binary path, malformed type, and ambiguous custom-only configurations -fail at startup. Trust configuration is provider-neutral and can be injected by -a standalone application, naruon adapter, or another CWL service without -embedding provider-specific certificate logic in the transport. +Exact text paths, standard path-like objects that yield exact text, and exact +PEM text or DER bytes remain supported. Empty values, binary paths, built-in +subclasses, malformed types, and ambiguous custom-only configurations fail at +startup before subclass-controlled scalar behavior can enter frozen trust state. +Trust configuration is provider-neutral and can be injected by a standalone +application, naruon adapter, or another CWL service without embedding +provider-specific certificate logic in the transport. ## Service identity binding @@ -93,10 +106,11 @@ URL policy or DNS-pinned transport. `client_certificate_file` enables a client certificate identity. The private key may be contained in the same PEM file or supplied through -`client_private_key_file`. `client_private_key_password` accepts the same secret -shapes supported by Python's certificate loader, including a zero-argument -callable for deferred secret retrieval. A supplied bytearray is copied to bytes -before it is retained. +`client_private_key_file`. A direct `client_private_key_password` must be exact +built-in text, bytes, or bytearray; the bytearray form is copied to immutable +bytes before retention. A zero-argument callable remains a separately explicit +trusted contract for deferred secret retrieval by Python's certificate loader. +Built-in subclasses are not accepted as direct password scalars. A private key or password without a certificate is rejected before filesystem access. Certificate and key loading errors remain startup/operator errors rather @@ -113,10 +127,13 @@ default. An existing endpoint that cannot yet negotiate TLS 1.3 can opt into `minimum_version=ssl.TLSVersion.TLSv1_2`; this is an explicit compatibility exception that should be inventoried and removed after the peer is upgraded. -Applications that previously subclassed `TLSConfiguration` must migrate to an -exact instance using the documented declarative fields. Private trust roots, -mutual-TLS identities, deferred private-key passwords, and the explicit TLS 1.2 -compatibility floor remain supported without subclassing. +Applications that previously supplied `str`, `bytes`, or `bytearray` subclasses +for trust, identity, or direct private-key password scalars must migrate to exact +built-in values. Standard `pathlib.Path` objects, exact private trust roots, +mutual-TLS identities, exact direct passwords, explicit deferred password +callbacks, and the TLS 1.2 compatibility floor remain supported. Applications +that previously subclassed `TLSConfiguration` must likewise use an exact +instance with the documented declarative fields. The configuration is threaded through both public builders and both already-validated pinned-client builders. It changes only TLS trust and client diff --git a/src/egressweave/tls.py b/src/egressweave/tls.py index e242b37..7b3e388 100644 --- a/src/egressweave/tls.py +++ b/src/egressweave/tls.py @@ -40,42 +40,51 @@ def _normalize_path( field_name: str, value: str | os.PathLike[str] | None, ) -> str | None: - """Return one non-empty text path without expanding or resolving it.""" + """Return one non-empty exact text path without expanding or resolving it. + + ``os.fspath`` is observed exactly once so ordinary ``pathlib.Path`` values + remain supported. Its result must be the exact built-in ``str`` type before + text inspection or retention; a text subclass is executable behavior rather + than an immutable declarative path value. + """ if value is None: return None try: normalized = os.fspath(value) except TypeError as exc: raise TypeError(f"{field_name} must be a string or path-like object") from exc - if not isinstance(normalized, str): - raise TypeError(f"{field_name} must resolve to a text path") + if type(normalized) is not str: + raise TypeError(f"{field_name} must resolve to an exact text path") if not normalized.strip(): raise ValueError(f"{field_name} must not be empty") return normalized def _normalize_ca_data(value: str | bytes | None) -> str | bytes | None: - """Return non-empty PEM text or DER bytes for a custom trust anchor.""" + """Return exact non-empty PEM text or DER bytes for a custom trust anchor.""" if value is None: return None - if isinstance(value, str): + if type(value) is str: if not value.strip(): raise ValueError("ca_data must not be empty") return value - if isinstance(value, bytes): + if type(value) is bytes: if not value: raise ValueError("ca_data must not be empty") return value - raise TypeError("ca_data must be PEM text or DER bytes") + raise TypeError("ca_data must be exact PEM text or DER bytes") def _validate_private_key_password(password: _PrivateKeyPassword) -> None: - """Reject password values that Python's TLS loader cannot consume safely.""" + """Accept exact secret scalars or an explicit deferred password callback.""" if password is None or callable(password): return - if isinstance(password, (str, bytes, bytearray)): + if type(password) in {str, bytes, bytearray}: return - raise TypeError("client_private_key_password must be text, bytes, or a callable") + raise TypeError( + "client_private_key_password must be exact text, bytes, bytearray, " + "or a callable" + ) @dataclass(frozen=True, slots=True) @@ -93,10 +102,10 @@ class TLSConfiguration: ``client_certificate_file`` enables mutual TLS. The private key can be in that PEM file or supplied separately through ``client_private_key_file``. - An optional password may be text, bytes, a bytearray, or a zero-argument - callable accepted by :meth:`ssl.SSLContext.load_cert_chain`. Mutable - bytearrays are copied to immutable bytes during construction. Password - values are deliberately excluded from representations and equality + An optional password may be exact text, exact bytes, an exact bytearray, or a + zero-argument callable accepted by :meth:`ssl.SSLContext.load_cert_chain`. + Mutable bytearrays are copied to immutable bytes during construction. + Password values are deliberately excluded from representations and equality comparisons. """ @@ -135,7 +144,7 @@ def __post_init__(self) -> None: ) object.__setattr__(self, "ca_data", _normalize_ca_data(self.ca_data)) _validate_private_key_password(self.client_private_key_password) - if isinstance(self.client_private_key_password, bytearray): + if type(self.client_private_key_password) is bytearray: object.__setattr__( self, "client_private_key_password", diff --git a/tests/test_tls_configuration_scalar_integrity.py b/tests/test_tls_configuration_scalar_integrity.py new file mode 100644 index 0000000..4f4d123 --- /dev/null +++ b/tests/test_tls_configuration_scalar_integrity.py @@ -0,0 +1,149 @@ +"""Regression coverage for exact TLS trust and identity scalar values.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from egressweave.tls import TLSConfiguration + + +class _HostileText(str): + """Expose any subclass-controlled text normalization at the TLS boundary.""" + + def strip(self, *args: object, **kwargs: object) -> str: + """Fail if trusted configuration invokes this polymorphic method.""" + del args, kwargs + raise AssertionError("TLS configuration invoked hostile text normalization") + + +class _HostileBytes(bytes): + """Expose any subclass-controlled truth-value check on CA bytes.""" + + def __len__(self) -> int: + """Fail if trusted configuration inspects polymorphic byte length.""" + raise AssertionError("TLS configuration invoked hostile byte length") + + +class _HostilePasswordText(str): + """Represent executable behavior hidden inside password text.""" + + +class _HostilePasswordBytes(bytes): + """Represent executable behavior hidden inside password bytes.""" + + +class _HostilePasswordBuffer(bytearray): + """Expose conversion of a mutable password subclass before retention.""" + + def __bytes__(self) -> bytes: + """Fail if trusted construction converts a polymorphic buffer.""" + raise AssertionError("TLS configuration invoked hostile password conversion") + + +class _HostileTextPath: + """Return a non-exact text path from the standard path protocol.""" + + def __fspath__(self) -> str: + """Return a text subclass that must be rejected before use.""" + return _HostileText("trust/private-ca.pem") + + +@pytest.mark.parametrize( + "field_name", + [ + "ca_file", + "ca_path", + "client_certificate_file", + "client_private_key_file", + ], +) +def test_tls_paths_reject_direct_text_subclasses_before_normalization( + field_name: str, +) -> None: + """Require exact path text before any subclass-defined text method runs.""" + with pytest.raises(TypeError, match="text path"): + TLSConfiguration(**{field_name: _HostileText("identity.pem")}) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "field_name", + [ + "ca_file", + "ca_path", + "client_certificate_file", + "client_private_key_file", + ], +) +def test_tls_paths_reject_pathlike_text_subclasses_before_normalization( + field_name: str, +) -> None: + """Detach one path-protocol value but reject a polymorphic text result.""" + with pytest.raises(TypeError, match="text path"): + TLSConfiguration(**{field_name: _HostileTextPath()}) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "ca_data", + [ + _HostileText("-----BEGIN CERTIFICATE-----"), + _HostileBytes(b"deferred-der-certificate"), + ], +) +def test_ca_data_rejects_builtin_subclasses_before_inspection(ca_data: object) -> None: + """Keep polymorphic text or bytes outside immutable trust state.""" + with pytest.raises(TypeError, match="ca_data"): + TLSConfiguration(ca_data=ca_data) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "password", + [ + _HostilePasswordText("secret"), + _HostilePasswordBytes(b"secret"), + _HostilePasswordBuffer(b"secret"), + ], +) +def test_private_key_password_rejects_builtin_subclasses_before_retention( + password: object, +) -> None: + """Keep polymorphic secret scalars outside immutable TLS identity state.""" + with pytest.raises(TypeError, match="client_private_key_password"): + TLSConfiguration( + client_certificate_file="identity/client.pem", + client_private_key_password=password, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize("password", ["secret", b"secret", bytearray(b"secret")]) +def test_exact_private_key_password_scalars_remain_supported(password: object) -> None: + """Preserve Python TLS loader password shapes while freezing bytearrays.""" + configuration = TLSConfiguration( + client_certificate_file="identity/client.pem", + client_private_key_password=password, # type: ignore[arg-type] + ) + + expected = bytes(password) if type(password) is bytearray else password + assert configuration.client_private_key_password == expected + assert type(configuration.client_private_key_password) is type(expected) + + +def test_exact_tls_scalar_values_and_standard_paths_remain_supported() -> None: + """Preserve reviewed exact values and ordinary pathlib integration.""" + password_callback = lambda: "secret" + configuration = TLSConfiguration( + ca_file=Path("trust/roots.pem"), + ca_path="trust/roots", + ca_data=b"deferred-der-certificate", + client_certificate_file=Path("identity/client.pem"), + client_private_key_file="identity/client.key", + client_private_key_password=password_callback, + ) + + assert configuration.ca_file == "trust/roots.pem" + assert configuration.ca_path == "trust/roots" + assert configuration.ca_data == b"deferred-der-certificate" + assert configuration.client_certificate_file == "identity/client.pem" + assert configuration.client_private_key_file == "identity/client.key" + assert configuration.client_private_key_password is password_callback