Skip to content
Draft
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 38 additions & 21 deletions docs/research/tls-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
seonghobae marked this conversation as resolved.

The public context helper accepts only the exact `TLSConfiguration` type before
it invokes `create_ssl_context()`. Subclassing this security value object is not
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
39 changes: 24 additions & 15 deletions src/egressweave/tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
"""

Expand Down Expand Up @@ -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",
Expand Down
149 changes: 149 additions & 0 deletions tests/test_tls_configuration_scalar_integrity.py
Original file line number Diff line number Diff line change
@@ -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
Loading